diff --git a/CHANGELOG.md b/CHANGELOG.md index 5381e74e9..28c374b02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -186,6 +186,16 @@ ## 🌟 New Features +- **Channel groups with stream failover.** A target with `options.channel_groups.enabled` collapses equal live + channels from several inputs (matched by caption, name, title or EPG id) into one channel. A start picks the best + member by QoS history, probed quality and free provider capacity and moves on to the next member when one fails. + While a client watches an MPEG-TS stream, a member that ends, fails, stops sending data or is too slow is replaced + underneath the open response, spliced at PAT/PMT and a keyframe; this works for direct and shared streams. Failing + members and inputs are paused for a while, and an outage limit and a switch limit bound the switching. The settings + live once in `config.yml` (`channel_groups`, Web UI: Settings → Channel groups); a target turns the feature on and + can override matching, source order, grace and failover on its own Channel groups page. Off by default. See + [Channel Groups & Stream Failover](docs/src/configuration/channel-groups.md). + - **Maintenance mode.** Inputs can be put into maintenance from the new dashboard card or through `/api/v1/maintenance` (`*` addresses all inputs). New streams of an input in maintenance get the new `maintenance.ts` custom video; playlist and EPG updates keep the last persisted state, and probes, metadata @@ -1599,6 +1609,18 @@ ## ⚙️ New Settings +- **config.yml (`channel_groups`)**: + - Added the global channel group settings: `match_by`, `match_as_ascii`, `match_normalize`, `epg_conflict`, + `name_strip_quality`, `policy`, `allow_provider_grace`, `start` (`timeout_secs`, `max_attempts`), `failover` + (`enabled`, `no_data_timeout_secs`, `slow_source_pct`, `slow_source_secs`, `max_outage_secs`, + `same_source_retries`, `stable_after_secs`, `max_switches`, `max_switches_window_secs`, `audio_language`) and + `circuit` (`source_failures`, `cooldown_secs`, `cooldown_max_secs`, `input_failures`, `input_window_secs`). See + [Channel Groups & Stream Failover](docs/src/configuration/channel-groups.md). + +- **source.yml (target `options`)**: + - Added `channel_groups` (default off): `enabled` plus the optional overrides `match_by`, `match_as_ascii`, + `epg_conflict`, `name_strip_quality`, `policy`, `allow_provider_grace` and `failover`. + - **config.yml (`reverse_proxy.stream`)**: - `hls_wrap_media_playlist` (default `true`): answers a proxied live HLS entry request whose provider returns a media playlist with a single-variant master playlist, so playlist refreshes keep their provider account across client diff --git a/Cargo.lock b/Cargo.lock index fafe2b946..20db13199 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4917,6 +4917,8 @@ dependencies = [ "lru", "rand 0.9.5", "reqwest", + "serde", + "serde_json", "shared", "tempfile", "thiserror 2.0.20", diff --git a/README.md b/README.md index 4ff4e8d40..bdc3c85c9 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ Tuliprox is not just a metadata processor — it actively manages stream deliver - **Redirect Mode** — avoid proxy overhead where direct delivery is preferred - **Shared MPEG-TS Streams** — several viewers can reuse one upstream provider stream - **Shared HLS Sessions** — reuse one server-side HLS session while retaining individual user leases and accounting +- **Channel Groups** — one channel per live program across inputs, with source selection and runtime failover - **HLS Segment Cache & Prefetch** — fetch manifests, initialization maps, and segments once and serve them locally - **HLS Recovery** — optional manifest recovery, bounded segment repair, stale-origin recovery, and diagnostics for difficult upstream streams - **Universal Catchup** — expose provider archive/catchup through regular M3U-compatible `.ts` URLs @@ -166,6 +167,9 @@ reconnects, probes, and background jobs. Upstream providers are not always reliable. Tuliprox is designed to preserve usable service when they fail partially or temporarily. - Automatic provider URL failover on timeouts and server errors +- **Channel groups with stream failover** collapse the same live channel from several providers or accounts into one + channel; playback starts on the best reachable source and, for MPEG-TS, switches to another source while the client + keeps watching - Choose whether a provider resumes from the last working URL or restarts from the first URL on future requests - `provider://` references resolve to the currently active provider URL - Asynchronous DNS resolution and caching diff --git a/backend/app/Cargo.toml b/backend/app/Cargo.toml index 672eb919a..0279578c0 100644 --- a/backend/app/Cargo.toml +++ b/backend/app/Cargo.toml @@ -109,6 +109,8 @@ tuliprox-btree = { workspace = true, features = ["test-support"] } tuliprox-mpegts = { workspace = true, features = ["test-support"] } # API tests build a GeoIp that answers a fixed country. tuliprox-repository = { workspace = true, features = ["test-support"] } +# Channel group selection tests seed pending provider releases. +tuliprox-session = { workspace = true, features = ["test-support"] } tokio = { workspace = true, features = ["test-util", "macros"] } rcgen = "0.14.9" rustls = "0.23.45" diff --git a/backend/app/src/api/api_utils/channel_group.rs b/backend/app/src/api/api_utils/channel_group.rs new file mode 100644 index 000000000..5f73aff7d --- /dev/null +++ b/backend/app/src/api/api_utils/channel_group.rs @@ -0,0 +1,761 @@ +use super::{create_stream_response_details, resolve_initial_stalker_playback_url}; +use crate::{ + api::model::{AppState, BoxedProviderStream, ProviderStreamCustomReason, StreamDetails, StreamError}, + auth::Fingerprint, + model::{ConfigInput, ConfigTarget, ProxyUserCredentials}, +}; +use axum::http::HeaderMap; +use bytes::Bytes; +use futures::StreamExt; +use shared::model::{ + ChannelGroupPlayback, ChannelGroupPolicy, ChannelGroupStartConfig, PlaylistItemType, StreamChannel, + UserConnectionPermission, VirtualId, +}; +use std::{collections::VecDeque, future::Future, net::SocketAddr, sync::Arc, time::Duration}; +use tokio::sync::Notify; +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, FailureClass, HalfOpenGuard, MemberHealthIndex, RankedCandidate}, + stream_options::StreamOptions, +}; + +pub(crate) enum PlaybackSource<'a> { + Single { input: &'a Arc, stream_url: &'a str }, + Group { group: Arc, mode: SelectionMode, prefer: Option }, +} + +pub(crate) struct ResolvedGroupPlayback { + pub acquired: AcquiredMember, + pub member: GroupMember, + pub input: Arc, + pub stream_url: Arc, +} + +pub(crate) fn group_shared_key(target_id: u16, group_vid: u32) -> String { format!("group:{target_id}:{group_vid}") } + +pub(crate) struct GroupSelectionRequest<'a> { + pub target_name: &'a Arc, + pub group: &'a ChannelGroupRecord, + pub policy: ChannelGroupPolicy, + pub mode: SelectionMode, + pub start: ChannelGroupStartConfig, + pub addr: &'a SocketAddr, + pub username: &'a str, + pub priority: i8, + pub kind: ConnectionKind, + pub lease: Option>, + pub allow_provider_grace: bool, + pub exclude: &'a [usize], + pub demoted_inputs: &'a [Arc], + pub prefer: Option, + /// Recording proxy users play inputs in maintenance, as for single-source playback. + pub bypass_maintenance: bool, + /// Runtime switches must not evict other playbacks; only a start may preempt. + pub allow_preempt: bool, +} + +impl<'a> GroupSelectionRequest<'a> { + /// A start selection: live mode, grace and preemption allowed, nothing excluded. + pub(crate) fn new( + target_name: &'a Arc, + group: &'a ChannelGroupRecord, + start: ChannelGroupStartConfig, + addr: &'a SocketAddr, + username: &'a str, + priority: i8, + kind: ConnectionKind, + ) -> Self { + Self { + target_name, + group, + policy: group.policy, + mode: SelectionMode::Live, + start, + addr, + username, + priority, + kind, + lease: None, + allow_provider_grace: true, + exclude: &[], + demoted_inputs: &[], + prefer: None, + bypass_maintenance: false, + allow_preempt: true, + } + } +} + +pub(crate) struct AcquiredMember { + pub index: usize, + pub handle: ManagedProviderHandle, + pub tier: CapacityTier, + pub half_open: Option, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum GroupSelectionFailure { + Exhausted, + NoCandidate, + /// Every remaining candidate's input is in maintenance. + Maintenance, +} + +pub(crate) async fn acquire_group_member( + app_state: &Arc, + req: GroupSelectionRequest<'_>, +) -> Result { + acquire_group_member_inner(app_state, req, None::)>).await +} + +#[cfg(test)] +pub(crate) async fn acquire_group_member_with_race)>( + app_state: &Arc, + req: GroupSelectionRequest<'_>, + after_probe: F, +) -> Result { + acquire_group_member_inner(app_state, req, Some(after_probe)).await +} + +async fn acquire_group_member_inner)>( + app_state: &Arc, + req: GroupSelectionRequest<'_>, + after_probe: Option, +) -> Result { + let budget_secs = req.start.timeout_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, + req: &GroupSelectionRequest<'_>, + cand_idx: usize, + first_remaining_idx: usize, + all_circuits_open: bool, +) -> (bool, Option) { + 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( + app_state: &Arc, + req: &GroupSelectionRequest<'_>, + remaining: &mut Vec, + probed_tiers: &mut Vec<(usize, CapacityTier)>, + all_circuits_open: bool, + filter_tier: F, + probe_tier: &dyn Fn(usize) -> CapacityTier, +) -> Option +where + F: Fn(CapacityTier) -> Option, +{ + 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, + req: &GroupSelectionRequest<'_>, + remaining: &[RankedCandidate], + all_circuits_open: bool, +) -> Option { + 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)>( + app_state: &Arc, + req: GroupSelectionRequest<'_>, + after_probe: Option, +) -> Result { + 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); + } + if !req.bypass_maintenance { + ranked.retain(|c| !app_state.maintenance.is_in_maintenance(&req.group.members[c.index].input_name)); + if ranked.is_empty() { + return Err(GroupSelectionFailure::Maintenance); + } + } + + 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 + // The own slot being released is reused through grace, unless grace is not allowed at all. + let own_release_grace = req.allow_provider_grace; + let tier1_match = |tier| match tier { + CapacityTier::OwnRelease => Some(own_release_grace), + 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 !req.allow_preempt { + return Err(GroupSelectionFailure::Exhausted); + } + 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, + group: Arc, + req: GroupSelectionRequest<'_>, +) -> Result { + let max_attempts = u32::from(req.start.max_attempts.max(1)); + let mut attempts = 0; + let start_time = std::time::Instant::now(); + let budget = Duration::from_secs(req.start.timeout_secs); + let mut exclude: Vec = req.exclude.to_vec(); + let demoted_inputs: Vec> = req.demoted_inputs.to_vec(); + + while attempts < max_attempts && (req.start.timeout_secs == 0 || start_time.elapsed() < budget) { + attempts += 1; + let attempt_req = GroupSelectionRequest { + mode: req.mode.clone(), + exclude: &exclude, + demoted_inputs: &demoted_inputs, + prefer: if attempts == 1 { req.prefer } else { None }, + ..req + }; + + 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 { + record_member_failure(&app_state.member_health, &member, acquired.half_open, FailureClass::Stream); + drop(acquired.handle); + exclude.push(acquired.index); + continue; + }; + + let stream_url = Arc::clone(&member.url); + return Ok(ResolvedGroupPlayback { acquired, member, input: member_input, stream_url }); + } + + Err(GroupSelectionFailure::Exhausted) +} + +pub(crate) fn record_member_failure( + health: &MemberHealthIndex, + member: &GroupMember, + guard: Option, + class: FailureClass, +) { + let now = std::time::Instant::now(); + match guard { + Some(guard) => health.record_classified_failure_with(guard, class, now), + None => health.record_classified_failure(&member_key(member), class, now), + } +} + +pub(crate) fn record_member_success(health: &MemberHealthIndex, member: &GroupMember, guard: Option) { + match guard { + Some(guard) => health.record_success_with(guard), + None => health.record_success(&member_key(member)), + } +} + +/// Selection settings of `target`, defaults when the target has no `channel_groups` block. +/// The `config.yml` channel group settings with the overrides of `target`. +pub(crate) fn group_playback_settings(app_state: &AppState, target: &ConfigTarget) -> ChannelGroupPlayback { + let target_cfg = target.options.as_ref().and_then(|options| options.channel_groups.as_ref()); + app_state.app_config.config.load().channel_groups.playback(target_cfg) +} + +/// First member whose input is configured, with its URL; it stands in for the group until a +/// member is selected. +pub(crate) fn group_representative( + app_state: &AppState, + group: &ChannelGroupRecord, +) -> Option<(Arc, Arc)> { + group.members.iter().find_map(|member| { + app_state.app_config.get_input_by_name(&member.input_name).map(|input| (input, Arc::clone(&member.url))) + }) +} + +/// The client channel of a group playback carrying the identity of `member`. +pub(crate) fn member_stream_channel( + base: &StreamChannel, + group: &ChannelGroupRecord, + member: &GroupMember, +) -> StreamChannel { + let mut channel = base.clone(); + channel.virtual_id = member.member_vid.get(); + channel.group_virtual_id = Some(group.group_vid.get()); + channel.input_name = Arc::clone(&member.input_name); + channel.item_type = member.item_type; + channel.cluster = member.cluster; + channel.title = Arc::clone(&member.title); + channel.url = Arc::clone(&member.url); + channel.provider_id = member.provider_id; + channel +} + +pub(crate) const PROBE_MIN_BYTES: usize = 3 * tuliprox_mpegts::packet::TS_PACKET_SIZE; +const PROBE_MAX_EMPTY_CHUNKS: u32 = 32; +/// Upper bound for the provider response of one member; the start budget can only shorten it. +pub(crate) const PROVIDER_OPEN_TIMEOUT: Duration = Duration::from_secs(4); +/// Upper bound for the first bytes of one member; the start budget can only shorten it. +pub(crate) const PROBE_FIRST_BYTES_TIMEOUT: Duration = Duration::from_secs(3); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ProbedPayload { + Ts, + Playlist, +} + +fn starts_with_playlist_tag(head: &[u8]) -> bool { + let head = head.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(head); + let start = head.iter().position(|byte| !byte.is_ascii_whitespace()).unwrap_or(head.len()); + head[start..].starts_with(b"#EXTM3U") +} + +/// Reads the head of a member body until it shows MPEG-TS sync or an HLS/DASH playlist, bounded by +/// `deadline`. Returns the body with the read chunks put back in front. +pub(crate) async fn probe_member_payload( + mut stream: BoxedProviderStream, + adaptive_hint: bool, + deadline: tokio::time::Instant, +) -> Result<(BoxedProviderStream, ProbedPayload), ()> { + if adaptive_hint { + return Ok((stream, ProbedPayload::Playlist)); + } + let mut chunks: Vec = Vec::new(); + let mut head: Vec = Vec::with_capacity(PROBE_MIN_BYTES); + let mut empty_chunks = 0u32; + while head.len() < PROBE_MIN_BYTES { + match tokio::time::timeout_at(deadline, stream.next()).await { + Ok(Some(Ok(chunk))) if chunk.is_empty() => { + empty_chunks += 1; + if empty_chunks > PROBE_MAX_EMPTY_CHUNKS { + return Err(()); + } + } + Ok(Some(Ok(chunk))) => { + let take = chunk.len().min(PROBE_MIN_BYTES - head.len()); + head.extend_from_slice(&chunk[..take]); + chunks.push(chunk); + } + Ok(None) => break, + Ok(Some(Err(_))) | Err(_) => return Err(()), + } + } + let payload = if starts_with_playlist_tag(&head) { + ProbedPayload::Playlist + } else if tuliprox_mpegts::packet::ts_sync_offset(&head) + .is_some_and(|offset| head.len() > offset + tuliprox_mpegts::packet::TS_PACKET_SIZE) + { + // Two sync bytes one packet apart: a single 0x47 in a short text body proves nothing. + ProbedPayload::Ts + } else { + return Err(()); + }; + let stream = if chunks.is_empty() { stream } else { PrefixedStream::new(chunks, stream).boxed() }; + Ok((stream, payload)) +} + +/// A provider body with the chunks a probe already read put back in front. +pub(crate) struct PrefixedStream { + head: VecDeque, + inner: BoxedProviderStream, +} + +impl PrefixedStream { + pub(crate) fn new(head: Vec, inner: BoxedProviderStream) -> Self { Self { head: head.into(), inner } } +} + +impl futures::Stream for PrefixedStream { + type Item = Result; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let this = self.get_mut(); + match this.head.pop_front() { + Some(chunk) => std::task::Poll::Ready(Some(Ok(chunk))), + None => this.inner.as_mut().poll_next(cx), + } + } +} + +/// Waits until `has_capacity` holds or `deadline` passes, re-checking on every `notify` wake-up +/// instead of polling. +pub(crate) async fn wait_for_capacity( + notify: &Notify, + deadline: tokio::time::Instant, + mut has_capacity: F, +) -> bool +where + F: FnMut() -> Fut, + Fut: Future, +{ + loop { + let notified = notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if has_capacity().await { + return true; + } + tokio::select! { + () = notified => {} + () = tokio::time::sleep_until(deadline) => return has_capacity().await, + } + } +} + +/// Waits until `user` is below its connection limit or `deadline` passes. +pub(crate) async fn wait_for_user_capacity( + app_state: &AppState, + user: &ProxyUserCredentials, + deadline: tokio::time::Instant, +) -> bool { + let notify = app_state.connection_manager.capacity_notified(); + wait_for_capacity(¬ify, deadline, || async { + app_state.active_users.user_connections(&user.username).await < user.max_connections + }) + .await +} + +pub(crate) struct GroupOpenRequest<'a> { + pub app_state: &'a Arc, + pub user: &'a ProxyUserCredentials, + pub fingerprint: &'a Fingerprint, + pub req_headers: &'a HeaderMap, + pub group: &'a ChannelGroupRecord, + pub base_channel: &'a StreamChannel, + pub stream_options: &'a StreamOptions, + pub share_stream: bool, + pub connection_permission: UserConnectionPermission, + pub connection_kind: ConnectionKind, + pub priority: i8, + pub session_token: &'a str, + pub is_reopen: bool, + pub deadline: tokio::time::Instant, +} + +pub(crate) struct OpenedGroupMember { + pub index: usize, + pub tier: CapacityTier, + pub member: GroupMember, + pub input: Arc, + pub url: Arc, + pub channel: StreamChannel, + /// `details.stream` carries the probed head in front of the provider body. + pub details: StreamDetails, + pub payload: ProbedPayload, +} + +pub(crate) struct MemberOpenFailure { + pub index: usize, + pub input_name: Arc, + pub class: FailureClass, +} + +fn is_adaptive_content_type(details: &StreamDetails) -> bool { + details.stream_info.as_ref().is_some_and(|(headers, _, _, _)| { + headers.iter().any(|(name, value)| { + name.as_str().eq_ignore_ascii_case("content-type") && { + let value = value.to_ascii_lowercase(); + value.contains("mpegurl") || value.contains("dash+xml") + } + }) + }) +} + +/// The URL to request for `member`; a Stalker member needs its playback link resolved first. +async fn member_playback_url( + req: &GroupOpenRequest<'_>, + input: &Arc, + member: &GroupMember, +) -> Option> { + if !input.input_type.is_stalker() { + return Some(Arc::clone(&member.url)); + } + resolve_initial_stalker_playback_url( + req.app_state, + input, + req.user, + member.provider_id, + member.cluster, + member.item_type, + &member.url, + ) + .await + .ok() +} + +/// Why an open produced no usable provider body, `None` when it did. A custom video in place of +/// the body counts as a failure. +fn open_failure_class(details: &StreamDetails) -> Option { + let exhausted_class = || { + if details.custom_reason == Some(ProviderStreamCustomReason::ProviderExhausted) { + FailureClass::Capacity + } else { + FailureClass::Stream + } + }; + match &details.stream_info { + Some((_, _, _, Some(_))) => Some(exhausted_class()), + _ if details.stream.is_some() => None, + Some((_, status, _, None)) => Some(FailureClass::from_status(*status)), + None => Some(exhausted_class()), + } +} + +/// Opens the acquired member: input lookup, Stalker link, provider open, custom-video and status +/// classification, payload probe. Records the outcome in the health index; on failure the provider +/// slot is released. +pub(crate) async fn open_group_member( + req: &GroupOpenRequest<'_>, + acquired: AcquiredMember, +) -> Result { + let app_state = req.app_state; + let AcquiredMember { index, handle, tier, half_open } = acquired; + let member = req.group.members[index].clone(); + let fail = |class: FailureClass, guard: Option| { + record_member_failure(&app_state.member_health, &member, guard, class); + MemberOpenFailure { index, input_name: Arc::clone(&member.input_name), class } + }; + + let Some(input) = app_state.app_config.get_input_by_name(&member.input_name) else { + drop(handle); + return Err(fail(FailureClass::Stream, half_open)); + }; + let channel = member_stream_channel(req.base_channel, req.group, &member); + + // Neither the Stalker link nor the provider response may outlive the budget: a provider that + // accepts the connection and never answers must not hold the start or the switch. + let open_deadline = req.deadline.min(tokio::time::Instant::now() + PROVIDER_OPEN_TIMEOUT); + let Ok(Some(url)) = tokio::time::timeout_at(open_deadline, member_playback_url(req, &input, &member)).await else { + drop(handle); + return Err(fail(FailureClass::Transport, half_open)); + }; + + let content_representation = if member.item_type == PlaylistItemType::Catchup { + crate::api::model::ProviderContentRepresentationMode::Identity + } else { + crate::api::model::ProviderContentRepresentationMode::PreserveOrigin + }; + let opened = tokio::time::timeout_at( + open_deadline, + create_stream_response_details( + app_state, + req.stream_options, + url.as_ref(), + &req.user.username, + req.fingerprint, + req.req_headers, + &input, + &channel, + member.item_type, + content_representation, + req.share_stream, + req.connection_permission, + None, + true, + true, + VirtualId::new(member.member_vid.get()), + req.priority, + req.connection_kind, + req.is_reopen, + Some(req.session_token), + None, + true, + None, + None, + None, + Some(handle), + ), + ) + .await; + let mut details = match opened { + Ok(Ok(details)) => details, + Ok(Err(_)) => return Err(fail(FailureClass::Stream, half_open)), + // The dropped open released the provider slot it held. + Err(_) => return Err(fail(FailureClass::Transport, half_open)), + }; + + let release = + |details: StreamDetails| app_state.connection_manager.release_managed_provider_handle(details.provider_handle); + if let Some(class) = open_failure_class(&details) { + release(details); + return Err(fail(class, half_open)); + } + let Some(provider_stream) = details.stream.take() else { + release(details); + return Err(fail(FailureClass::Stream, half_open)); + }; + + let adaptive_hint = member.item_type.is_live_adaptive() || is_adaptive_content_type(&details); + let probe_deadline = req.deadline.min(tokio::time::Instant::now() + PROBE_FIRST_BYTES_TIMEOUT); + let Ok((provider_stream, payload)) = probe_member_payload(provider_stream, adaptive_hint, probe_deadline).await + else { + release(details); + return Err(fail(FailureClass::Stream, half_open)); + }; + details.stream = Some(provider_stream); + record_member_success(&app_state.member_health, &member, half_open); + Ok(OpenedGroupMember { index, tier, member, input, url, channel, details, payload }) +} diff --git a/backend/app/src/api/api_utils/channel_group_failover/budget.rs b/backend/app/src/api/api_utils/channel_group_failover/budget.rs new file mode 100644 index 000000000..2f8918d23 --- /dev/null +++ b/backend/app/src/api/api_utils/channel_group_failover/budget.rs @@ -0,0 +1,277 @@ +use shared::model::ChannelGroupFailoverConfig; +use std::{ + collections::VecDeque, + time::{Duration, Instant}, +}; + +/// Playback a member needs before its failure opens a fresh switch budget. +const FRESH_BUDGET_AFTER: Duration = Duration::from_secs(5); + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum BudgetDecision { + /// Reopen the current member, it played long enough to deserve another try. + RetrySame, + /// Select another member, excluding the ones already tried in this incident. + Next { exclude: Vec }, + /// Stop switching: the incident budget or the flap guard is exhausted. + GiveUp, +} + +/// Decides how a running group stream reacts to a failing member. An incident starts at the +/// first trigger and ends after `stable_after_secs` of playback without a new one. Each stretch +/// without playback is bounded by `max_outage_secs`, and `max_switches` bounds the switches within +/// `max_switches_window_secs` across incidents. +pub(crate) struct FailoverBudget { + budget: Duration, + stable_reset: Duration, + flap_window: Duration, + flap_max_switches: u8, + same_member_retries: u8, + incident_started: Option, + last_trigger: Option, + tried: Vec, + switches: VecDeque, + same_retries_left: u8, + member_first_data: Option, +} + +impl FailoverBudget { + pub(crate) fn new(failover: &ChannelGroupFailoverConfig) -> Self { + Self { + budget: Duration::from_secs(failover.max_outage_secs), + stable_reset: Duration::from_secs(failover.stable_after_secs), + flap_window: Duration::from_secs(failover.max_switches_window_secs), + flap_max_switches: failover.max_switches, + same_member_retries: failover.same_source_retries, + incident_started: None, + last_trigger: None, + tried: Vec::new(), + switches: VecDeque::new(), + same_retries_left: failover.same_source_retries, + member_first_data: None, + } + } + + pub(crate) fn on_trigger(&mut self, current: usize, member_count: usize, now: Instant) -> BudgetDecision { + self.last_trigger = Some(now); + let started = *self.incident_started.get_or_insert(now); + if now.saturating_duration_since(started) >= self.budget { + return BudgetDecision::GiveUp; + } + if self.flap_max_switches > 0 { + while self.switches.front().is_some_and(|at| now.saturating_duration_since(*at) > self.flap_window) { + self.switches.pop_front(); + } + if self.switches.len() >= usize::from(self.flap_max_switches) { + return BudgetDecision::GiveUp; + } + } + let stable = + self.member_first_data.is_some_and(|first| now.saturating_duration_since(first) >= self.stable_reset); + if stable && self.same_retries_left > 0 { + self.same_retries_left -= 1; + return BudgetDecision::RetrySame; + } + if !self.tried.contains(¤t) { + self.tried.push(current); + } + if self.tried.len() >= member_count { + self.tried.retain(|index| *index == current); + } + BudgetDecision::Next { exclude: self.tried.clone() } + } + + /// Whether a trigger now would give up, without starting or changing an incident. + pub(crate) fn would_give_up(&self, now: Instant) -> bool { + let budget_spent = + self.incident_started.is_some_and(|started| now.saturating_duration_since(started) >= self.budget); + let flapping = self.flap_max_switches > 0 + && self.switches.iter().filter(|at| now.saturating_duration_since(**at) <= self.flap_window).count() + >= usize::from(self.flap_max_switches); + budget_spent || flapping + } + + pub(crate) fn on_switched(&mut self, index: usize, previous: usize, now: Instant) { + if self.flap_max_switches > 0 { + self.switches.push_back(now); + } + if index != previous { + self.same_retries_left = self.same_member_retries; + } + self.member_first_data = None; + } + + /// Called for packets sent to the client. + pub(crate) fn on_data(&mut self, now: Instant) { + let first_data = *self.member_first_data.get_or_insert(now); + // The budget bounds the time without playback: a member that really plays restarts it, + // while the members tried in this incident stay excluded until it ends. A member that + // sends a few packets and fails (an error clip) does not. + if self.incident_started.is_some() + && now.saturating_duration_since(first_data) >= FRESH_BUDGET_AFTER.min(self.budget / 2) + { + self.incident_started = None; + } + if self.last_trigger.is_some_and(|last| now.saturating_duration_since(last) >= self.stable_reset) { + self.incident_started = None; + self.last_trigger = None; + self.tried.clear(); + self.same_retries_left = self.same_member_retries; + } + } + + /// End of the current incident's switch budget. + pub(crate) fn deadline(&self) -> Option { self.incident_started.map(|started| started + self.budget) } +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::model::ChannelGroupFailoverConfig; + use std::time::{Duration, Instant}; + + fn config(max_switches: u8) -> ChannelGroupFailoverConfig { + ChannelGroupFailoverConfig { + max_outage_secs: 20, + stable_after_secs: 30, + max_switches_window_secs: 600, + max_switches, + same_source_retries: 1, + ..ChannelGroupFailoverConfig::default() + } + } + + fn secs(start: Instant, secs: u64) -> Instant { start + Duration::from_secs(secs) } + + #[test] + fn budget_exceeded_gives_up() { + let t0 = Instant::now(); + let mut budget = FailoverBudget::new(&config(6)); + assert_eq!(budget.on_trigger(0, 3, t0), BudgetDecision::Next { exclude: vec![0] }); + budget.on_switched(1, 0, secs(t0, 1)); + assert_eq!(budget.on_trigger(1, 3, secs(t0, 21)), BudgetDecision::GiveUp); + } + + #[test] + fn budget_resets_after_stable_playback() { + let t0 = Instant::now(); + let mut budget = FailoverBudget::new(&config(6)); + budget.on_trigger(0, 3, t0); + budget.on_switched(1, 0, secs(t0, 1)); + budget.on_data(secs(t0, 2)); + budget.on_data(secs(t0, 31)); + assert_eq!(budget.deadline(), None); + assert_eq!(budget.on_trigger(1, 3, secs(t0, 32)), BudgetDecision::RetrySame); + assert_eq!(budget.deadline(), Some(secs(t0, 52))); + } + + #[test] + fn budget_is_per_incident_not_per_stream() { + let t0 = Instant::now(); + let mut budget = FailoverBudget::new(&config(0)); + for incident in 0..5u64 { + let start = secs(t0, incident * 100); + let current = usize::try_from(incident % 2).expect("index"); + let next = 1 - current; + assert_ne!(budget.on_trigger(current, 2, start), BudgetDecision::GiveUp); + budget.on_switched(next, current, start); + budget.on_data(start); + budget.on_data(start + Duration::from_secs(31)); + } + } + + #[test] + fn walk_restarts_after_full_round_within_budget() { + let t0 = Instant::now(); + let mut budget = FailoverBudget::new(&config(6)); + assert_eq!(budget.on_trigger(0, 3, t0), BudgetDecision::Next { exclude: vec![0] }); + budget.on_switched(1, 0, t0); + assert_eq!(budget.on_trigger(1, 3, secs(t0, 1)), BudgetDecision::Next { exclude: vec![0, 1] }); + budget.on_switched(2, 1, secs(t0, 1)); + assert_eq!(budget.on_trigger(2, 3, secs(t0, 2)), BudgetDecision::Next { exclude: vec![2] }); + } + + #[test] + fn flap_guard_gives_up() { + let t0 = Instant::now(); + let mut budget = FailoverBudget::new(&config(2)); + budget.on_trigger(0, 3, t0); + budget.on_switched(1, 0, t0); + budget.on_trigger(1, 3, secs(t0, 1)); + budget.on_switched(2, 1, secs(t0, 1)); + assert_eq!(budget.on_trigger(2, 3, secs(t0, 2)), BudgetDecision::GiveUp); + } + + #[test] + fn flap_guard_disabled_with_zero() { + let t0 = Instant::now(); + let mut budget = FailoverBudget::new(&config(0)); + for step in 0..10u64 { + let current = usize::try_from(step % 3).expect("index"); + assert!(matches!(budget.on_trigger(current, 3, secs(t0, step)), BudgetDecision::Next { .. })); + budget.on_switched((current + 1) % 3, current, secs(t0, step)); + } + } + + #[test] + fn fresh_member_failure_skips_same_member_retry() { + let t0 = Instant::now(); + let mut budget = FailoverBudget::new(&config(6)); + budget.on_data(t0); + assert_eq!(budget.on_trigger(0, 2, secs(t0, 5)), BudgetDecision::Next { exclude: vec![0] }); + } + + #[test] + fn stable_member_failure_retries_same_member_first() { + let t0 = Instant::now(); + let mut budget = FailoverBudget::new(&config(6)); + budget.on_data(t0); + assert_eq!(budget.on_trigger(0, 2, secs(t0, 31)), BudgetDecision::RetrySame); + budget.on_switched(0, 0, secs(t0, 31)); + budget.on_data(secs(t0, 32)); + assert_eq!(budget.on_trigger(0, 2, secs(t0, 33)), BudgetDecision::Next { exclude: vec![0] }); + } + + #[test] + fn failure_after_good_playback_gets_a_fresh_budget() { + let t0 = Instant::now(); + let mut budget = FailoverBudget::new(&config(6)); + assert_eq!(budget.on_trigger(0, 3, t0), BudgetDecision::Next { exclude: vec![0] }); + budget.on_switched(1, 0, secs(t0, 1)); + budget.on_data(secs(t0, 1)); + budget.on_data(secs(t0, 25)); + assert_eq!(budget.on_trigger(1, 3, secs(t0, 26)), BudgetDecision::Next { exclude: vec![0, 1] }); + } + + #[test] + fn flap_history_is_not_kept_without_guard() { + let t0 = Instant::now(); + let mut budget = FailoverBudget::new(&config(0)); + for step in 0..100u64 { + budget.on_switched(1, 0, secs(t0, step)); + } + assert!(budget.switches.is_empty()); + } + + #[test] + fn brief_data_does_not_restart_budget() { + let t0 = Instant::now(); + let mut budget = FailoverBudget::new(&config(0)); + assert_eq!(budget.on_trigger(0, 2, t0), BudgetDecision::Next { exclude: vec![0] }); + budget.on_switched(1, 0, secs(t0, 1)); + budget.on_data(secs(t0, 1)); + budget.on_data(secs(t0, 3)); + assert_eq!(budget.on_trigger(1, 2, secs(t0, 21)), BudgetDecision::GiveUp); + } + + #[test] + fn would_give_up_leaves_the_incident_untouched() { + let t0 = Instant::now(); + let mut budget = FailoverBudget::new(&config(6)); + assert!(!budget.would_give_up(t0)); + assert_eq!(budget.deadline(), None, "asking starts no incident"); + budget.on_trigger(0, 2, t0); + assert!(budget.would_give_up(secs(t0, 21))); + assert!(!budget.would_give_up(secs(t0, 5))); + } +} diff --git a/backend/app/src/api/api_utils/channel_group_failover/mod.rs b/backend/app/src/api/api_utils/channel_group_failover/mod.rs new file mode 100644 index 000000000..eb0a4e9c2 --- /dev/null +++ b/backend/app/src/api/api_utils/channel_group_failover/mod.rs @@ -0,0 +1,634 @@ +//! Runtime failover of channel group playback: the provider body behind an open client response +//! is replaced by another member when it fails, stalls or delivers slower than real time. + +mod budget; +#[cfg(test)] +mod tests; + +use super::{ + channel_group::{ + acquire_group_member, open_group_member, record_member_failure, GroupOpenRequest, GroupSelectionFailure, + GroupSelectionRequest, OpenedGroupMember, ProbedPayload, + }, + connection_priority_for_kind, get_stream_options, StreamResponseMode, +}; +use crate::{ + api::model::{AppState, BoxedProviderStream, StreamError}, + auth::Fingerprint, + model::{ConfigTarget, ProxyUserCredentials}, + utils::debug_if_enabled, +}; +use axum::http::HeaderMap; +pub(crate) use budget::{BudgetDecision, FailoverBudget}; +use bytes::Bytes; +use futures::Future; +use shared::model::{ChannelGroupPlayback, EventMessage, StreamChannel, SwitchReason, UserConnectionPermission}; +use std::{ + pin::Pin, + sync::Arc, + task::{Context, Poll}, + time::{Duration, Instant}, +}; +use tokio::time::Sleep; +use tokio_util::sync::CancellationToken; +use tuliprox_core::model::AllocationId; +use tuliprox_mpegts::{ + packet::ts_null_packet_bytes, + splice::{RealtimeMeter, SpliceOutput, TsSpliceAligner}, +}; +use tuliprox_repository::channel_group_repository::ChannelGroupRecord; +use tuliprox_session::{ + active_provider_manager::{ConnectionKind, ManagedProviderHandle}, + channel_group::{AudioSignature, FailureClass, SelectionMode}, + MemberBinding, +}; + +/// While a switch runs, a null packet this often keeps players and proxies from timing out. +const KEEPALIVE_INTERVAL: Duration = Duration::from_millis(500); +/// Longest stretch of a new member held back while waiting for its splice point (PAT/PMT and a +/// keyframe); beyond it the member starts at its first PAT. +const SPLICE_BUDGET_BYTES: usize = 8 * 1024 * 1024; +const SPLICE_BUDGET: Duration = Duration::from_secs(4); +/// Re-check interval while every candidate is busy during a switch. +const EXHAUSTED_RETRY_INTERVAL: Duration = Duration::from_millis(100); +/// Pause before a new round over members that all failed within one switch; it doubles per +/// round up to the maximum. +const NO_CANDIDATE_RETRY_INTERVAL: Duration = Duration::from_millis(500); +const NO_CANDIDATE_RETRY_MAX_INTERVAL: Duration = Duration::from_secs(4); +/// Upper bound for the first bytes of a member opened at runtime. +const RUNTIME_OPEN_TIMEOUT: Duration = Duration::from_secs(3); + +/// Everything a running group stream needs to open another member for its client. +pub(crate) struct GroupFailoverContext { + pub app_state: Arc, + pub target: Arc, + /// `target.name` as the health and selection key, allocated once per stream. + pub target_name: Arc, + pub group: Arc, + pub playback: ChannelGroupPlayback, + pub user: ProxyUserCredentials, + pub fingerprint: Fingerprint, + pub req_headers: HeaderMap, + pub session_token: Arc, + pub connection_kind: ConnectionKind, + pub connection_permission: UserConnectionPermission, + pub share_stream: bool, + pub base_channel: StreamChannel, + /// Key of the shared stream this group stream feeds; its provider allocation moves with the + /// member. + pub shared_key: Option>, +} + +/// The member a switch opened: its provider body and the slot it holds. +pub(crate) struct RuntimeMember { + pub index: usize, + pub stream: BoxedProviderStream, + pub handle: Option, + /// Cancelled when the provider manager preempts the member's slot. + pub cancel: Option, + /// The member's provider allocation; a shared stream owns it now. + pub allocation_id: Option, +} + +pub(crate) type SwitchFuture = Pin> + Send>>; + +/// Decides about and prepares member switches of one running group stream. The owner of the +/// provider handle (direct client stream or shared producer) executes them. +pub(crate) struct GroupFailoverDriver { + ctx: Arc, + current: usize, + budget: FailoverBudget, + aligner: TsSpliceAligner, + meter: RealtimeMeter, + stall_timeout: Option, + stall: Pin>, + stall_armed: bool, + keepalive: Pin>, + pending_since: Option, + underrun: bool, + /// Second piece of a split chunk, sent before the provider body is polled again. + pending_output: Option, + /// Provider allocation of the current member while a shared stream owns it. + shared_allocation: Option, +} + +impl GroupFailoverDriver { + pub(crate) fn new(ctx: Arc, current: usize) -> Self { + let failover = ctx.playback.failover; + Self { + budget: FailoverBudget::new(&failover), + aligner: TsSpliceAligner::new(SPLICE_BUDGET_BYTES, SPLICE_BUDGET), + meter: RealtimeMeter::new(failover.slow_source_pct, Duration::from_secs(failover.slow_source_secs)), + stall_timeout: (failover.no_data_timeout_secs > 0) + .then(|| Duration::from_secs(failover.no_data_timeout_secs)), + stall: Box::pin(tokio::time::sleep(Duration::ZERO)), + stall_armed: false, + keepalive: Box::pin(tokio::time::sleep(Duration::ZERO)), + pending_since: None, + underrun: false, + pending_output: None, + shared_allocation: None, + ctx, + current, + } + } + + pub(crate) fn group_vid(&self) -> u32 { self.ctx.group.group_vid.get() } + + /// Hot path: aligns the chunk and does the per-chunk bookkeeping. `None` while the aligner + /// holds packets back. + pub(crate) fn process_chunk(&mut self, chunk: Bytes) -> Option { + let now = Instant::now(); + let waited = self.pending_since.take().map_or(Duration::ZERO, |since| now.saturating_duration_since(since)); + let (first, second) = match self.aligner.push(chunk, now) { + SpliceOutput::Hold => return None, + SpliceOutput::Emit(packets) => (packets, None), + SpliceOutput::EmitTwo(first, second) => (first, Some(second)), + }; + // Only packets that reach the client count as playback and hold off the stall check. + self.stall_armed = false; + self.budget.on_data(now); + let mut slow = self.meter.observe(&first, waited, now); + if let Some(second) = &second { + slow |= self.meter.observe(second, Duration::ZERO, now); + } + if slow { + self.underrun = true; + self.meter.reset(now); + } + self.pending_output = second; + Some(first) + } + + /// The second piece of the last split chunk, to send before polling the provider again. + pub(crate) fn take_pending_output(&mut self) -> Option { self.pending_output.take() } + + /// Called while the provider body is pending; true once it stayed silent for the stall timeout. + pub(crate) fn poll_stalled(&mut self, cx: &mut Context<'_>) -> bool { + let now = Instant::now(); + // The wait also feeds the underrun meter, with or without a stall timeout. + self.pending_since.get_or_insert(now); + let Some(timeout) = self.stall_timeout else { + return false; + }; + if !self.stall_armed { + self.stall.as_mut().reset(tokio::time::Instant::from_std(now + timeout)); + self.stall_armed = true; + } + self.stall.as_mut().poll(cx).is_ready() + } + + /// True once the realtime meter detected an underrun; consumes the signal. + pub(crate) fn take_underrun(&mut self) -> bool { std::mem::take(&mut self.underrun) } + + /// Records the failure of the current member and returns the future that opens the next one, + /// or `None` when the budget is spent. The caller releases the current provider slot before + /// it polls the future, so a retry on the same account finds its slot free. + pub(crate) fn begin_switch(&mut self, reason: SwitchReason, status: Option) -> Option { + let now = Instant::now(); + // A slow member still plays: when no switch is allowed it keeps playing, and the check + // leaves the incident untouched. + if reason == SwitchReason::Underrun && self.budget.would_give_up(now) { + return None; + } + let group = &self.ctx.group; + // The member failed whether or not another one may take over. + record_member_failure( + &self.ctx.app_state.member_health, + &group.members[self.current], + None, + failure_class_for(reason, status), + ); + let decision = self.budget.on_trigger(self.current, group.members.len(), now); + let (exclude, prefer) = match decision { + BudgetDecision::GiveUp => return None, + BudgetDecision::RetrySame => (Vec::new(), Some(self.current)), + BudgetDecision::Next { exclude } => (exclude, None), + }; + let deadline = self.budget.deadline().unwrap_or(now); + self.stall_armed = false; + self.pending_since = None; + self.keepalive.as_mut().reset(tokio::time::Instant::now() + KEEPALIVE_INTERVAL); + Some(Box::pin(switch_member( + Arc::clone(&self.ctx), + SwitchRequest { + shared_allocation: self.shared_allocation, + previous: self.current, + exclude, + prefer, + deadline: tokio::time::Instant::from_std(deadline), + reason, + }, + ))) + } + + /// Continues on the member a switch opened. + pub(crate) fn on_switched(&mut self, index: usize) { + let now = Instant::now(); + self.budget.on_switched(index, self.current, now); + self.current = index; + self.aligner.begin_member(now); + self.meter.begin_member(now); + self.stall_armed = false; + self.pending_since = None; + self.underrun = false; + } + + /// While a switch runs: a null packet every `KEEPALIVE_INTERVAL`. + pub(crate) fn poll_keepalive(&mut self, cx: &mut Context<'_>) -> Poll { + if self.keepalive.as_mut().poll(cx).is_pending() { + return Poll::Pending; + } + self.keepalive.as_mut().reset(tokio::time::Instant::now() + KEEPALIVE_INTERVAL); + Poll::Ready(ts_null_packet_bytes()) + } +} + +/// How a member failure counts in the health index: an upstream error by its HTTP status, a +/// transport error without one; an ended, stalled or slow body as a stream failure. +pub(crate) fn failure_class_for(reason: SwitchReason, status: Option) -> FailureClass { + match (reason, status.and_then(|status| axum::http::StatusCode::from_u16(status).ok())) { + (SwitchReason::UpstreamError, Some(status)) => FailureClass::from_status(status), + (SwitchReason::UpstreamError, None) => FailureClass::Transport, + (SwitchReason::UpstreamEof | SwitchReason::Stall | SwitchReason::Underrun, _) => FailureClass::Stream, + } +} + +/// The token the provider manager cancels when it preempts the slot of `handle`. +pub(crate) fn member_cancel_token(handle: Option<&ManagedProviderHandle>) -> Option { + handle.and_then(ManagedProviderHandle::handle).and_then(|handle| handle.cancel_token.clone()) +} + +struct SwitchRequest { + /// Provider allocation of the replaced member, when a shared stream owns it. + shared_allocation: Option, + previous: usize, + exclude: Vec, + prefer: Option, + deadline: tokio::time::Instant, + reason: SwitchReason, +} + +/// Selection mode of a runtime switch: prefer members like the one that played. +fn runtime_mode(ctx: &GroupFailoverContext, previous: usize) -> SelectionMode { + let probe = ctx + .app_state + .member_health + .health(&ctx.target_name, &ctx.group.members[previous], std::time::Instant::now()) + .probe; + SelectionMode::Runtime { + current_codec: probe.as_ref().map(|p| p.codec.clone()).unwrap_or_default(), + current_resolution: probe.as_ref().map(|p| p.resolution.clone()).unwrap_or_default(), + current_audio: probe.as_ref().map(|p| AudioSignature { + codec: p.audio_codec.clone(), + channels: p.audio_channels.clone(), + language: p.audio_language.clone(), + }), + audio_language: ctx.playback.failover.audio_language, + } +} + +/// Takes over the opened member: a shared stream receives its provider slot, the session is bound +/// to it and the switch is announced. +async fn adopt_member( + ctx: &GroupFailoverContext, + detached: Option, + previous: usize, + reason: SwitchReason, + opened: OpenedGroupMember, + stream: BoxedProviderStream, +) -> Option { + let app_state = &ctx.app_state; + let mut handle = opened.details.provider_handle; + let cancel = member_cancel_token(handle.as_ref()); + let slot = handle.as_ref().and_then(ManagedProviderHandle::handle); + let allocation_id = slot.map(|handle| handle.allocation_id); + let provider = slot + .and_then(|handle| handle.allocation.get_provider_name()) + .unwrap_or_else(|| Arc::clone(&opened.member.input_name)); + if let (Some(key), Some(detached)) = (&ctx.shared_key, detached) { + let attached = match handle.take() { + Some(handle) => app_state.shared_stream_manager.attach_provider(key, detached, handle).await, + None => true, + }; + if !attached { + // The shared stream ended meanwhile; the dropped handle released its slot. + return None; + } + } + let member_vid = opened.member.member_vid.get(); + app_state + .active_users + .bind_member( + &ctx.user.username, + &ctx.session_token, + MemberBinding { + group_vid: ctx.group.group_vid.get(), + member_vid, + member_index: opened.index, + input_name: Arc::clone(&opened.member.input_name), + }, + ) + .await; + if ctx.shared_key.is_some() { + // Every viewer of the shared stream plays the new member, not only the one that started it. + app_state + .active_users + .update_shared_group_provider(ctx.target.id, ctx.group.group_vid.get(), Arc::clone(&provider)) + .await; + } + app_state + .active_users + .update_session_provider_binding(&ctx.user.username, &ctx.session_token, provider, Arc::clone(&opened.url)) + .await; + app_state.event_manager.send_event(EventMessage::ChannelGroupMemberSwitched { + target_id: ctx.target.id, + group_vid: ctx.group.group_vid.get(), + from_member_vid: ctx.group.members[previous].member_vid.get(), + to_member_vid: member_vid, + reason, + }); + Some(RuntimeMember { index: opened.index, stream, handle, cancel, allocation_id }) +} + +async fn switch_member(ctx: Arc, request: SwitchRequest) -> Option { + let SwitchRequest { previous, mut exclude, mut prefer, deadline, reason, shared_allocation } = request; + let app_state = &ctx.app_state; + let priority = connection_priority_for_kind(&ctx.user, ctx.connection_kind); + let stream_options = get_stream_options(&app_state.app_config, StreamResponseMode::Stream); + let mode = runtime_mode(&ctx, previous); + let mut demoted_inputs: Vec> = Vec::new(); + // Members a new round must not try again within this switch: their provider refused the + // account (auth, capacity) or delivers nothing that can be spliced. + let mut hard_exclude: Vec = Vec::new(); + let mut round_pause = NO_CANDIDATE_RETRY_INTERVAL; + if let Some(key) = &ctx.shared_key { + // Subscribers stay while the failed member's slot is given back. Without the expected + // allocation the shared stream is no longer this one's and nothing may be switched. + let detached = match shared_allocation { + Some(allocation_id) => app_state.shared_stream_manager.detach_provider(key, allocation_id).await, + None => false, + }; + if !detached { + return None; + } + } + loop { + if tokio::time::Instant::now() >= deadline { + return None; + } + let wanted = prefer.take(); + let req = GroupSelectionRequest { + mode: mode.clone(), + allow_provider_grace: false, + exclude: &exclude, + demoted_inputs: &demoted_inputs, + prefer: wanted, + bypass_maintenance: ctx.user.is_recording_proxy_user(), + allow_preempt: false, + ..GroupSelectionRequest::new( + &ctx.target_name, + &ctx.group, + ctx.playback.start, + &ctx.fingerprint.addr, + &ctx.user.username, + priority, + ctx.connection_kind, + ) + }; + let acquired = match tokio::time::timeout_at(deadline, acquire_group_member(app_state, req)).await { + Ok(Ok(acquired)) => acquired, + Ok(Err(GroupSelectionFailure::Exhausted)) => { + // A released slot frees once its provider body has ended, which happens + // asynchronously; the slot this stream just gave back is worth waiting for. + prefer = wanted; + tokio::time::sleep_until(deadline.min(tokio::time::Instant::now() + EXHAUSTED_RETRY_INTERVAL)).await; + continue; + } + Ok(Err(GroupSelectionFailure::NoCandidate)) => { + // Every other member failed in this switch: start a new round over them after a + // pause, still bounded by the budget. The replaced member stays out. + exclude.retain(|index| *index == previous || hard_exclude.contains(index)); + tokio::time::sleep_until(deadline.min(tokio::time::Instant::now() + round_pause)).await; + round_pause = (round_pause * 2).min(NO_CANDIDATE_RETRY_MAX_INTERVAL); + continue; + } + Ok(Err(_)) | Err(_) => return None, + }; + let open_req = GroupOpenRequest { + app_state, + user: &ctx.user, + fingerprint: &ctx.fingerprint, + req_headers: &ctx.req_headers, + group: &ctx.group, + base_channel: &ctx.base_channel, + stream_options: &stream_options, + share_stream: ctx.share_stream, + connection_permission: ctx.connection_permission, + connection_kind: ctx.connection_kind, + priority, + session_token: &ctx.session_token, + is_reopen: true, + deadline: deadline.min(tokio::time::Instant::now() + RUNTIME_OPEN_TIMEOUT), + }; + match open_group_member(&open_req, acquired).await { + Ok(mut opened) => { + let stream = opened.details.stream.take(); + let (Some(stream), ProbedPayload::Ts) = (stream, opened.payload) else { + // Only MPEG-TS can be spliced into the running response. + app_state.connection_manager.release_managed_provider_handle(opened.details.provider_handle); + exclude.push(opened.index); + hard_exclude.push(opened.index); + continue; + }; + return adopt_member(&ctx, shared_allocation, previous, reason, opened, stream).await; + } + Err(failure) => { + if matches!(failure.class, FailureClass::Auth | FailureClass::Capacity) { + if !demoted_inputs.contains(&failure.input_name) { + demoted_inputs.push(failure.input_name); + } + hard_exclude.push(failure.index); + } + exclude.push(failure.index); + } + } + } +} + +/// How a group playback's member body ended for good: no member could take over. +pub(crate) enum GroupEnd { + Eof, + Error(StreamError), + Stalled, + /// A switch was started for `reason` but no member could be opened within the budget. + SwitchFailed { + reason: SwitchReason, + }, +} + +pub(crate) enum GroupPoll { + Data(Bytes), + /// A null packet sent while a switch runs: keeps the connection alive, carries no media. + Keepalive(Bytes), + Pending, + Ended(GroupEnd), +} + +/// Owner of a group playback's provider slot and body: the direct client stream or the producer +/// of a shared stream. +pub(crate) trait MemberHost { + fn member_body(&mut self) -> Option<&mut BoxedProviderStream>; + /// True when the provider manager preempted the current member's slot: no failover then. + fn member_preempted(&self) -> bool; + /// Gives up the failed member's body and slot before the next member is acquired, so the + /// same account can be reused. + fn release_member(&mut self); + fn install_member(&mut self, member: RuntimeMember); +} + +/// The switch state machine of a group playback, shared by its direct and shared hosts. +pub(crate) struct GroupSwitcher { + driver: GroupFailoverDriver, + /// The switch in flight and the failure that started it. + switch: Option<(SwitchFuture, SwitchReason)>, +} + +impl GroupSwitcher { + pub(crate) fn new(driver: GroupFailoverDriver) -> Self { Self { driver, switch: None } } + + /// True while another member is being opened. + pub(crate) fn is_switching(&self) -> bool { self.switch.is_some() } + + /// Polls the member body of `host`, switching members underneath it when it fails. + pub(crate) fn poll_member(&mut self, host: &mut impl MemberHost, cx: &mut Context<'_>) -> GroupPoll { + loop { + if let Some(packets) = self.driver.take_pending_output() { + return GroupPoll::Data(packets); + } + if let Some((switch, reason)) = self.switch.as_mut() { + let reason = *reason; + match switch.as_mut().poll(cx) { + Poll::Ready(Some(member)) => { + self.switch = None; + self.driver.on_switched(member.index); + self.driver.shared_allocation = member.allocation_id; + host.install_member(member); + continue; + } + Poll::Ready(None) => { + self.switch = None; + return GroupPoll::Ended(GroupEnd::SwitchFailed { reason }); + } + Poll::Pending => { + return match self.driver.poll_keepalive(cx) { + Poll::Ready(keepalive) => GroupPoll::Keepalive(keepalive), + Poll::Pending => GroupPoll::Pending, + }; + } + } + } + let Some(body) = host.member_body() else { + return GroupPoll::Ended(GroupEnd::Eof); + }; + match body.as_mut().poll_next(cx) { + Poll::Ready(Some(Ok(chunk))) => { + let packets = self.driver.process_chunk(chunk); + if self.driver.take_underrun() { + // The packets already read still go out; the next poll switches. + self.start_switch(host, SwitchReason::Underrun, None); + } + if let Some(packets) = packets { + return GroupPoll::Data(packets); + } + } + Poll::Ready(Some(Err(err))) => { + if !self.start_switch(host, SwitchReason::UpstreamError, err.provider_http_status()) { + return GroupPoll::Ended(GroupEnd::Error(err)); + } + } + Poll::Ready(None) => { + if !self.start_switch(host, SwitchReason::UpstreamEof, None) { + return GroupPoll::Ended(GroupEnd::Eof); + } + } + Poll::Pending => { + if !self.driver.poll_stalled(cx) { + return GroupPoll::Pending; + } + if !self.start_switch(host, SwitchReason::Stall, None) { + return GroupPoll::Ended(GroupEnd::Stalled); + } + } + } + } + } + + fn start_switch(&mut self, host: &mut impl MemberHost, reason: SwitchReason, status: Option) -> bool { + if host.member_preempted() { + return false; + } + let Some(switch) = self.driver.begin_switch(reason, status) else { + return false; + }; + debug_if_enabled!("Group stream {} lost its member ({reason:?}); switching", self.driver.group_vid()); + host.release_member(); + self.switch = Some((switch, reason)); + true + } +} + +/// The producer of a shared group stream: forwards the member body to the shared stream and +/// replaces the member underneath it when it fails. The shared stream owns the provider slot. +pub(crate) struct GroupFailoverStream { + switcher: GroupSwitcher, + host: SharedMemberHost, +} + +struct SharedMemberHost { + body: Option, + /// Preemption of the current member's slot. + member_cancel: Option, +} + +impl MemberHost for SharedMemberHost { + fn member_body(&mut self) -> Option<&mut BoxedProviderStream> { self.body.as_mut() } + + fn member_preempted(&self) -> bool { self.member_cancel.as_ref().is_some_and(CancellationToken::is_cancelled) } + + fn release_member(&mut self) { self.body = None; } + + fn install_member(&mut self, member: RuntimeMember) { + self.member_cancel = member.cancel; + self.body = Some(member.stream); + } +} + +impl GroupFailoverStream { + /// `slot` is the provider handle of the first member, which the shared stream takes over. + pub(crate) fn new( + mut driver: GroupFailoverDriver, + inner: BoxedProviderStream, + slot: Option<&ManagedProviderHandle>, + ) -> Self { + driver.shared_allocation = slot.and_then(ManagedProviderHandle::handle).map(|handle| handle.allocation_id); + Self { + switcher: GroupSwitcher::new(driver), + host: SharedMemberHost { body: Some(inner), member_cancel: member_cancel_token(slot) }, + } + } +} + +impl futures::Stream for GroupFailoverStream { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + match this.switcher.poll_member(&mut this.host, cx) { + GroupPoll::Data(packets) | GroupPoll::Keepalive(packets) => Poll::Ready(Some(Ok(packets))), + GroupPoll::Pending => Poll::Pending, + GroupPoll::Ended(GroupEnd::Error(err)) => Poll::Ready(Some(Err(err))), + GroupPoll::Ended(GroupEnd::Eof | GroupEnd::Stalled | GroupEnd::SwitchFailed { .. }) => Poll::Ready(None), + } + } +} diff --git a/backend/app/src/api/api_utils/channel_group_failover/tests.rs b/backend/app/src/api/api_utils/channel_group_failover/tests.rs new file mode 100644 index 000000000..8445b268f --- /dev/null +++ b/backend/app/src/api/api_utils/channel_group_failover/tests.rs @@ -0,0 +1,207 @@ +use super::failure_class_for; +use shared::model::SwitchReason; +use tuliprox_session::channel_group::FailureClass; + +#[test] +fn upstream_error_class_follows_http_status() { + assert_eq!(failure_class_for(SwitchReason::UpstreamError, Some(401)), FailureClass::Auth); + assert_eq!(failure_class_for(SwitchReason::UpstreamError, Some(429)), FailureClass::Capacity); + assert_eq!(failure_class_for(SwitchReason::UpstreamError, Some(502)), FailureClass::Stream); + assert_eq!(failure_class_for(SwitchReason::UpstreamError, None), FailureClass::Transport); + for reason in [SwitchReason::UpstreamEof, SwitchReason::Stall, SwitchReason::Underrun] { + assert_eq!(failure_class_for(reason, Some(401)), FailureClass::Stream); + } +} + +mod switcher { + use super::super::{ + GroupEnd, GroupFailoverContext, GroupFailoverDriver, GroupPoll, GroupSwitcher, MemberHost, RuntimeMember, + }; + use crate::api::{ + api_utils::tests::group_playback_support::{member_ts, GroupPlaybackEnv}, + model::{BoxedProviderStream, StreamError}, + }; + use bytes::Bytes; + use futures::StreamExt; + use shared::model::ChannelGroupFailoverConfig; + use std::{ + sync::Arc, + task::{Context, Poll}, + time::Duration, + }; + + struct FakeHost { + body: Option, + preempted: bool, + released: u32, + installed: u32, + } + + impl FakeHost { + fn new(body: BoxedProviderStream, preempted: bool) -> Self { + Self { body: Some(body), preempted, released: 0, installed: 0 } + } + } + + impl MemberHost for FakeHost { + fn member_body(&mut self) -> Option<&mut BoxedProviderStream> { self.body.as_mut() } + + fn member_preempted(&self) -> bool { self.preempted } + + fn release_member(&mut self) { + self.released += 1; + self.body = None; + } + + fn install_member(&mut self, member: RuntimeMember) { + self.installed += 1; + self.body = Some(member.stream); + } + } + + async fn switcher(failover: ChannelGroupFailoverConfig) -> (GroupPlaybackEnv, GroupSwitcher) { + let env = GroupPlaybackEnv::builder(&["solis", "korath"]).build().await; + let user = env.app_state.app_config.get_user_credentials("viewer").expect("viewer"); + let ctx = GroupFailoverContext { + app_state: Arc::clone(&env.app_state), + target: Arc::clone(&env.target), + target_name: env.target.name.as_str().into(), + group: Arc::clone(&env.group_record), + playback: shared::model::ChannelGroupPlayback { + failover, + ..shared::model::ChannelGroupsConfig::default().playback(None) + }, + user: (*user).clone(), + fingerprint: crate::auth::Fingerprint::new( + "fp".to_string(), + "127.0.0.1".to_string(), + std::net::SocketAddr::from(([127, 0, 0, 1], 1)), + ), + req_headers: axum::http::HeaderMap::new(), + session_token: "switcher-session".into(), + connection_kind: tuliprox_session::active_provider_manager::ConnectionKind::Normal, + connection_permission: shared::model::UserConnectionPermission::Allowed, + share_stream: false, + base_channel: shared::model::StreamChannel::default(), + shared_key: None, + }; + let switcher = GroupSwitcher::new(GroupFailoverDriver::new(Arc::new(ctx), 0)); + (env, switcher) + } + + fn no_budget() -> ChannelGroupFailoverConfig { + ChannelGroupFailoverConfig { max_outage_secs: 0, ..ChannelGroupFailoverConfig::default() } + } + + async fn poll(switcher: &mut GroupSwitcher, host: &mut FakeHost) -> GroupPoll { + std::future::poll_fn(|cx: &mut Context<'_>| match switcher.poll_member(host, cx) { + GroupPoll::Pending => Poll::Pending, + ready => Poll::Ready(ready), + }) + .await + } + + #[tokio::test] + async fn preempted_member_ends_without_switch() { + let (_env, mut switcher) = switcher(ChannelGroupFailoverConfig::default()).await; + let mut host = FakeHost::new(futures::stream::empty().boxed(), true); + assert!(matches!(poll(&mut switcher, &mut host).await, GroupPoll::Ended(GroupEnd::Eof))); + assert_eq!((host.released, host.installed), (0, 0)); + assert!(!switcher.is_switching()); + } + + #[tokio::test] + async fn spent_budget_ends_with_the_body_end() { + let (_env, mut switcher) = switcher(no_budget()).await; + let error = futures::stream::iter([Err(StreamError::Stream("provider reset".to_string()))]).boxed(); + let mut host = FakeHost::new(error, false); + assert!(matches!(poll(&mut switcher, &mut host).await, GroupPoll::Ended(GroupEnd::Error(_)))); + assert_eq!(host.released, 0, "nothing is released when no switch follows"); + } + + #[tokio::test] + async fn split_chunks_reach_the_client_in_order_before_the_end() { + let (_env, mut switcher) = switcher(no_budget()).await; + let ts = member_ts(0x100, 10); + let chunks = vec![Ok(Bytes::copy_from_slice(&ts[..300])), Ok(Bytes::copy_from_slice(&ts[300..]))]; + let mut host = FakeHost::new(futures::stream::iter(chunks).boxed(), false); + let mut sent = Vec::new(); + loop { + match poll(&mut switcher, &mut host).await { + GroupPoll::Data(bytes) => sent.extend_from_slice(&bytes), + GroupPoll::Ended(GroupEnd::Eof) => break, + _ => panic!("unexpected poll result"), + } + } + assert_eq!(sent, ts[..ts.len() / 188 * 188]); + } + + #[tokio::test] + async fn stall_without_budget_ends_stalled() { + let selection = ChannelGroupFailoverConfig { no_data_timeout_secs: 1, ..no_budget() }; + let (_env, mut switcher) = switcher(selection).await; + let mut host = FakeHost::new(futures::stream::pending().boxed(), false); + let ended = + tokio::time::timeout(Duration::from_secs(3), poll(&mut switcher, &mut host)).await.expect("bounded"); + assert!(matches!(ended, GroupPoll::Ended(GroupEnd::Stalled))); + } + + /// A packet on PID 0x100 whose adaptation field carries `pcr_27mhz`. + fn pcr_packet(pcr_27mhz: u64) -> Vec { + let base = pcr_27mhz / 300; + let ext = pcr_27mhz % 300; + let byte = |value: u64| u8::try_from(value & 0xFF).expect("byte"); + let mut packet = vec![0x47, 0x01, 0x00, 0x20, 183, 0x10]; + packet.extend([ + byte(base >> 25), + byte(base >> 17), + byte(base >> 9), + byte(base >> 1), + byte((base & 1) << 7) | 0x7E | byte(ext >> 8), + byte(ext), + ]); + packet.resize(188, 0xFF); + packet + } + + #[tokio::test] + async fn slow_member_triggers_underrun_switch() { + let selection = ChannelGroupFailoverConfig { + slow_source_pct: 90, + slow_source_secs: 2, + no_data_timeout_secs: 0, + ..ChannelGroupFailoverConfig::default() + }; + let (_env, mut switcher) = switcher(selection).await; + // Every 100 ms of wall time the provider delivers 40 ms of media. + let slow = futures::stream::unfold(27_000_000_u64, |pcr| async move { + tokio::time::sleep(Duration::from_millis(100)).await; + let next = pcr + 27_000 * 40; + Some((Ok::(Bytes::from(pcr_packet(next))), next)) + }) + .boxed(); + let mut host = FakeHost::new(slow, false); + let underrun_switch = tokio::time::timeout(Duration::from_secs(8), async { + while host.released == 0 { + let _ = poll(&mut switcher, &mut host).await; + } + }) + .await; + assert!(underrun_switch.is_ok(), "the underrun starts a switch"); + } + + #[tokio::test] + async fn bytes_that_never_reach_the_client_do_not_hold_off_the_stall() { + let selection = ChannelGroupFailoverConfig { no_data_timeout_secs: 1, ..no_budget() }; + let (_env, mut switcher) = switcher(selection).await; + // Text instead of MPEG-TS every 100 ms: the aligner drops it all. + let garbage = futures::stream::unfold((), |()| async { + tokio::time::sleep(Duration::from_millis(100)).await; + Some((Ok::(Bytes::from_static(b"offline")), ())) + }) + .boxed(); + let mut host = FakeHost::new(garbage, false); + let ended = tokio::time::timeout(Duration::from_secs(4), poll(&mut switcher, &mut host)).await; + assert!(matches!(ended, Ok(GroupPoll::Ended(GroupEnd::Stalled))), "no media within the stall timeout"); + } +} diff --git a/backend/app/src/api/api_utils/local_files.rs b/backend/app/src/api/api_utils/local_files.rs index e0b9222cf..4e23acf34 100644 --- a/backend/app/src/api/api_utils/local_files.rs +++ b/backend/app/src/api/api_utils/local_files.rs @@ -345,6 +345,7 @@ pub(crate) async fn local_stream_response( req_headers, meter_uid: metering.meter_uid, meter_stream: metering.meter_stream, + group_failover: None, }) .await { diff --git a/backend/app/src/api/api_utils/maintenance.rs b/backend/app/src/api/api_utils/maintenance.rs index fb6840606..78abb4e75 100644 --- a/backend/app/src/api/api_utils/maintenance.rs +++ b/backend/app/src/api/api_utils/maintenance.rs @@ -1,4 +1,4 @@ -//! Gate 1: a new playback request for an input in maintenance gets the +//! Maintenance check: a new playback request for an input in maintenance gets the //! maintenance clip instead of a provider connection. //! //! Requests that continue something already admitted pass: the DVR's internal diff --git a/backend/app/src/api/api_utils/mod.rs b/backend/app/src/api/api_utils/mod.rs index d689cb798..fd5c21be1 100644 --- a/backend/app/src/api/api_utils/mod.rs +++ b/backend/app/src/api/api_utils/mod.rs @@ -233,11 +233,13 @@ 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; +pub(crate) mod channel_group_failover; mod clock; mod local_files; mod maintenance; @@ -318,12 +320,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::{group_playback_settings, 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}, diff --git a/backend/app/src/api/api_utils/provider_acquire.rs b/backend/app/src/api/api_utils/provider_acquire.rs index b86083b3f..bcc2b6de5 100644 --- a/backend/app/src/api/api_utils/provider_acquire.rs +++ b/backend/app/src/api/api_utils/provider_acquire.rs @@ -1,5 +1,11 @@ use super::{ activate_session_before_stream_open, admission_failure_response, blocks_new_playback, + channel_group::{ + acquire_group_member, group_playback_settings, group_representative, group_shared_key, open_group_member, + wait_for_user_capacity, GroupOpenRequest, GroupSelectionFailure, GroupSelectionRequest, OpenedGroupMember, + PlaybackSource, ProbedPayload, + }, + channel_group_failover::{GroupFailoverContext, GroupFailoverDriver, GroupFailoverStream}, 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, @@ -25,13 +31,17 @@ 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::FailureClass, ProviderSessionHeaders}; pub(super) fn create_unmapped_provider_stream(app_config: &AppConfig) -> ProviderStreamState { ProviderStreamState::Custom { @@ -293,10 +303,9 @@ pub(crate) async fn stream_response_with_provider_handle( session_token: &str, request_class: Option, mut stream_channel: StreamChannel, - stream_url: &str, + source: PlaybackSource<'_>, pinned_provider: Option<&Arc>, req_headers: &HeaderMap, - input: &Arc, target: &Arc, user: &ProxyUserCredentials, connection_permission: UserConnectionPermission, @@ -305,26 +314,53 @@ pub(crate) async fn stream_response_with_provider_handle( grace_mode: Option, preacquired_provider_handle: Option, ) -> 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()); + } + + // A group stands in with its first resolvable member until the selection below picks one. + let (initial_input, initial_stream_url): (Arc, Cow<'_, str>) = match &source { + PlaybackSource::Single { input, stream_url } => (Arc::clone(input), Cow::Borrowed(*stream_url)), + PlaybackSource::Group { group, .. } => { + let Some((input, url)) = group_representative(app_state, group) else { + app_state.event_manager.send_event(EventMessage::ChannelGroupExhausted { + target_id: target.id, + group_vid: group.group_vid.get(), + }); + return create_custom_video_stream_response( + &app_state.provider_stream_ctx(), + &fingerprint.addr, + CustomVideoStreamType::ChannelUnavailable, + ) + .into_response(); + }; + (input, Cow::Owned(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 +368,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 +389,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: Cow<'_, str> = match &source { + PlaybackSource::Group { group, .. } => Cow::Owned(group_shared_key(target.id, group.group_vid.get())), + PlaybackSource::Single { stream_url, .. } => Cow::Borrowed(*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 mut shared_lock = if share_stream { + 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 +423,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 +435,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 +463,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 +476,52 @@ 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) => { + // A hold can last the whole grace period: viewers joining this group's shared stream + // must not wait behind it, so the share lock is given up while holding. + drop(shared_lock.take()); + let hold_duration = Duration::from_millis(app_state.get_grace_options().period_millis); + let mut passed = wait_for_user_capacity(app_state, user, tokio::time::Instant::now() + hold_duration).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 +531,262 @@ 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; + if share_stream { + let write_lock = app_state.app_config.file_locks.write_lock_str(shared_lookup_key.as_ref()).await; + if let Some(value) = try_shared_stream_response_if_any( + app_state, + shared_lookup_key.as_ref(), + fingerprint, + user, + connection_permission, + connection_kind, + stream_channel.clone(), + session_token, + req_headers, + activation.placeholder_transition_version, + ) + .await + { + return value.into_response(); + } + let _ = shared_lock.insert(write_lock); + } + } + + let stream_options = get_stream_options(&app_state.app_config, StreamResponseMode::Stream); + + // A group started on an MPEG-TS member can fail over at runtime. + let (mut stream_details, input, stream_url, mut stream_channel, group_runtime) = 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::::from(stream_url), stream_channel, None) + } + PlaybackSource::Group { group, mode, prefer } => { + let playback = group_playback_settings(app_state, target); + let target_name: Arc = Arc::from(target.name.as_str()); + let priority = connection_priority_for_kind(user, connection_kind); + // The start budget bounds selection, provider open and payload probe of all attempts. + let budget_secs = if playback.start.timeout_secs == 0 { 86_400 } else { playback.start.timeout_secs }; + let start_deadline = tokio::time::Instant::now() + Duration::from_secs(budget_secs); + let open_req = GroupOpenRequest { + app_state, + user, + fingerprint, + req_headers, + group: &group, + base_channel: &stream_channel, + stream_options: &stream_options, + share_stream, + connection_permission, + connection_kind, + priority, + session_token, + is_reopen: false, + deadline: start_deadline, + }; + let mut exclude: Vec = Vec::new(); + let mut demoted_inputs: Vec> = Vec::new(); + let mut opened: Option = None; + let mut maintenance_only = false; + for attempt in 0..playback.start.max_attempts.max(1) { + let remaining = start_deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + break; + } + let req = GroupSelectionRequest { + mode: mode.clone(), + allow_provider_grace: playback.allow_provider_grace, + exclude: &exclude, + demoted_inputs: &demoted_inputs, + prefer: if attempt == 0 { prefer } else { None }, + bypass_maintenance: user.is_recording_proxy_user(), + ..GroupSelectionRequest::new( + &target_name, + &group, + playback.start, + &fingerprint.addr, + &user.username, + priority, + connection_kind, + ) + }; + let acquired = match tokio::time::timeout(remaining, acquire_group_member(app_state, req)).await { + Ok(Ok(acquired)) => acquired, + Ok(Err(GroupSelectionFailure::Maintenance)) => { + maintenance_only = exclude.is_empty(); + break; + } + Ok(Err(GroupSelectionFailure::Exhausted | GroupSelectionFailure::NoCandidate)) | Err(_) => break, + }; + match open_group_member(&open_req, acquired).await { + Ok(member) => { + opened = Some(member); + break; + } + Err(failure) => { + if matches!(failure.class, FailureClass::Auth | FailureClass::Capacity) { + demoted_inputs.push(failure.input_name); + } + exclude.push(failure.index); + } + } + } + + let Some(opened) = opened else { + app_state + .active_users + .release_unbound_session_reservation( + &user.username, + session_token, + activation.placeholder_transition_version, + activation.placeholder_transition_version.is_some(), + ) + .await; + if maintenance_only { + return admission_failure_response( + app_state, + fingerprint, + user, + stream_channel, + Arc::clone(&initial_input.name), + req_headers, + ConnectFailureReason::Maintenance, + ); + } + app_state.event_manager.send_event(EventMessage::ChannelGroupExhausted { + target_id: target.id, + group_vid: group.group_vid.get(), + }); + 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(); + }; + + app_state.event_manager.send_event(EventMessage::ChannelGroupMemberSelected { + target_id: target.id, + group_vid: group.group_vid.get(), + member_vid: opened.member.member_vid.get(), + tier: opened.tier, + }); + app_state + .active_users + .bind_member( + &user.username, + session_token, + tuliprox_session::MemberBinding { + group_vid: group.group_vid.get(), + member_vid: opened.member.member_vid.get(), + member_index: opened.index, + input_name: Arc::clone(&opened.member.input_name), + }, + ) + .await; + // The session plays the member's provider now, not the representative's. + let member_provider = + opened.details.provider_name.clone().unwrap_or_else(|| Arc::clone(&opened.member.input_name)); + app_state + .active_users + .update_session_provider_binding( + &user.username, + session_token, + member_provider, + Arc::clone(&opened.url), + ) + .await; + let OpenedGroupMember { index, details, input, url, channel, payload, .. } = opened; + let runtime = + (playback.failover.enabled && payload == ProbedPayload::Ts && item_type == PlaylistItemType::Live) + .then(|| (Arc::clone(&group), index, playback)); + (details, input, url, channel, runtime) } }; @@ -505,13 +802,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 +836,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::::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::::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 +901,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(), @@ -670,6 +968,40 @@ pub(crate) async fn stream_response_with_provider_handle( } else { false }; + let has_provider_stream = stream_details.stream.is_some() && stream_details.custom_reason.is_none(); + let group_driver = group_runtime.filter(|_| has_provider_stream).map(|(group, index, playback)| { + GroupFailoverDriver::new( + Arc::new(GroupFailoverContext { + app_state: Arc::clone(app_state), + target: Arc::clone(target), + target_name: target.name.as_str().into(), + group, + playback, + user: user.clone(), + fingerprint: fingerprint.clone(), + req_headers: req_headers.clone(), + session_token: Arc::from(session_token), + connection_kind, + connection_permission, + share_stream, + base_channel: stream_channel.clone(), + shared_key: is_stream_shared.then(|| Arc::from(shared_lookup_key.as_ref())), + }), + index, + ) + }); + // A shared producer fails over inside the stream it feeds; a direct response through its + // client stream, which owns the provider slot. + let group_failover = match group_driver { + Some(driver) if is_stream_shared => { + // The shared stream takes over this handle below. + let slot = provider_handle.as_ref(); + stream_details.stream = + stream_details.stream.take().map(|stream| GroupFailoverStream::new(driver, stream, slot).boxed()); + None + } + driver => driver, + }; let stream = match create_active_client_stream(crate::api::model::ActiveClientStreamParams { stream_details, app_state, @@ -683,6 +1015,7 @@ pub(crate) async fn stream_response_with_provider_handle( req_headers, meter_uid: metering.meter_uid, meter_stream: metering.meter_stream, + group_failover, }) .await { @@ -722,7 +1055,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 +1107,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!( diff --git a/backend/app/src/api/api_utils/streaming.rs b/backend/app/src/api/api_utils/streaming.rs index cea64b0d5..6d657fa5a 100644 --- a/backend/app/src/api/api_utils/streaming.rs +++ b/backend/app/src/api/api_utils/streaming.rs @@ -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, + session: &UserSession, + fallback_input: &Arc, + fallback_channel: StreamChannel, +) -> (Arc, 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, @@ -657,11 +676,13 @@ pub(super) async fn force_stream_response( fingerprint: &Fingerprint, app_state: &Arc, user_session: &UserSession, - mut stream_channel: StreamChannel, + stream_channel: StreamChannel, ctx: ForceStreamRequestContext<'_>, grace_mode: Option, 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, @@ -898,6 +919,7 @@ pub(super) async fn force_stream_response( req_headers: ctx.req_headers, meter_uid: metering.meter_uid, meter_stream: metering.meter_stream, + group_failover: None, }) .await { @@ -952,7 +974,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 +1012,9 @@ pub(crate) async fn stream_response( session_token: &str, request_class: Option, stream_channel: StreamChannel, - stream_url: &str, + source: PlaybackSource<'_>, pinned_provider: Option<&Arc>, req_headers: &HeaderMap, - input: &Arc, target: &Arc, user: &ProxyUserCredentials, connection_permission: UserConnectionPermission, @@ -1007,10 +1028,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, @@ -1139,6 +1159,7 @@ pub(super) async fn try_shared_stream_response_if_any( req_headers, meter_uid: metering.meter_uid, meter_stream: metering.meter_stream, + group_failover: None, }) .await { diff --git a/backend/app/src/api/api_utils/tests.rs b/backend/app/src/api/api_utils/tests.rs index 77a58cc05..947421ddf 100644 --- a/backend/app/src/api/api_utils/tests.rs +++ b/backend/app/src/api/api_utils/tests.rs @@ -37,3 +37,10 @@ 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_probe; +mod channel_group_runtime; +mod channel_group_selection; +mod channel_group_sticky; +pub(crate) mod group_playback_support; diff --git a/backend/app/src/api/api_utils/tests/admission_session_admission.rs b/backend/app/src/api/api_utils/tests/admission_session_admission.rs index cd02fedfe..2df1d54e9 100644 --- a/backend/app/src/api/api_utils/tests/admission_session_admission.rs +++ b/backend/app/src/api/api_utils/tests/admission_session_admission.rs @@ -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, diff --git a/backend/app/src/api/api_utils/tests/channel_group_playback.rs b/backend/app/src/api/api_utils/tests/channel_group_playback.rs new file mode 100644 index 000000000..1260b3c7c --- /dev/null +++ b/backend/app/src/api/api_utils/tests/channel_group_playback.rs @@ -0,0 +1,178 @@ +use super::{ + group_playback_support::{ts_fixture, GroupPlaybackEnv}, + support::FakeOriginMode, +}; +use shared::model::AdmissionStrategy; +use std::sync::Arc; +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 +} + +#[tokio::test] +async fn group_skips_member_in_maintenance() { + let env = GroupPlaybackEnv::builder(&["solis", "korath"]).with_qos("solis", 1, 95).build().await; + env.app_state.maintenance.add("solis", None, 1).await.expect("maintenance"); + assert_eq!(env.get_xtream_live(env.group_vid).await.status(), 200); + assert_eq!((env.origin_hits("solis"), env.origin_hits("korath")), (0, 1)); +} + +#[tokio::test] +async fn group_with_all_members_in_maintenance_returns_maintenance() { + let env = GroupPlaybackEnv::builder(&["solis", "korath"]).build().await; + env.app_state.maintenance.add("solis", None, 1).await.expect("maintenance"); + env.app_state.maintenance.add("korath", None, 1).await.expect("maintenance"); + let response = env.get_xtream_live(env.group_vid).await; + assert_ne!(response.status(), 200); + assert_eq!((env.origin_hits("solis"), env.origin_hits("korath")), (0, 0)); + assert_eq!(env.slots_in_use(), 0); +} + +#[tokio::test] +async fn start_budget_bounds_all_attempts() { + let blocked = Arc::new(tokio::sync::Notify::new()); + let channel_groups = shared::model::ChannelGroupsConfig { + start: shared::model::ChannelGroupStartConfig { timeout_secs: 2, ..Default::default() }, + ..Default::default() + }; + let env = GroupPlaybackEnv::builder(&["solis", "korath"]) + .origin_mode("solis", FakeOriginMode::BlockFirstChunk(Arc::clone(&blocked))) + .origin_mode("korath", FakeOriginMode::BlockFirstChunk(Arc::clone(&blocked))) + .with_channel_groups(channel_groups) + .build() + .await; + let started = std::time::Instant::now(); + let response = env.get_xtream_live(env.group_vid).await; + let elapsed = started.elapsed(); + blocked.notify_waiters(); + assert!(elapsed < std::time::Duration::from_millis(2_500), "start took {elapsed:?}"); + drop(response); + // A failed open releases its slot once the dropped provider body completes. + let released = async { + while env.slots_in_use() != 0 { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }; + assert!(tokio::time::timeout(std::time::Duration::from_secs(2), released).await.is_ok()); +} diff --git a/backend/app/src/api/api_utils/tests/channel_group_probe.rs b/backend/app/src/api/api_utils/tests/channel_group_probe.rs new file mode 100644 index 000000000..e6419e646 --- /dev/null +++ b/backend/app/src/api/api_utils/tests/channel_group_probe.rs @@ -0,0 +1,213 @@ +use super::{ + super::channel_group::{ + probe_member_payload, wait_for_capacity, wait_for_user_capacity, ProbedPayload, PROBE_MIN_BYTES, + }, + group_playback_support::ts_fixture, + support::{create_test_app_config, create_test_app_state_for_config}, +}; +use crate::{ + api::model::{BoxedProviderStream, StreamError}, + model::ProxyUserCredentials, +}; +use bytes::Bytes; +use futures::StreamExt; +use std::{sync::Arc, time::Duration}; +use tokio::time::Instant; + +fn stream_of(chunks: Vec) -> BoxedProviderStream { + futures::stream::iter(chunks.into_iter().map(Ok::)).boxed() +} + +async fn collect(stream: BoxedProviderStream) -> Vec { + stream + .fold(Vec::new(), |mut acc, chunk| async move { + acc.extend_from_slice(&chunk.expect("chunk")); + acc + }) + .await +} + +fn deadline_in(secs: u64) -> Instant { Instant::now() + Duration::from_secs(secs) } + +#[tokio::test] +async fn probe_accepts_ts_split_over_chunks() { + let ts = ts_fixture(); + let chunks = ts.chunks(100).map(Bytes::copy_from_slice).collect(); + let (stream, payload) = probe_member_payload(stream_of(chunks), false, deadline_in(3)).await.expect("ts accepted"); + assert!(matches!(payload, ProbedPayload::Ts)); + assert_eq!(collect(stream).await, ts); +} + +#[tokio::test] +async fn probe_accepts_extm3u_with_text_plain() { + let body = Bytes::from_static(b"\xEF\xBB\xBF #EXTM3U\n#EXT-X-VERSION:3\nsegment.ts\n"); + let (stream, payload) = + probe_member_payload(stream_of(vec![body.clone()]), false, deadline_in(3)).await.expect("playlist accepted"); + assert!(matches!(payload, ProbedPayload::Playlist)); + assert_eq!(collect(stream).await, body.to_vec()); +} + +#[tokio::test] +async fn probe_trusts_adaptive_hint_without_reading() { + let pending: BoxedProviderStream = futures::stream::pending().boxed(); + let (_stream, payload) = probe_member_payload(pending, true, deadline_in(3)).await.expect("hint accepted"); + assert!(matches!(payload, ProbedPayload::Playlist)); +} + +#[tokio::test] +async fn probe_rejects_garbage() { + let body = Bytes::from(vec![b'x'; PROBE_MIN_BYTES * 2]); + assert!(probe_member_payload(stream_of(vec![body]), false, deadline_in(3)).await.is_err()); +} + +#[tokio::test(start_paused = true)] +async fn probe_rejects_endless_empty_chunks_within_deadline() { + let started = Instant::now(); + let deadline = started + Duration::from_secs(3); + let empty: BoxedProviderStream = futures::stream::repeat_with(|| Ok::(Bytes::new())).boxed(); + assert!(probe_member_payload(empty, false, deadline).await.is_err()); + assert!(Instant::now() <= deadline); +} + +#[tokio::test(start_paused = true)] +async fn probe_times_out_on_silent_stream() { + let deadline = deadline_in(3); + let silent: BoxedProviderStream = futures::stream::pending().boxed(); + assert!(probe_member_payload(silent, false, deadline).await.is_err()); + assert!(Instant::now() >= deadline); +} + +#[tokio::test] +async fn probe_puts_read_chunks_back_in_order() { + let ts = ts_fixture(); + let mut tail = ts.clone(); + tail.extend_from_slice(&ts); + let chunks = vec![ + Bytes::copy_from_slice(&ts[..10]), + Bytes::new(), + Bytes::copy_from_slice(&ts[10..]), + Bytes::from(ts.clone()), + ]; + let (stream, _) = probe_member_payload(stream_of(chunks), false, deadline_in(3)).await.expect("ts accepted"); + assert_eq!(collect(stream).await, tail); +} + +fn viewer(max_connections: u32) -> ProxyUserCredentials { + let mut user = ProxyUserCredentials::default(); + user.username = "viewer".to_string(); + user.max_connections = max_connections; + user +} + +#[tokio::test] +async fn wait_for_user_capacity_returns_immediately_below_limit() { + let app_state = create_test_app_state_for_config(Arc::new(create_test_app_config())); + assert!(wait_for_user_capacity(&app_state, &viewer(1), deadline_in(2)).await); +} + +#[tokio::test] +async fn wait_for_user_capacity_times_out() { + let app_state = create_test_app_state_for_config(Arc::new(create_test_app_config())); + let started = Instant::now(); + assert!(!wait_for_user_capacity(&app_state, &viewer(0), started + Duration::from_millis(300)).await); + assert!(started.elapsed() >= Duration::from_millis(300)); +} + +#[tokio::test] +async fn wait_for_capacity_rechecks_on_notification() { + let notify = Arc::new(tokio::sync::Notify::new()); + let checks = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let started = Instant::now(); + let waiter = { + let notify = Arc::clone(¬ify); + let checks = Arc::clone(&checks); + tokio::spawn(async move { + wait_for_capacity(¬ify, deadline_in(5), || { + let checks = Arc::clone(&checks); + async move { checks.fetch_add(1, std::sync::atomic::Ordering::SeqCst) >= 1 } + }) + .await + }) + }; + tokio::time::sleep(Duration::from_millis(100)).await; + notify.notify_waiters(); + assert!(waiter.await.expect("waiter")); + assert!(started.elapsed() < Duration::from_secs(1)); +} + +#[tokio::test] +async fn refresh_member_keys_keeps_only_group_member_probes() { + let env = super::group_playback_support::GroupPlaybackEnv::builder(&["solis", "korath"]).build().await; + let member = env.group_record.members[0].clone(); + let mut stranger = member.clone(); + stranger.input_stream_id = "not-a-member".into(); + assert_eq!(crate::api::tasks::refresh_member_keys(&env.app_state).await, env.group_record.members.len()); + let video = Some(r#"{"codec_name":"h264","width":1920,"height":1080}"#); + let health = &env.app_state.member_health; + health.patch_probe(&tuliprox_session::channel_group::member_key(&stranger), video, 4_000); + health.patch_probe(&tuliprox_session::channel_group::member_key(&member), video, 4_000); + let target: Arc = env.target.name.as_str().into(); + let now = std::time::Instant::now(); + assert!(health.health(&target, &stranger, now).probe.is_none()); + assert!(health.health(&target, &member, now).probe.is_some()); +} + +#[tokio::test] +async fn probe_rejects_short_text_body_with_sync_byte() { + let body = Bytes::from_static(b"Gone. Please try again later."); + assert!(probe_member_payload(stream_of(vec![body]), false, deadline_in(3)).await.is_err()); +} + +#[tokio::test] +async fn prefixed_stream_passes_inner_after_head() { + use super::super::channel_group::PrefixedStream; + let head = vec![Bytes::from_static(b"ab"), Bytes::from_static(b"c")]; + let inner = stream_of(vec![Bytes::from_static(b"de")]); + let stream = PrefixedStream::new(head, inner).boxed(); + assert_eq!(collect(stream).await, b"abcde"); +} + +async fn wait_for(timeout: Duration, mut condition: impl FnMut() -> bool) -> bool { + let deadline = Instant::now() + timeout; + while !condition() { + if Instant::now() >= deadline { + return false; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + true +} + +#[tokio::test] +async fn member_key_task_refreshes_on_sources_change_and_stops_on_cancel() { + let env = super::group_playback_support::GroupPlaybackEnv::builder(&["solis", "korath"]).build().await; + let member = env.group_record.members[0].clone(); + let key = tuliprox_session::channel_group::member_key(&member); + let target: Arc = env.target.name.as_str().into(); + let health = Arc::clone(&env.app_state.member_health); + let video = Some(r#"{"codec_name":"h264","width":1920,"height":1080}"#); + let cancel = tokio_util::sync::CancellationToken::new(); + let task = crate::api::tasks::exec_channel_group_member_keys(&env.app_state, &cancel); + // The startup refresh registers the members. + assert!( + wait_for(Duration::from_secs(2), || { + health.patch_probe(&key, video, 1_000); + health.health(&target, &member, std::time::Instant::now()).probe.is_some() + }) + .await + ); + health.set_member_keys(std::collections::HashSet::new()); + env.app_state + .event_manager + .send_event(shared::model::EventMessage::ConfigChange(shared::model::ConfigType::Sources)); + assert!( + wait_for(Duration::from_secs(2), || { + health.patch_probe(&key, video, 1_000); + health.health(&target, &member, std::time::Instant::now()).probe.is_some() + }) + .await, + "a sources change refreshes the member keys" + ); + cancel.cancel(); + assert!(tokio::time::timeout(Duration::from_secs(2), task).await.is_ok(), "the task stops on cancel"); +} diff --git a/backend/app/src/api/api_utils/tests/channel_group_runtime.rs b/backend/app/src/api/api_utils/tests/channel_group_runtime.rs new file mode 100644 index 000000000..9649f8a82 --- /dev/null +++ b/backend/app/src/api/api_utils/tests/channel_group_runtime.rs @@ -0,0 +1,502 @@ +use super::{ + group_playback_support::{has_pid, member_ts, read_body_until, GroupPlaybackEnv}, + support::FakeOriginMode, +}; +use shared::model::{ChannelGroupFailoverConfig, EventMessage, SwitchReason}; +use std::time::Duration; + +const SOLIS_PID: u16 = 0x100; +const KORATH_PID: u16 = 0x200; + +fn runtime_selection(no_data_timeout_secs: u64, max_outage_secs: u64) -> ChannelGroupFailoverConfig { + ChannelGroupFailoverConfig { no_data_timeout_secs, max_outage_secs, ..ChannelGroupFailoverConfig::default() } +} + +async fn wait_until(timeout: Duration, condition: impl Fn() -> bool) -> bool { + let deadline = tokio::time::Instant::now() + timeout; + while !condition() { + if tokio::time::Instant::now() >= deadline { + return false; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + true +} + +fn input_slots(env: &GroupPlaybackEnv, input: &str) -> usize { + env.app_state.active_provider.active_connections().unwrap_or_default().get(input).copied().unwrap_or_default() +} + +#[tokio::test] +async fn switches_member_on_upstream_eof_without_client_reconnect() { + let env = GroupPlaybackEnv::builder(&["solis", "korath"]) + .with_qos("solis", 1, 95) + .origin_mode("solis", FakeOriginMode::FixedBytes(member_ts(SOLIS_PID, 40))) + .origin_mode("korath", FakeOriginMode::TsStream(member_ts(KORATH_PID, 40))) + .build() + .await; + let response = env.get_xtream_live(env.group_vid).await; + assert_eq!(response.status(), 200); + let (body, ended) = read_body_until(response, Duration::from_secs(5), |read| has_pid(read, KORATH_PID)).await; + assert!(!ended, "the client body stays open across the switch"); + assert!(has_pid(&body, SOLIS_PID) && has_pid(&body, KORATH_PID)); + assert_eq!((env.origin_hits("solis"), env.origin_hits("korath")), (1, 1)); +} + +#[tokio::test] +async fn switches_member_on_stall() { + let env = GroupPlaybackEnv::builder(&["solis", "korath"]) + .with_qos("solis", 1, 95) + .with_failover(runtime_selection(1, 20)) + .origin_mode("solis", FakeOriginMode::TsThenStall(member_ts(SOLIS_PID, 40))) + .origin_mode("korath", FakeOriginMode::TsStream(member_ts(KORATH_PID, 40))) + .build() + .await; + let response = env.get_xtream_live(env.group_vid).await; + let (body, ended) = read_body_until(response, Duration::from_secs(5), |read| has_pid(read, KORATH_PID)).await; + assert!(!ended); + assert!(has_pid(&body, KORATH_PID)); +} + +#[tokio::test] +async fn provider_slot_released_on_switch() { + let env = GroupPlaybackEnv::builder(&["solis", "korath"]) + .with_qos("solis", 1, 95) + .origin_mode("solis", FakeOriginMode::FixedBytes(member_ts(SOLIS_PID, 40))) + .origin_mode("korath", FakeOriginMode::TsStream(member_ts(KORATH_PID, 40))) + .build() + .await; + let response = env.get_xtream_live(env.group_vid).await; + let (_body, ended) = read_body_until(response, Duration::from_secs(5), |read| has_pid(read, KORATH_PID)).await; + assert!(!ended); + assert!( + wait_until(Duration::from_secs(2), || input_slots(&env, "solis") == 0 && input_slots(&env, "korath") == 1) + .await + ); +} + +#[tokio::test] +async fn runtime_budget_exhausted_ends_with_channel_unavailable() { + let env = GroupPlaybackEnv::builder(&["solis", "korath"]) + .with_failover(runtime_selection(8, 2)) + .origin_mode("solis", FakeOriginMode::FixedBytes(member_ts(SOLIS_PID, 10))) + .origin_mode("korath", FakeOriginMode::FixedBytes(member_ts(KORATH_PID, 10))) + .build() + .await; + let response = env.get_xtream_live(env.group_vid).await; + let (_body, ended) = read_body_until(response, Duration::from_secs(8), |_| false).await; + assert!(ended, "the body ends once the switch budget is spent"); + assert!(wait_until(Duration::from_secs(2), || env.slots_in_use() == 0).await); +} + +#[tokio::test] +async fn runtime_switch_does_not_use_grace() { + let env = GroupPlaybackEnv::builder(&["solis", "korath"]) + .with_qos("solis", 1, 95) + .with_failover(runtime_selection(8, 2)) + .origin_mode("solis", FakeOriginMode::FixedBytes(member_ts(SOLIS_PID, 40))) + .origin_mode("korath", FakeOriginMode::TsStream(member_ts(KORATH_PID, 40))) + .build() + .await; + let _korath_full = env.hold("korath", 0); + let response = env.get_xtream_live(env.group_vid).await; + let (body, ended) = read_body_until(response, Duration::from_secs(6), |_| false).await; + assert!(ended); + assert!(!has_pid(&body, KORATH_PID)); + assert_eq!(env.origin_hits("korath"), 0); +} + +#[tokio::test] +async fn switch_emits_member_switched_event() { + let env = GroupPlaybackEnv::builder(&["solis", "korath"]) + .with_qos("solis", 1, 95) + .origin_mode("solis", FakeOriginMode::FixedBytes(member_ts(SOLIS_PID, 40))) + .origin_mode("korath", FakeOriginMode::TsStream(member_ts(KORATH_PID, 40))) + .build() + .await; + let mut events = env.app_state.event_manager.get_event_channel(); + let response = env.get_xtream_live(env.group_vid).await; + let (_body, _) = read_body_until(response, Duration::from_secs(5), |read| has_pid(read, KORATH_PID)).await; + let switched = tokio::time::timeout(Duration::from_secs(2), async { + loop { + if let Ok(EventMessage::ChannelGroupMemberSwitched { from_member_vid, to_member_vid, reason, .. }) = + events.recv().await + { + return (from_member_vid, to_member_vid, reason); + } + } + }) + .await + .expect("switch event"); + assert_eq!(switched, (env.member_vid("solis"), env.member_vid("korath"), SwitchReason::UpstreamEof)); +} + +#[tokio::test] +async fn non_group_stream_eof_unchanged() { + let plain = member_ts(SOLIS_PID, 10); + let env = GroupPlaybackEnv::builder(&["solis", "korath"]) + .origin_mode("solis", FakeOriginMode::FixedBytes(plain.clone())) + .build() + .await; + let response = env.get_xtream_live(env.plain_vid).await; + let (body, ended) = read_body_until(response, Duration::from_secs(5), |_| false).await; + assert!(ended); + assert_eq!(body, plain); + assert_eq!(env.origin_hits("korath"), 0); +} + +#[tokio::test] +async fn switch_continues_at_pat_with_new_version() { + let env = GroupPlaybackEnv::builder(&["solis", "korath"]) + .with_qos("solis", 1, 95) + .origin_mode("solis", FakeOriginMode::FixedBytes(member_ts(SOLIS_PID, 40))) + .origin_mode("korath", FakeOriginMode::TsStream(member_ts(KORATH_PID, 40))) + .build() + .await; + let response = env.get_xtream_live(env.group_vid).await; + let (body, _) = read_body_until(response, Duration::from_secs(5), |read| has_pid(read, KORATH_PID)).await; + let packets: Vec<&[u8]> = body.as_chunks::<188>().0.iter().map(<[u8; 188]>::as_slice).collect(); + let pid = |packet: &[u8]| (u16::from(packet[1] & 0x1F) << 8) | u16::from(packet[2]); + let last_solis = packets.iter().rposition(|packet| pid(packet) == SOLIS_PID).expect("solis packets"); + let first_korath = packets.iter().position(|packet| pid(packet) == KORATH_PID).expect("korath packets"); + let between: Vec = packets[last_solis + 1..first_korath].iter().map(|packet| pid(packet)).collect(); + let splice = between.iter().position(|pid| *pid == 0).expect("PAT before the new member"); + assert!(between[..splice].iter().all(|pid| *pid == 0x1FFF), "only keep-alive null packets before the splice"); + assert_eq!(between[splice + 1], 0x1000, "PMT follows the PAT"); + let pat = packets[last_solis + 1 + splice]; + assert_eq!((pat[10] >> 1) & 0x1F, 1, "PAT version continues after the first member's version 0"); +} + +#[tokio::test] +async fn shared_group_stream_switches_member_and_moves_allocation() { + let env = GroupPlaybackEnv::builder(&["solis", "korath"]) + .share_ts() + .with_qos("solis", 1, 95) + .origin_mode("solis", FakeOriginMode::FixedBytes(member_ts(SOLIS_PID, 40))) + .origin_mode("korath", FakeOriginMode::TsStream(member_ts(KORATH_PID, 40))) + .build() + .await; + let response = env.get_xtream_live(env.group_vid).await; + assert_eq!(response.status(), 200); + let (body, ended) = read_body_until(response, Duration::from_secs(5), |read| has_pid(read, KORATH_PID)).await; + assert!(!ended, "the shared subscriber keeps its response across the switch"); + assert!(has_pid(&body, KORATH_PID)); + assert!( + wait_until(Duration::from_secs(2), || input_slots(&env, "solis") == 0 && input_slots(&env, "korath") == 1) + .await + ); +} + +#[tokio::test] +async fn group_stream_sleep_timer_ends_without_switch() { + let env = GroupPlaybackEnv::builder(&["solis", "korath"]) + .with_qos("solis", 1, 95) + // Only an end of the member body may trigger a switch; the paused clock would fire the + // stall and underrun checks. + .with_failover(ChannelGroupFailoverConfig { + no_data_timeout_secs: 0, + slow_source_pct: 0, + ..ChannelGroupFailoverConfig::default() + }) + .sleep_timer_mins(1) + .origin_mode("solis", FakeOriginMode::TsStream(member_ts(SOLIS_PID, 40))) + .origin_mode("korath", FakeOriginMode::TsStream(member_ts(KORATH_PID, 40))) + .build() + .await; + let response = env.get_xtream_live(env.group_vid).await; + tokio::time::pause(); + let (_body, ended) = read_body_until(response, Duration::from_secs(120), |_| false).await; + assert!(ended, "the sleep timer ends the response"); + assert_eq!(env.origin_hits("korath"), 0, "the sleep timer triggers no switch"); + assert!( + matches!(env.circuit("solis"), tuliprox_session::channel_group::CircuitView::Closed), + "the sleep timer is no member failure" + ); +} + +#[tokio::test] +async fn start_skips_member_whose_open_hangs() { + let env = GroupPlaybackEnv::builder(&["solis", "korath"]) + .with_qos("solis", 1, 95) + .origin_mode("solis", FakeOriginMode::Silent) + .origin_mode("korath", FakeOriginMode::TsStream(member_ts(KORATH_PID, 40))) + .build() + .await; + let response = tokio::time::timeout(Duration::from_secs(6), env.get_xtream_live(env.group_vid)) + .await + .expect("the hanging open is bounded"); + let (body, _) = read_body_until(response, Duration::from_secs(3), |read| has_pid(read, KORATH_PID)).await; + assert!(has_pid(&body, KORATH_PID)); +} + +#[tokio::test] +async fn runtime_switch_to_silent_member_respects_budget() { + let env = GroupPlaybackEnv::builder(&["solis", "korath"]) + .with_qos("solis", 1, 95) + .with_failover(runtime_selection(8, 2)) + .origin_mode("solis", FakeOriginMode::FixedBytes(member_ts(SOLIS_PID, 10))) + .origin_mode("korath", FakeOriginMode::Silent) + .build() + .await; + let response = env.get_xtream_live(env.group_vid).await; + let (_body, ended) = read_body_until(response, Duration::from_secs(6), |_| false).await; + assert!(ended, "the switch budget bounds a member that never answers"); + assert!(wait_until(Duration::from_secs(2), || env.slots_in_use() == 0).await); +} + +#[tokio::test] +async fn stall_after_spent_budget_ends_stream() { + let env = GroupPlaybackEnv::builder(&["solis", "korath"]) + .with_failover(runtime_selection(1, 2)) + .origin_mode("solis", FakeOriginMode::TsThenStall(member_ts(SOLIS_PID, 10))) + .origin_mode("korath", FakeOriginMode::TsThenStall(member_ts(KORATH_PID, 10))) + .build() + .await; + let response = env.get_xtream_live(env.group_vid).await; + let (_body, ended) = read_body_until(response, Duration::from_secs(8), |_| false).await; + assert!(ended, "a stall after the spent budget ends the response"); + assert!(wait_until(Duration::from_secs(2), || env.slots_in_use() == 0).await); +} + +#[tokio::test] +async fn retry_same_member_on_single_connection_account() { + let selection = ChannelGroupFailoverConfig { + no_data_timeout_secs: 1, + stable_after_secs: 1, + same_source_retries: 1, + max_outage_secs: 10, + ..ChannelGroupFailoverConfig::default() + }; + let env = GroupPlaybackEnv::builder(&["solis", "korath"]) + .with_qos("solis", 1, 95) + .with_failover(selection) + .max_connections("solis", 1) + .origin_mode("solis", FakeOriginMode::TsThenStall(member_ts(SOLIS_PID, 40))) + .build() + .await; + let _korath_full = env.hold("korath", 0); + let response = env.get_xtream_live(env.group_vid).await; + let (_body, ended) = read_body_until(response, Duration::from_secs(6), |_| env.origin_hits("solis") >= 2).await; + assert!(!ended, "the stream waits for its own released slot instead of ending"); + assert_eq!(env.origin_hits("solis"), 2); +} + +#[tokio::test] +async fn preempted_shared_group_member_does_not_fail_over() { + use super::super::channel_group_failover::{GroupFailoverContext, GroupFailoverDriver, GroupFailoverStream}; + use futures::StreamExt; + + let env = GroupPlaybackEnv::builder(&["solis", "korath"]).build().await; + let user = env.app_state.app_config.get_user_credentials("viewer").expect("viewer"); + let ctx = GroupFailoverContext { + app_state: std::sync::Arc::clone(&env.app_state), + target: std::sync::Arc::clone(&env.target), + target_name: env.target.name.as_str().into(), + group: std::sync::Arc::clone(&env.group_record), + playback: shared::model::ChannelGroupsConfig::default().playback(None), + user: (*user).clone(), + fingerprint: super::support::create_test_fingerprint(std::net::SocketAddr::from(([127, 0, 0, 1], 12345))), + req_headers: axum::http::HeaderMap::new(), + session_token: "preempted-session".into(), + connection_kind: tuliprox_session::active_provider_manager::ConnectionKind::Normal, + connection_permission: shared::model::UserConnectionPermission::Allowed, + share_stream: true, + base_channel: shared::model::StreamChannel::default(), + shared_key: Some("group:1:preempted".into()), + }; + let slot = env + .app_state + .active_provider + .acquire_connection( + &"solis".into(), + &std::net::SocketAddr::from(([127, 0, 0, 1], 1)), + 0, + tuliprox_session::active_provider_manager::ConnectionKind::Normal, + ) + .expect("solis slot"); + let slot = + tuliprox_session::ManagedProviderHandle::new(std::sync::Arc::clone(&env.app_state.active_provider), slot); + // The provider manager cancels this token when it preempts the slot. + slot.handle().and_then(|handle| handle.cancel_token.as_ref()).expect("cancel token").cancel(); + let ended_body = futures::stream::empty().boxed(); + let mut stream = + GroupFailoverStream::new(GroupFailoverDriver::new(std::sync::Arc::new(ctx), 0), ended_body, Some(&slot)); + let next = tokio::time::timeout(Duration::from_secs(2), stream.next()).await.expect("ends without a switch"); + assert!(next.is_none()); + assert_eq!((env.origin_hits("solis"), env.origin_hits("korath")), (0, 0)); +} + +async fn session_provider(env: &GroupPlaybackEnv) -> String { + env.app_state.active_users.bound_member_session_provider("viewer").await.map(|p| p.to_string()).unwrap_or_default() +} + +#[tokio::test] +async fn group_session_provider_follows_selected_member() { + let env = GroupPlaybackEnv::builder(&["solis", "korath"]) + .with_qos("korath", 2, 95) + .origin_mode("korath", FakeOriginMode::TsStream(member_ts(KORATH_PID, 40))) + .build() + .await; + let response = env.get_xtream_live(env.group_vid).await; + let (_body, _) = read_body_until(response, Duration::from_secs(3), |read| has_pid(read, KORATH_PID)).await; + assert_eq!(session_provider(&env).await, "korath"); +} + +#[tokio::test] +async fn group_session_provider_follows_runtime_switch() { + let env = GroupPlaybackEnv::builder(&["solis", "korath"]) + .with_qos("solis", 1, 95) + .origin_mode("solis", FakeOriginMode::FixedBytes(member_ts(SOLIS_PID, 40))) + .origin_mode("korath", FakeOriginMode::TsStream(member_ts(KORATH_PID, 40))) + .build() + .await; + let response = env.get_xtream_live(env.group_vid).await; + let (_body, _) = read_body_until(response, Duration::from_secs(5), |read| has_pid(read, KORATH_PID)).await; + assert_eq!(session_provider(&env).await, "korath"); +} + +fn no_flap_guard(no_data_timeout_secs: u64, max_outage_secs: u64) -> ChannelGroupFailoverConfig { + ChannelGroupFailoverConfig { max_switches: 0, ..runtime_selection(no_data_timeout_secs, max_outage_secs) } +} + +#[tokio::test] +async fn all_members_ending_quickly_are_bounded_by_budget_without_flap_guard() { + let env = GroupPlaybackEnv::builder(&["solis", "korath"]) + .with_failover(no_flap_guard(8, 2)) + .origin_mode("solis", FakeOriginMode::FixedBytes(member_ts(SOLIS_PID, 10))) + .origin_mode("korath", FakeOriginMode::FixedBytes(member_ts(KORATH_PID, 10))) + .build() + .await; + let started = std::time::Instant::now(); + let response = env.get_xtream_live(env.group_vid).await; + let (_body, ended) = read_body_until(response, Duration::from_secs(8), |_| false).await; + assert!(ended, "the switch budget alone ends the response"); + assert!(started.elapsed() < Duration::from_secs(6), "ended after {:?}", started.elapsed()); +} + +#[tokio::test] +async fn all_members_stalling_are_bounded_by_budget_without_flap_guard() { + let env = GroupPlaybackEnv::builder(&["solis", "korath"]) + .with_failover(no_flap_guard(1, 3)) + .origin_mode("solis", FakeOriginMode::TsThenStall(member_ts(SOLIS_PID, 10))) + .origin_mode("korath", FakeOriginMode::TsThenStall(member_ts(KORATH_PID, 10))) + .build() + .await; + let started = std::time::Instant::now(); + let response = env.get_xtream_live(env.group_vid).await; + let (_body, ended) = read_body_until(response, Duration::from_secs(12), |_| false).await; + assert!(ended, "the switch budget alone ends the response"); + assert!(started.elapsed() < Duration::from_secs(9), "ended after {:?}", started.elapsed()); +} + +#[tokio::test] +async fn switch_retries_failed_member_until_budget() { + let env = GroupPlaybackEnv::builder(&["solis", "korath"]) + .with_qos("solis", 1, 95) + .with_failover(no_flap_guard(8, 8)) + .origin_mode("solis", FakeOriginMode::FixedBytes(member_ts(SOLIS_PID, 10))) + .origin_mode("korath", FakeOriginMode::Silent) + .build() + .await; + let response = env.get_xtream_live(env.group_vid).await; + let (_body, ended) = read_body_until(response, Duration::from_secs(14), |_| false).await; + assert!(ended); + assert!(env.origin_hits("korath") >= 2, "korath tried {} times within the budget", env.origin_hits("korath")); +} + +#[tokio::test] +async fn give_up_still_records_member_failure() { + let env = GroupPlaybackEnv::builder(&["solis", "korath"]) + .with_qos("solis", 1, 95) + .with_failover(no_flap_guard(8, 0)) + .origin_mode("solis", FakeOriginMode::FixedBytes(member_ts(SOLIS_PID, 10))) + .build() + .await; + let response = env.get_xtream_live(env.group_vid).await; + let (_body, ended) = read_body_until(response, Duration::from_secs(5), |_| false).await; + assert!(ended, "a spent budget ends the response at once"); + assert!( + matches!(env.circuit("solis"), tuliprox_session::channel_group::CircuitView::Counting(1)), + "the failure that ended the stream counts for the member" + ); +} + +#[tokio::test] +async fn failed_switch_shows_channel_unavailable_video() { + const MEMBER_PID: u16 = 0x150; + let env = GroupPlaybackEnv::builder(&["solis", "korath"]) + .with_qos("solis", 1, 95) + .channel_unavailable_video() + .with_failover(no_flap_guard(8, 2)) + .origin_mode("solis", FakeOriginMode::FixedBytes(member_ts(MEMBER_PID, 10))) + .origin_mode("korath", FakeOriginMode::Status(503)) + .build() + .await; + let response = env.get_xtream_live(env.group_vid).await; + // The fixture's video PID 0x100 shows the channel-unavailable clip. + let (body, _) = read_body_until(response, Duration::from_secs(8), |read| has_pid(read, 0x100)).await; + assert!(has_pid(&body, MEMBER_PID)); + assert!(has_pid(&body, 0x100), "the failed switch ends with the channel-unavailable video"); +} + +#[tokio::test] +async fn shared_switch_keeps_both_subscribers_and_rebinds_their_provider() { + let env = GroupPlaybackEnv::builder(&["solis", "korath"]) + .share_ts() + .with_qos("solis", 1, 95) + .origin_mode("solis", FakeOriginMode::TsThenStall(member_ts(SOLIS_PID, 40))) + .origin_mode("korath", FakeOriginMode::TsStream(member_ts(KORATH_PID, 40))) + .with_failover(runtime_selection(1, 20)) + .build() + .await; + let first = env.get_xtream_live_from(env.group_vid, 12345).await; + let second = env.get_xtream_live_from(env.group_vid, 12346).await; + assert_eq!((first.status().as_u16(), second.status().as_u16()), (200, 200)); + let (one, two) = tokio::join!( + read_body_until(first, Duration::from_secs(6), |read| has_pid(read, KORATH_PID)), + read_body_until(second, Duration::from_secs(6), |read| has_pid(read, KORATH_PID)) + ); + assert!(has_pid(&one.0, KORATH_PID) && has_pid(&two.0, KORATH_PID), "both subscribers continue on korath"); + assert_eq!(env.origin_hits("solis"), 1, "one shared producer"); + let providers = env.app_state.active_users.stream_providers("viewer").await; + assert_eq!(providers.len(), 2); + assert!(providers.iter().all(|provider| provider.as_ref() == "korath"), "providers {providers:?}"); +} + +#[tokio::test] +async fn capacity_rejecting_member_is_opened_once_per_switch() { + let env = GroupPlaybackEnv::builder(&["solis", "korath"]) + .with_qos("solis", 1, 95) + .with_failover(no_flap_guard(8, 4)) + .origin_mode("solis", FakeOriginMode::FixedBytes(member_ts(SOLIS_PID, 10))) + .origin_mode("korath", FakeOriginMode::Status(429)) + .build() + .await; + let response = env.get_xtream_live(env.group_vid).await; + let (_body, ended) = read_body_until(response, Duration::from_secs(10), |_| false).await; + assert!(ended); + // One open repeats the HTTP request itself (request retry, up to 3 requests); further rounds + // of the switch must not open the member again. + assert!( + env.origin_hits("korath") <= 3, + "a provider that reports no capacity is not asked again in the same switch ({} requests)", + env.origin_hits("korath") + ); +} + +#[tokio::test] +async fn disabled_failover_ends_stream_with_its_member() { + let env = GroupPlaybackEnv::builder(&["solis", "korath"]) + .with_qos("solis", 1, 95) + .with_failover(ChannelGroupFailoverConfig { enabled: false, ..ChannelGroupFailoverConfig::default() }) + .origin_mode("solis", FakeOriginMode::FixedBytes(member_ts(SOLIS_PID, 10))) + .origin_mode("korath", FakeOriginMode::TsStream(member_ts(KORATH_PID, 40))) + .build() + .await; + let response = env.get_xtream_live(env.group_vid).await; + let (body, ended) = read_body_until(response, Duration::from_secs(5), |_| false).await; + assert!(ended, "the response ends with its member"); + assert!(has_pid(&body, SOLIS_PID)); + assert!(!has_pid(&body, KORATH_PID), "no switch without failover"); + assert_eq!(env.origin_hits("korath"), 0); +} diff --git a/backend/app/src/api/api_utils/tests/channel_group_selection.rs b/backend/app/src/api/api_utils/tests/channel_group_selection.rs new file mode 100644 index 000000000..1ec8cce99 --- /dev/null +++ b/backend/app/src/api/api_utils/tests/channel_group_selection.rs @@ -0,0 +1,329 @@ +use super::{ + super::channel_group::{ + acquire_group_member, acquire_group_member_with_race, group_playback_settings, group_representative, + member_stream_channel, 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 = + 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 { + 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 { + app_config_with_sources_and_config(sources, Config::default()) +} + +fn app_config_with_sources_and_config(sources: SourcesConfig, mut config: Config) -> tuliprox_core::model::AppConfig { + 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, + group: ChannelGroupRecord, + config: ChannelGroupsConfig, + target: Arc, +} + +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: ChannelGroupsConfig::default(), 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 { + allow_provider_grace: allow_grace, + exclude, + ..GroupSelectionRequest::new( + &self.target, + &self.group, + self.config.start, + &CLIENT_ADDR, + "viewer", + 0, + ConnectionKind::Normal, + ) + } + } + + 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; 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.start.timeout_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); +} + +#[tokio::test] +async fn representative_skips_member_without_configured_input() { + let mut env = SelectionEnv::new(&[("korath", 1)]); + env.group.members.insert(0, group_member("orun", "9", 9, 99)); + let (input, url) = group_representative(&env.app_state, &env.group).expect("representative"); + assert_eq!(input.name.as_ref(), "korath"); + assert_eq!(url.as_ref(), env.group.members[1].url.as_ref()); +} + +#[tokio::test] +async fn representative_of_group_without_resolvable_member_is_none() { + let mut env = SelectionEnv::new(&[("korath", 1)]); + env.group.members.clear(); + assert!(group_representative(&env.app_state, &env.group).is_none()); +} + +#[test] +fn member_stream_channel_carries_member_identity() { + let env_group = ChannelGroupRecord { + group_vid: VirtualId::new(7), + key: "zdf".into(), + origin: ChannelGroupOrigin::Auto, + policy: ChannelGroupPolicy::Priority, + orphaned: Vec::new(), + members: vec![group_member("korath", "42", 42, 300)], + }; + let base = shared::model::StreamChannel { + target_id: 3, + virtual_id: 7, + input_name: "solis".intern(), + ..shared::model::StreamChannel::default() + }; + let channel = member_stream_channel(&base, &env_group, &env_group.members[0]); + assert_eq!((channel.target_id, channel.virtual_id, channel.group_virtual_id), (3, 300, Some(7))); + assert_eq!((channel.input_name.as_ref(), channel.provider_id), ("korath", 42)); + assert_eq!(channel.url.as_ref(), env_group.members[0].url.as_ref()); +} + +#[tokio::test] +async fn group_playback_settings_apply_target_overrides_to_global_config() { + let global = ChannelGroupsConfig { + start: shared::model::ChannelGroupStartConfig { timeout_secs: 7, max_attempts: 2 }, + ..ChannelGroupsConfig::default() + }; + let config = Config { channel_groups: global.clone(), ..Config::default() }; + let app_state = create_test_app_state_for_config(Arc::new(app_config_with_sources_and_config( + SourcesConfig::default(), + config, + ))); + let plain = tuliprox_core::model::ConfigTarget::from(&shared::model::ConfigTargetDto::default()); + assert_eq!(group_playback_settings(&app_state, &plain), global.playback(None)); + + let dto = shared::model::ConfigTargetDto { + options: Some(shared::model::ConfigTargetOptions { + channel_groups: Some(shared::model::ChannelGroupsTargetConfig { + enabled: true, + allow_provider_grace: Some(false), + failover: Some(false), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }; + let target = tuliprox_core::model::ConfigTarget::from(&dto); + let playback = group_playback_settings(&app_state, &target); + assert_eq!(playback.start, global.start); + assert!(!playback.allow_provider_grace); + assert!(!playback.failover.enabled); +} + +#[tokio::test] +async fn own_release_tier_respects_disabled_grace() { + 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(&[], false)).await.expect("acquired"); + assert_eq!((acquired.index, acquired.tier), (1, CapacityTier::Free)); +} + +#[tokio::test] +async fn selection_request_defaults_match_start_path() { + let env = SelectionEnv::new(&[("korath", 1)]); + let req = GroupSelectionRequest::new( + &env.target, + &env.group, + env.config.start, + &CLIENT_ADDR, + "viewer", + 3, + ConnectionKind::Normal, + ); + assert_eq!(req.policy, env.group.policy); + assert_eq!(req.mode, SelectionMode::Live); + assert!(req.lease.is_none() && req.exclude.is_empty() && req.demoted_inputs.is_empty() && req.prefer.is_none()); + assert!(req.allow_provider_grace && req.allow_preempt && !req.bypass_maintenance); + assert_eq!((req.priority, req.username), (3, "viewer")); +} diff --git a/backend/app/src/api/api_utils/tests/channel_group_sticky.rs b/backend/app/src/api/api_utils/tests/channel_group_sticky.rs new file mode 100644 index 000000000..290e0a1e3 --- /dev/null +++ b/backend/app/src/api/api_utils/tests/channel_group_sticky.rs @@ -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); +} diff --git a/backend/app/src/api/api_utils/tests/cleanup.rs b/backend/app/src/api/api_utils/tests/cleanup.rs index f2aacb0a7..0244f856a 100644 --- a/backend/app/src/api/api_utils/tests/cleanup.rs +++ b/backend/app/src/api/api_utils/tests/cleanup.rs @@ -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, diff --git a/backend/app/src/api/api_utils/tests/group_playback_support.rs b/backend/app/src/api/api_utils/tests/group_playback_support.rs new file mode 100644 index 000000000..a11284398 --- /dev/null +++ b/backend/app/src/api/api_utils/tests/group_playback_support.rs @@ -0,0 +1,1131 @@ +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, ChannelGroupsConfig, ChannelGroupsTargetConfig, 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 { + 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 +} + +/// A short MPEG-TS program: PAT, PMT with one H.264 stream on `video_pid`, a keyframe PES start, +/// then `continuation` payload packets on `video_pid`. The video PID tells members apart. +pub(crate) fn member_ts(video_pid: u16, continuation: usize) -> Vec { + fn packet(pid: u16, pusi: bool, adaptation: Option, payload: &[u8]) -> Vec { + let pid_bytes = pid.to_be_bytes(); + let mut packet = vec![0x47, (if pusi { 0x40 } else { 0 }) | (pid_bytes[0] & 0x1F), pid_bytes[1]]; + if let Some(flags) = adaptation { + let adaptation_len = 183 - payload.len(); + packet.extend([0x30, u8::try_from(adaptation_len).expect("adaptation"), flags]); + packet.extend(std::iter::repeat_n(0xFF, adaptation_len - 1)); + } else { + packet.push(0x10); + } + packet.extend_from_slice(payload); + packet.resize(188, 0xFF); + packet + } + fn section(mut body: Vec) -> Vec { + let length = u8::try_from(body.len() - 3 + 4).expect("section length"); + body[1] = 0xB0; + body[2] = length; + let crc = tuliprox_mpegts::packet::mpeg2_crc32(&body); + body.extend_from_slice(&crc.to_be_bytes()); + let mut payload = vec![0x00]; + payload.extend(body); + payload + } + let pid = video_pid.to_be_bytes(); + let pat = section(vec![0x00, 0, 0, 0x00, 0x01, 0xC1, 0x00, 0x00, 0x00, 0x01, 0xF0, 0x00]); + let pmt = section(vec![ + 0x02, + 0, + 0, + 0x00, + 0x01, + 0xC1, + 0x00, + 0x00, + 0xE0 | pid[0], + pid[1], + 0xF0, + 0x00, + 0x1B, + 0xE0 | pid[0], + pid[1], + 0xF0, + 0x00, + ]); + let keyframe = [ + 0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x80, 0x05, 0x21, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x65, + ]; + let mut ts = packet(0, true, None, &pat); + ts.extend(packet(0x1000, true, None, &pmt)); + ts.extend(packet(video_pid, true, Some(0x40), &keyframe)); + for _ in 0..continuation { + ts.extend(packet(video_pid, false, None, &[0x11; 184])); + } + ts +} + +/// Whether `bytes` holds a TS packet on `pid` at a packet boundary. +pub(crate) fn has_pid(bytes: &[u8], pid: u16) -> bool { + bytes + .as_chunks::<188>() + .0 + .iter() + .any(|packet| packet[0] == 0x47 && ((u16::from(packet[1] & 0x1F) << 8) | u16::from(packet[2])) == pid) +} + +/// Reads the response body until `done` holds for what was read, the body ends, or `timeout` +/// passes. Returns the bytes read and whether the body ended. +pub(crate) async fn read_body_until( + response: Response, + timeout: std::time::Duration, + done: impl Fn(&[u8]) -> bool, +) -> (Vec, bool) { + use futures::StreamExt; + let mut body = response.into_body().into_data_stream(); + let mut read = Vec::new(); + let deadline = tokio::time::Instant::now() + timeout; + loop { + if done(&read) { + return (read, false); + } + match tokio::time::timeout_at(deadline, body.next()).await { + Ok(Some(Ok(chunk))) => read.extend_from_slice(&chunk), + Ok(Some(Err(_)) | None) => return (read, true), + Err(_) => return (read, false), + } + } +} + +pub(crate) struct TrackedOrigin { + pub addr: SocketAddr, + pub requests: Arc>>, + 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::TsThenStall(bytes) => { + let head = "HTTP/1.1 200 OK\r\nContent-Type: video/mp2t\r\nConnection: close\r\n\r\n"; + let _ = socket.write_all(head.as_bytes()).await; + let _ = socket.write_all(bytes).await; + // Keeps the connection open without data until the client goes away. + let mut probe = [0_u8; 1]; + let _ = socket.read(&mut probe).await; + } + FakeOriginMode::TsStream(bytes) => { + let head = "HTTP/1.1 200 OK\r\nContent-Type: video/mp2t\r\nConnection: close\r\n\r\n"; + let _ = socket.write_all(head.as_bytes()).await; + while socket.write_all(bytes).await.is_ok() { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + } + FakeOriginMode::Silent => { + let mut probe = [0_u8; 1]; + let _ = socket.read(&mut probe).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, + pub target: Arc, + pub group_vid: u32, + pub plain_vid: u32, + pub favourite_vid: u32, + pub group_record: Arc, + pub origins: HashMap, + pub router: axum::Router, + pub hdhr_router: Option, + 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, + channel_groups: None, + hls_cache: false, + share_ts: false, + sleep_timer_mins: None, + max_connections: HashMap::new(), + channel_unavailable_video: 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") + } + + /// Like `get_xtream_live`, from another client socket of the same user. + pub async fn get_xtream_live_from(&self, vid: u32, port: u16) -> 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], port)))); + self.router.clone().oneshot(req).await.expect("endpoint response") + } + + 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 { + 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 { + 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 { self.origins.keys().cloned().collect() } +} + +#[allow(clippy::struct_excessive_bools)] +pub(crate) struct GroupPlaybackEnvBuilder { + inputs: Vec, + origin_modes: HashMap, + qos: Vec<(String, u32, u8)>, + hls: bool, + stalker: Option, + user_limit: Option, + admission: Option>, + favourite: bool, + plain_channel: bool, + member_archives: Vec>, + epg: Option, + epg_channel_id: Option, + hdhomerun: bool, + channel_groups: Option, + hls_cache: bool, + share_ts: bool, + sleep_timer_mins: Option, + max_connections: HashMap, + channel_unavailable_video: bool, +} + +#[allow(dead_code)] +impl GroupPlaybackEnvBuilder { + pub fn hls_cache(mut self) -> Self { + self.hls_cache = true; + self.hls = true; + self + } + + /// Serves the channel-unavailable custom video (test fixture, PIDs 0x100/0x101). + pub fn channel_unavailable_video(mut self) -> Self { + self.channel_unavailable_video = true; + self + } + + pub fn sleep_timer_mins(mut self, mins: u32) -> Self { + self.sleep_timer_mins = Some(mins); + self + } + + pub fn max_connections(mut self, input: &str, max: u16) -> Self { + self.max_connections.insert(input.to_string(), max); + self + } + + pub fn share_ts(mut self) -> Self { + self.share_ts = true; + self + } + + /// The `config.yml` channel group settings. + pub fn with_channel_groups(mut self, channel_groups: ChannelGroupsConfig) -> Self { + self.channel_groups = Some(channel_groups); + self + } + + /// The `config.yml` failover settings. + pub fn with_failover(self, failover: shared::model::ChannelGroupFailoverConfig) -> Self { + let channel_groups = ChannelGroupsConfig { failover, ..self.channel_groups.clone().unwrap_or_default() }; + self.with_channel_groups(channel_groups) + } + + pub fn origin_mode(mut self, input: &str, mode: FakeOriginMode) -> Self { + self.origin_modes.insert(input.to_string(), mode); + self + } + + 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) -> 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) -> 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]) -> 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: self.max_connections.get(input_name).copied().unwrap_or(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 { + custom_stream_response_enabled: self.channel_unavailable_video, + sleep_timer_mins: self.sleep_timer_mins, + storage_dir: storage_dir.clone(), + user_access_control: true, + channel_groups: self.channel_groups.clone().unwrap_or_default(), + 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: self + .hls_cache + .then(|| crate::model::HlsCacheConfig::from(&shared::model::HlsCacheConfigDto::default())), + }), + ..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(ChannelGroupsTargetConfig { + enabled: true, + ..ChannelGroupsTargetConfig::default() + }), + share_live_streams: shared::model::ConfigTargetShareLiveStreams { + hls: self.hls_cache, + mpeg_ts: self.share_ts, + }, + ..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 self.channel_unavailable_video { + let fixture = + include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test/fixtures/hls/channel_unavailable.ts")); + app_state.app_config.custom_stream_response.store(Some(Arc::new(crate::model::CustomStreamResponse { + channel_unavailable: Some(crate::api::model::TransportStreamBuffer::new(fixture.to_vec())), + user_connections_exhausted: None, + provider_connections_exhausted: None, + low_priority_preempted: None, + user_account_expired: None, + panel_api_provisioning: None, + hls_session_or_lease_expired: None, + maintenance: None, + panel_api_provisioning_hls_segments: Vec::new(), + }))); + } + app_state + .member_health + .set_event_sink(Arc::clone(&app_state.event_manager) as Arc); + + 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, + } + } +} diff --git a/backend/app/src/api/api_utils/tests/maintenance.rs b/backend/app/src/api/api_utils/tests/maintenance.rs index 81612f2c6..439a0d5fb 100644 --- a/backend/app/src/api/api_utils/tests/maintenance.rs +++ b/backend/app/src/api/api_utils/tests/maintenance.rs @@ -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}, @@ -102,7 +102,7 @@ fn gate_runs_before_shared_stream_join() { #[test] fn redirect_defers_maintenance_inputs_to_the_gated_proxy_path() { // Redirect mode would send the client straight to the provider; for an input in - // maintenance it must fall through to the proxied path, where gate 1 answers. + // maintenance it must fall through to the proxied path, where the maintenance check answers. let body = function_body(include_str!("../strategy.rs"), "pub async fn redirect_response<"); let gate = body.find("if blocks_new_playback(").expect("gate present"); let redirect = body.find("Some(redirect(").expect("redirect response present"); @@ -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, diff --git a/backend/app/src/api/api_utils/tests/provider_urls.rs b/backend/app/src/api/api_utils/tests/provider_urls.rs index 4cb92f4e3..f3908033f 100644 --- a/backend/app/src/api/api_utils/tests/provider_urls.rs +++ b/backend/app/src/api/api_utils/tests/provider_urls.rs @@ -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(); diff --git a/backend/app/src/api/api_utils/tests/stream_responses_response_headers.rs b/backend/app/src/api/api_utils/tests/stream_responses_response_headers.rs index 26ed21ec4..91bf3c8ec 100644 --- a/backend/app/src/api/api_utils/tests/stream_responses_response_headers.rs +++ b/backend/app/src/api/api_utils/tests/stream_responses_response_headers.rs @@ -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(), diff --git a/backend/app/src/api/api_utils/tests/stream_responses_session_activation.rs b/backend/app/src/api/api_utils/tests/stream_responses_session_activation.rs index a191a6223..c106b7db6 100644 --- a/backend/app/src/api/api_utils/tests/stream_responses_session_activation.rs +++ b/backend/app/src/api/api_utils/tests/stream_responses_session_activation.rs @@ -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, diff --git a/backend/app/src/api/api_utils/tests/support.rs b/backend/app/src/api/api_utils/tests/support.rs index 387f4f5a3..e630d4b10 100644 --- a/backend/app/src/api/api_utils/tests/support.rs +++ b/backend/app/src/api/api_utils/tests/support.rs @@ -104,14 +104,27 @@ pub(in crate::api::api_utils::tests) async fn spawn_range_aware_test_origin( #[allow(dead_code)] #[derive(Clone)] -pub(in crate::api::api_utils::tests) enum FakeOriginMode { +pub(crate) enum FakeOriginMode { EmptyBody, BlockFirstChunk(Arc), ErrorBeforeFirstChunk, ErrorAfterFirstChunk(Vec), FixedBytes(Vec), + Status(u16), + Body { + content_type: String, + bytes: Vec, + }, + Hls, + /// MPEG-TS bytes, then an open connection without data. + TsThenStall(Vec), + /// The bytes repeated every 50 ms until the client disconnects. + TsStream(Vec), + /// Accepts the request and never answers. + Silent, } +#[allow(clippy::too_many_lines)] pub(in crate::api::api_utils::tests) async fn spawn_controlled_fake_origin( mode: FakeOriginMode, max_requests: usize, @@ -178,6 +191,44 @@ pub(in crate::api::api_utils::tests) async fn spawn_controlled_fake_origin( let _ = socket.write_all(bytes).await; let _ = socket.shutdown().await; } + FakeOriginMode::TsThenStall(bytes) => { + let head = "HTTP/1.1 200 OK\r\nContent-Type: video/mp2t\r\nConnection: close\r\n\r\n"; + let _ = socket.write_all(head.as_bytes()).await; + let _ = socket.write_all(bytes).await; + // Keeps the connection open without data until the client goes away. + let mut probe = [0_u8; 1]; + let _ = socket.read(&mut probe).await; + } + FakeOriginMode::TsStream(bytes) => { + let head = "HTTP/1.1 200 OK\r\nContent-Type: video/mp2t\r\nConnection: close\r\n\r\n"; + let _ = socket.write_all(head.as_bytes()).await; + while socket.write_all(bytes).await.is_ok() { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + } + FakeOriginMode::Silent => { + let mut probe = [0_u8; 1]; + let _ = socket.read(&mut probe).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 +521,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 +559,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 +581,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, diff --git a/backend/app/src/api/endpoints/custom_video_stream_api.rs b/backend/app/src/api/endpoints/custom_video_stream_api.rs index 068f0721b..deb0d415e 100644 --- a/backend/app/src/api/endpoints/custom_video_stream_api.rs +++ b/backend/app/src/api/endpoints/custom_video_stream_api.rs @@ -711,6 +711,7 @@ mod tests { qos_aggregation: CancellationToken::new(), recordings: CancellationToken::new(), hls_cache: CancellationToken::new(), + channel_groups: tokio_util::sync::CancellationToken::new(), }; let metadata_manager = Arc::new(MetadataUpdateManager::new(tokens.metadata.clone())); @@ -738,6 +739,7 @@ mod tests { auth: crate::api::model::AuthState::for_tests(), playlist_updates: crate::api::model::PlaylistUpdateControl::for_tests(), maintenance: crate::api::model::create_test_maintenance(""), + member_health: Arc::new(tuliprox_session::MemberHealthIndex::default()), }) } diff --git a/backend/app/src/api/endpoints/hls_api/manifest/cached.rs b/backend/app/src/api/endpoints/hls_api/manifest/cached.rs index 0b7e2a3dc..87d7c77de 100644 --- a/backend/app/src/api/endpoints/hls_api/manifest/cached.rs +++ b/backend/app/src/api/endpoints/hls_api/manifest/cached.rs @@ -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={}", diff --git a/backend/app/src/api/endpoints/hls_api/manifest/provisioning.rs b/backend/app/src/api/endpoints/hls_api/manifest/provisioning.rs index 3820bc4d7..70545f1c6 100644 --- a/backend/app/src/api/endpoints/hls_api/manifest/provisioning.rs +++ b/backend/app/src/api/endpoints/hls_api/manifest/provisioning.rs @@ -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, fingerprint: &Fingerprint, @@ -168,6 +168,40 @@ pub(in crate::api::endpoints::hls_api) async fn try_reserve_hls_virtual_entry_or }; let session_owner = shared_hls_session_owner.as_deref().unwrap_or(session_token.as_str()); + let classified = tuliprox_repository::target_stream_item::classify_virtual_id( + &app_state.app_config, + &app_state.playlists, + target, + VirtualId::new(virtual_id), + ) + .await; + if let tuliprox_repository::target_stream_item::ResolvedStreamTarget::Group(group) = classified { + let playback = crate::api::api_utils::group_playback_settings(app_state, target); + let target_name: Arc = Arc::from(target.name.as_str()); + let req = crate::api::api_utils::GroupSelectionRequest { + lease: Some(PlaybackLeaseRef::new(session_owner, PlaybackKind::LiveHls)), + allow_provider_grace: playback.allow_provider_grace, + bypass_maintenance: user.is_recording_proxy_user(), + ..crate::api::api_utils::GroupSelectionRequest::new( + &target_name, + &group, + playback.start, + &fingerprint.addr, + &user.username, + connection_priority_for_kind(user, connection_kind), + connection_kind, + ) + }; + let Ok(resolved) = crate::api::api_utils::resolve_hls_group_entry(app_state, Arc::clone(&group), req).await + else { + return false; + }; + let mut acquired_handle = resolved.acquired.handle; + let provider_handle = acquired_handle.take(); + app_state.connection_manager.release_provider_handle(provider_handle); + return true; + } + let Some(reservation) = try_reserve_hls_entry_origin_account_for_redirect( app_state, fingerprint, diff --git a/backend/app/src/api/endpoints/hls_api/owner_token.rs b/backend/app/src/api/endpoints/hls_api/owner_token.rs index ff2a825ec..7b414c4e5 100644 --- a/backend/app/src/api/endpoints/hls_api/owner_token.rs +++ b/backend/app/src/api/endpoints/hls_api/owner_token.rs @@ -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, diff --git a/backend/app/src/api/endpoints/hls_api/segment/entry.rs b/backend/app/src/api/endpoints/hls_api/segment/entry.rs index 073eda608..6d3750705 100644 --- a/backend/app/src/api/endpoints/hls_api/segment/entry.rs +++ b/backend/app/src/api/endpoints/hls_api/segment/entry.rs @@ -5,15 +5,16 @@ use super::{ hls_custom_video_manifest_response, hls_entry_user_session_token, hls_media_playlist_wrap_enabled, hls_panel_provisioning_or_status_response, hls_playback_kind, hls_response, m3u_archive_epg_reference_ts, m3u_catchup_epg_reference_from_session_token, normalize_xtream_live_hls_url, release_prepared_hls_manifest_session, - terminate_failed_hls_manifest_session, try_reserve_hls_entry_origin_account_for_redirect, wrap_media_playlist, - HlsEntryStreamContext, HlsMediaPlaylistWrap, HlsOriginEntryUrl, HlsRequestStage, + resolve_hls_origin_playlist_url, terminate_failed_hls_manifest_session, + try_reserve_hls_entry_origin_account_for_redirect, wrap_media_playlist, HlsEntryStreamContext, + HlsMediaPlaylistWrap, HlsOriginEntryUrl, HlsRequestStage, }; use crate::{ api::{ api_utils::{ acquire_exact_provider_handle, blocks_new_playback, connection_priority_for_kind, - get_hls_playback_ttl_secs, is_running_playback, select_provider_stream_url, ExactProviderAcquire, - HLS_MANIFEST_CAPACITY_WAIT, + get_hls_playback_ttl_secs, group_playback_settings, is_running_playback, resolve_hls_group_entry, + select_provider_stream_url, ExactProviderAcquire, GroupSelectionRequest, HLS_MANIFEST_CAPACITY_WAIT, }, model::{AppState, CustomVideoStreamType, PlaybackLeaseRef, ProviderAllocation, UserSession}, }, @@ -40,6 +41,10 @@ use tuliprox_hls::api::{ build_hls_origin_session_owner, build_proxy_session_id, extract_hls_provider_session_headers, HlsOriginSource, HlsSessionKey, }; +use tuliprox_session::{ + channel_group::{FailureClass, HalfOpenGuard}, + MemberBinding, +}; pub(in crate::api::endpoints::hls_api) struct HlsCacheManifestOrigin<'a> { pub(in crate::api::endpoints::hls_api) raw_request_url: &'a str, @@ -88,9 +93,26 @@ pub(in crate::api) async fn handle_hls_stream_request( if app_state.active_users.is_user_blocked_for_stream(&user.username, VirtualId::new(virtual_id)).await { return axum::http::StatusCode::BAD_REQUEST.into_response(); } + let entry_group = if matches!(stage, HlsRequestStage::Entry) { + match tuliprox_repository::target_stream_item::classify_virtual_id( + &app_state.app_config, + &app_state.playlists, + target, + VirtualId::new(virtual_id), + ) + .await + { + tuliprox_repository::target_stream_item::ResolvedStreamTarget::Group(group) => Some(group), + _ => None, + } + } else { + None + }; // Only a fresh entry is gated. Variant refreshes, segments and entry re-polls of a running - // session (unwrapped media playlists) belong to a running playback. + // session (unwrapped media playlists) belong to a running playback. A group checks + // maintenance per member during selection. if matches!(stage, HlsRequestStage::Entry) + && entry_group.is_none() && !is_running_playback(user_session) && blocks_new_playback(app_state, input, user, false) { @@ -120,7 +142,7 @@ pub(in crate::api) async fn handle_hls_stream_request( }; // The wrapper seals the normalized entry URL, not the provider-resolved one, so every // refresh can resolve it to whichever account is selected then. - let entry_url = url.clone(); + let mut entry_url = url.clone(); // Recover archive context when callers (esp. Xtream timeshift) pass None but the // resolved provider URL / catchup session still carries Flussonic archive markers. let archive_reference = archive_reference.or_else(|| m3u_archive_epg_reference_ts(&url)).or_else(|| { @@ -149,6 +171,79 @@ pub(in crate::api) async fn handle_hls_stream_request( ); if hls_cache_enabled_for_user(app_state, target, user) { + if let Some(group) = entry_group { + // The shared origin belongs to the selected member, so viewers of the same member share + // it. The cache path reserves its own provider account; the selection only picks. + let playback = group_playback_settings(app_state, target); + let target_name: Arc = Arc::from(target.name.as_str()); + let kind = connection_kind.unwrap_or(crate::api::model::ConnectionKind::Normal); + let req = GroupSelectionRequest { + allow_provider_grace: playback.allow_provider_grace, + bypass_maintenance: user.is_recording_proxy_user(), + allow_preempt: false, + ..GroupSelectionRequest::new( + &target_name, + &group, + playback.start, + &fingerprint.addr, + &user.username, + connection_priority_for_kind(user, kind), + kind, + ) + }; + let Ok(resolved) = resolve_hls_group_entry(app_state, Arc::clone(&group), req).await else { + return hls_panel_provisioning_or_status_response( + app_state, + user, + input, + virtual_id, + original_hls_entry_path, + server_info.path.as_deref(), + StatusCode::SERVICE_UNAVAILABLE, + ) + .await; + }; + let mut selection_handle = resolved.acquired.handle; + app_state.connection_manager.release_provider_handle(selection_handle.take()); + let member_url = resolve_hls_origin_playlist_url( + app_state, + target, + &resolved.input, + resolved.member.member_vid.get(), + resolved.stream_url.as_ref(), + ) + .await + .unwrap_or_else(|_| resolved.stream_url.to_string()); + let member_url = + ensure_hls_manifest_extension(&normalize_xtream_live_hls_url(&member_url, &resolved.input)); + let member_origin = build_hls_origin_resolution(&resolved.input, &member_url); + let member_source = build_hls_origin_source_for_playback( + &resolved.input, + resolved.member.input_stream_id.to_string(), + archive_reference, + Some(member_url.as_str()), + ); + return create_hls_cache_entry_master_playlist_response( + app_state, + fingerprint, + user, + member_source, + virtual_id, + user_session, + stream_context.known_bitrate_bps(), + session_token_hint, + if archive_reference.is_some() { + member_url.as_str() + } else { + member_origin.as_ref().map_or(member_url.as_str(), |origin| origin.session_entry_url.as_str()) + }, + &resolved.input, + connection_permission, + connection_kind, + server_info.path.as_deref(), + ) + .await; + } let Some(origin_source) = hls_origin_source.clone() else { return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response(); }; @@ -175,8 +270,21 @@ pub(in crate::api) async fn handle_hls_stream_request( } let fallback_connection_kind = connection_kind.unwrap_or(crate::api::model::ConnectionKind::Normal); + let mut bound_member_info: Option<(u32, Arc, String, Option)> = None; + let (request_url, session_token, provider_handle, selected_provider_config) = if let Some(session) = user_session { - let pinned_provider = if session.provider.is_empty() { &input.name } else { &session.provider }; + let bound_inp_opt = session.member_binding.as_ref().and_then(|binding| { + app_state.app_config.get_input_by_name(&binding.input_name).map(|inp| (binding.member_vid, inp)) + }); + if let Some((member_vid, ref bound_inp)) = bound_inp_opt { + bound_member_info = Some((member_vid, Arc::clone(bound_inp), String::new(), None)); + } + let effective_lookup_input = bound_inp_opt.as_ref().map_or(input, |(_, inp)| inp.as_ref()); + let pinned_provider = if session.provider.is_empty() { + effective_lookup_input.name.clone() + } else { + Arc::clone(&session.provider) + }; let pinned_kind = hls_playback_kind(archive_reference, &url, Some(session.token.as_str())); let session_kind = session.connection_kind.or(connection_kind).unwrap_or(crate::api::model::ConnectionKind::Normal); @@ -185,7 +293,7 @@ pub(in crate::api) async fn handle_hls_stream_request( let acquired = acquire_exact_provider_handle( app_state, &ExactProviderAcquire { - provider: pinned_provider, + provider: &pinned_provider, addr: &fingerprint.addr, allow_grace: false, priority: connection_priority_for_kind(user, session_kind), @@ -200,7 +308,7 @@ pub(in crate::api) async fn handle_hls_stream_request( } else { debug_if_enabled!( "HLS pinned provider {} unavailable for {}; aborting allocation to prevent mid-session migration", - sanitize_sensitive_info(pinned_provider), + sanitize_sensitive_info(&pinned_provider), sanitize_sensitive_info(&fingerprint.addr.to_string()) ); None @@ -216,7 +324,7 @@ pub(in crate::api) async fn handle_hls_stream_request( return hls_panel_provisioning_or_status_response( app_state, user, - input, + effective_lookup_input, virtual_id, original_hls_entry_path, server_info.path.as_deref(), @@ -250,7 +358,7 @@ pub(in crate::api) async fn handle_hls_stream_request( }; let Some((_provider_name, stream_url)) = select_provider_stream_url( &url, - input, + effective_lookup_input, &selected_provider_config, accept_requested_stream_url, &app_state.app_config, @@ -277,7 +385,7 @@ pub(in crate::api) async fn handle_hls_stream_request( debug_if_enabled!( "HLS session {} changed while waiting for pinned provider {}", sanitize_sensitive_info(&session.token), - sanitize_sensitive_info(pinned_provider) + sanitize_sensitive_info(&pinned_provider) ); return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response(); } @@ -291,7 +399,7 @@ pub(in crate::api) async fn handle_hls_stream_request( .create_user_session(crate::api::model::CreateUserSessionParams { user, session_token: &session.token, - virtual_id, + virtual_id: session.virtual_id, provider: &selected_provider_config.name, stream_url: &stream_url, addr: &fingerprint.addr, @@ -312,67 +420,199 @@ pub(in crate::api) async fn handle_hls_stream_request( None => (url, None, None, None), } } else { - // Append/shift catchup must keep an m3u-catchup session token even when shared HLS - // cache is off; otherwise rewritten segments register as LiveHls in the panel. - let user_session_token = hls_entry_user_session_token( - fingerprint, - &user.username, - virtual_id, - session_token_hint, - archive_reference, - ); - let hls_session_owner = if hls_cache_enabled_for_user(app_state, target, user) { - let session_key = HlsSessionKey::new(input.id, stream_context.stream_ref()); - let proxy_session_id = build_proxy_session_id(&session_key, &app_state.get_encrypt_secret()); - Some(build_hls_origin_session_owner(&proxy_session_id)) - } else { - None - }; - let session_owner = hls_session_owner.as_deref().unwrap_or(user_session_token.as_str()); - // Archive playback keeps its own reconnect window semantics, so the lease must - // be classified as catchup rather than plain live HLS. - let playback_kind = hls_playback_kind(archive_reference, &url, Some(user_session_token.as_str())); - let hls_session_ttl_secs = get_hls_playback_ttl_secs(app_state, playback_kind); - let Some(reservation) = try_reserve_hls_entry_origin_account_for_redirect( - app_state, - fingerprint, - user, - input, - virtual_id, - &url, - &user_session_token, - session_owner, - playback_kind, - hls_session_ttl_secs, - connection_permission, - fallback_connection_kind, - true, + let classified = tuliprox_repository::target_stream_item::classify_virtual_id( + &app_state.app_config, + &app_state.playlists, + target, + VirtualId::new(virtual_id), ) - .await - else { - return hls_panel_provisioning_or_status_response( + .await; + + if let tuliprox_repository::target_stream_item::ResolvedStreamTarget::Group(group) = classified { + let user_session_token = hls_entry_user_session_token( + fingerprint, + &user.username, + virtual_id, + session_token_hint, + archive_reference, + ); + let playback_kind = hls_playback_kind(archive_reference, &url, Some(user_session_token.as_str())); + let hls_session_ttl_secs = get_hls_playback_ttl_secs(app_state, playback_kind); + + let playback = group_playback_settings(app_state, target); + let target_name: Arc = Arc::from(target.name.as_str()); + let req = GroupSelectionRequest { + lease: Some(PlaybackLeaseRef::new(user_session_token.as_str(), playback_kind)), + allow_provider_grace: playback.allow_provider_grace, + bypass_maintenance: user.is_recording_proxy_user(), + ..GroupSelectionRequest::new( + &target_name, + &group, + playback.start, + &fingerprint.addr, + &user.username, + connection_priority_for_kind(user, fallback_connection_kind), + fallback_connection_kind, + ) + }; + + let Ok(resolved) = resolve_hls_group_entry(app_state, Arc::clone(&group), req).await else { + return hls_panel_provisioning_or_status_response( + app_state, + user, + input, + virtual_id, + original_hls_entry_path, + server_info.path.as_deref(), + StatusCode::SERVICE_UNAVAILABLE, + ) + .await; + }; + + let mut acquired_handle = resolved.acquired.handle; + let provider_handle = acquired_handle.take(); + let selected_provider_config = + provider_handle.as_ref().and_then(|handle| handle.allocation.get_provider_config()); + + let origin_member_url = resolve_hls_origin_playlist_url( app_state, + target, + &resolved.input, + resolved.member.member_vid.get(), + resolved.stream_url.as_ref(), + ) + .await + .unwrap_or_else(|_| resolved.stream_url.to_string()); + + let member_url = if let Some(cfg) = &selected_provider_config { + select_provider_stream_url(&origin_member_url, &resolved.input, cfg, false, &app_state.app_config) + .await + .map_or_else(|| origin_member_url.clone(), |(_, u)| u) + } else { + origin_member_url.clone() + }; + + let provider_name = selected_provider_config + .as_ref() + .map_or_else(|| Arc::clone(&resolved.input.name), |cfg| Arc::clone(&cfg.name)); + + let session_token = app_state + .active_users + .create_user_session(crate::api::model::CreateUserSessionParams { + user, + session_token: &user_session_token, + virtual_id, + provider: &provider_name, + stream_url: &member_url, + addr: &fingerprint.addr, + connection_permission, + connection_kind: Some(fallback_connection_kind), + socket_bound: PlaylistItemType::LiveHls.uses_socket_bound_session(), + }) + .await; + + app_state + .active_users + .bind_member( + &user.username, + &session_token, + MemberBinding { + group_vid: group.group_vid.get(), + member_vid: resolved.member.member_vid.get(), + member_index: resolved.acquired.index, + input_name: Arc::clone(&resolved.member.input_name), + }, + ) + .await; + + app_state.active_provider.refresh_adaptive_playback_lease( + &provider_name, + &user_session_token, + playback_kind, + hls_session_ttl_secs, + ); + + bound_member_info = Some(( + resolved.member.member_vid.get(), + resolved.input, + resolved.member.input_stream_id.to_string(), + resolved.acquired.half_open, + )); + + debug_if_enabled!( + "API endpoint [HLS] create_session_fingerprint user={} virtual_id={virtual_id} (group) member_vid={} provider={} stream_url={}", + sanitize_sensitive_info(&user.username), + resolved.member.member_vid.get(), + selected_provider_config.as_ref().map_or("", |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("", |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("", |provider| provider.name.as_ref()), + sanitize_sensitive_info(&reservation.request_url) + ); + ( + reservation.request_url, + Some(reservation.session_token), + reservation.provider_handle, + reservation.selected_provider_config, + ) + } }; // The session as created or reserved above; manifest response cookies may only enter this @@ -403,9 +643,16 @@ pub(in crate::api) async fn handle_hls_stream_request( } } + let (effective_virtual_id, effective_input, effective_stream_ref, half_open_guard) = + if let Some((m_vid, ref m_input, ref m_sref, guard)) = bound_member_info { + (m_vid, m_input.as_ref(), if m_sref.is_empty() { stream_ref.as_str() } else { m_sref.as_str() }, guard) + } else { + (virtual_id, input, stream_ref.as_str(), None) + }; + let user_agent_stream_index = crate::api::api_utils::resolve_stream_user_agent_index( app_state, - input, + effective_input, provider_handle.is_some(), &user.username, session_token.as_deref(), @@ -426,7 +673,7 @@ pub(in crate::api) async fn handle_hls_stream_request( // breaks forced same-account reuse on the next HLS/Catchup stream request. app_state.connection_manager.release_provider_handle(provider_handle); - let input_source = InputSource::from(input).with_url(request_url); + let input_source = InputSource::from(effective_input).with_url(request_url); let download_result = match download_legacy_hls_manifest(app_state, &input_source, &headers).await { Ok((content, response_url, response_headers)) => match classify_hls_playlist(&content) { // A refresh keeps its session on a malformed body; the player retries the playlist. @@ -447,6 +694,9 @@ pub(in crate::api) async fn handle_hls_stream_request( }; match download_result { Ok((content, response_url, response_headers, playlist_kind)) => { + if let Some(guard) = half_open_guard { + app_state.member_health.record_success_with(guard); + } let encrypt_secret = app_state.get_encrypt_secret(); let base_url = server_info.get_base_url(); let rewrite_hls_props = RewriteHlsProps { @@ -455,8 +705,8 @@ pub(in crate::api) async fn handle_hls_stream_request( content: &content, hls_url: response_url, target_id: target.id, - virtual_id, - input_id: input.id, + virtual_id: effective_virtual_id, + input_id: effective_input.id, user_token: session_token.as_deref(), origin_provider: selected_provider_name.as_deref(), playlist_kind: Some(playlist_kind), @@ -489,14 +739,14 @@ pub(in crate::api) async fn handle_hls_stream_request( user, base_url: &base_url, target_id: target.id, - input, - virtual_id, + input: effective_input, + virtual_id: effective_virtual_id, session_token, sealed_url: &entry_url, provider, binding_tag: provider_binding_tag, known_bitrate_bps: stream_context.known_bitrate_bps(), - stream_ref: Some(&stream_ref), + stream_ref: Some(effective_stream_ref), }, hls_content, ) @@ -507,6 +757,13 @@ pub(in crate::api) async fn handle_hls_stream_request( hls_response(hls_content).into_response() } Err(err) => { + if let Some(guard) = half_open_guard { + app_state.member_health.record_classified_failure_with( + guard, + FailureClass::Stream, + std::time::Instant::now(), + ); + } error!("Failed to download m3u8: {}", request::text_response_error_log_label(&err)); if let Some(session_token) = session_token.as_deref() { terminate_failed_hls_manifest_session( @@ -544,8 +801,28 @@ pub(in crate::api::endpoints::hls_api) async fn get_stream_channel( } } let target_id = target.id; - m3u_get_item_for_stream_id(virtual_id, &app_state.app_config, &app_state.playlists, target) - .await - .ok() - .map(|pli| pli.to_stream_channel(target_id)) + if let Ok(pli) = m3u_get_item_for_stream_id(virtual_id, &app_state.app_config, &app_state.playlists, target).await { + return Some(pli.to_stream_channel(target_id)); + } + let classified = tuliprox_repository::target_stream_item::classify_virtual_id( + &app_state.app_config, + &app_state.playlists, + target, + VirtualId::new(virtual_id), + ) + .await; + match classified { + tuliprox_repository::target_stream_item::ResolvedStreamTarget::Member { group, index } => { + let mut channel = if target.has_output(TargetType::Xtream) { + tuliprox_repository::target_stream_item::member_to_xtream_item(&group, index, target) + .to_stream_channel(target_id) + } else { + tuliprox_repository::target_stream_item::member_to_m3u_item(&group, index, target) + .to_stream_channel(target_id) + }; + channel.group_virtual_id = Some(group.group_vid.get()); + Some(channel) + } + _ => None, + } } diff --git a/backend/app/src/api/endpoints/hls_api/segment/registration.rs b/backend/app/src/api/endpoints/hls_api/segment/registration.rs index 935159283..505a04d2e 100644 --- a/backend/app/src/api/endpoints/hls_api/segment/registration.rs +++ b/backend/app/src/api/endpoints/hls_api/segment/registration.rs @@ -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, diff --git a/backend/app/src/api/endpoints/hls_api/segment/routing.rs b/backend/app/src/api/endpoints/hls_api/segment/routing.rs index 4ccf9b7b0..8c40ddbf0 100644 --- a/backend/app/src/api/endpoints/hls_api/segment/routing.rs +++ b/backend/app/src/api/endpoints/hls_api/segment/routing.rs @@ -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, - target: &Arc, + target: &ConfigTarget, input: &ConfigInput, virtual_id: u32, fallback_url: &str, ) -> Result { if input.input_type.is_xtream() && target.has_output(TargetType::Xtream) { - let pli = xtream_get_item_for_stream_id(virtual_id, &app_state.app_config, &app_state.playlists, target, None) - .await - .map_err(|_| StatusCode::NOT_FOUND)?; + let 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(); diff --git a/backend/app/src/api/endpoints/hls_api/tests.rs b/backend/app/src/api/endpoints/hls_api/tests.rs index 7d0a36fec..0a005f5f6 100644 --- a/backend/app/src/api/endpoints/hls_api/tests.rs +++ b/backend/app/src/api/endpoints/hls_api/tests.rs @@ -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; diff --git a/backend/app/src/api/endpoints/hls_api/tests/channel_group.rs b/backend/app/src/api/endpoints/hls_api/tests/channel_group.rs new file mode 100644 index 000000000..47c4ce52c --- /dev/null +++ b/backend/app/src/api/endpoints/hls_api/tests/channel_group.rs @@ -0,0 +1,50 @@ +use crate::api::api_utils::tests::group_playback_support::{first_segment_url, GroupPlaybackEnv}; +use axum::http::StatusCode; + +#[tokio::test] +async fn hls_entry_selects_member_with_tiers() { + let env = GroupPlaybackEnv::new(&["solis", "korath"]).hls().build().await; + let _blocker = env.hold("solis", 0); + let manifest = env.get_hls_manifest(env.group_vid).await; + assert_eq!(manifest.status(), StatusCode::OK); + assert_eq!(env.origin_hits("solis"), 0); + assert_eq!(env.origin_hits("korath"), 1); + assert_eq!(env.bound_member_input("viewer").await, "korath"); +} + +#[tokio::test] +async fn segment_routing_resolves_member_vid_to_member_input() { + let env = GroupPlaybackEnv::new(&["solis", "korath"]).hls().with_qos("korath", 2, 95).build().await; + let manifest = env.get_hls_manifest_text(env.group_vid).await; + let segment_url = first_segment_url(&manifest); + assert!(segment_url.contains(&env.member_vid("korath").to_string())); + let segment = env.get(&segment_url).await; + assert_eq!(segment.status(), StatusCode::OK); + assert_eq!(env.origin_hits("solis"), 0); + assert_eq!(env.origin_hits("korath"), 1); + assert_eq!(env.bound_member_input("viewer").await, "korath"); +} + +#[tokio::test] +async fn manifest_refresh_never_reselects_even_with_open_circuit() { + let env = GroupPlaybackEnv::new(&["solis", "korath"]).hls().build().await; + let first = env.get_hls_manifest_text(env.group_vid).await; + let bound = env.bound_member_input("viewer").await; + assert!(!bound.is_empty()); + env.open_circuit(&bound); + let second = env.refresh_hls_manifest(&first).await; + assert_eq!(second.status(), StatusCode::OK); + assert_eq!(env.bound_member_input("viewer").await, bound); +} + +#[tokio::test] +async fn hls_cache_entry_uses_selected_member_input() { + let env = GroupPlaybackEnv::new(&["solis", "korath"]).hls_cache().with_qos("korath", 2, 95).build().await; + let manifest = env.get_hls_manifest(env.group_vid).await; + assert!(manifest.status().is_success() || manifest.status().is_redirection(), "status {}", manifest.status()); + let body = axum::body::to_bytes(manifest.into_body(), 1024 * 1024).await.expect("body"); + // The variant request makes the shared origin fetch its upstream playlist. + let _variant = env.get(&first_segment_url(&String::from_utf8_lossy(&body))).await; + assert_eq!(env.origin_hits("solis"), 0); + assert!(env.origin_hits("korath") > 0); +} diff --git a/backend/app/src/api/endpoints/hls_api/tests/support/app_state.rs b/backend/app/src/api/endpoints/hls_api/tests/support/app_state.rs index b464e1b64..5a678d435 100644 --- a/backend/app/src/api/endpoints/hls_api/tests/support/app_state.rs +++ b/backend/app/src/api/endpoints/hls_api/tests/support/app_state.rs @@ -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()), }) } diff --git a/backend/app/src/api/endpoints/m3u_api.rs b/backend/app/src/api/endpoints/m3u_api.rs index a6a7f2001..c740a4a53 100644 --- a/backend/app/src/api/endpoints/m3u_api.rs +++ b/backend/app/src/api/endpoints/m3u_api.rs @@ -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, + source: Option>, ) -> impl IntoResponse + Send { let target_name = &target.name; if !target.has_output(TargetType::M3u) { @@ -183,7 +184,30 @@ pub(in crate::api) async fn m3u_api_stream_loaded( } let virtual_id = pli.virtual_id; - if app_state.active_users.is_user_blocked_for_stream(&user.username, virtual_id).await { + let resolved_target = tuliprox_repository::target_stream_item::classify_virtual_id( + &app_state.app_config, + &app_state.playlists, + &target, + virtual_id, + ) + .await; + let is_group = source.as_ref().map_or_else( + || { + matches!( + resolved_target, + tuliprox_repository::target_stream_item::ResolvedStreamTarget::Group(_) + | tuliprox_repository::target_stream_item::ResolvedStreamTarget::Member { .. } + ) + }, + |s| matches!(s, PlaybackSource::Group { .. }), + ); + + let check_vid = match &resolved_target { + tuliprox_repository::target_stream_item::ResolvedStreamTarget::Group(group) + | tuliprox_repository::target_stream_item::ResolvedStreamTarget::Member { group, .. } => group.group_vid, + tuliprox_repository::target_stream_item::ResolvedStreamTarget::Item => virtual_id, + }; + if app_state.active_users.is_user_blocked_for_stream(&user.username, check_vid).await { return axum::http::StatusCode::BAD_REQUEST.into_response(); } @@ -249,23 +273,25 @@ pub(in crate::api) async fn m3u_api_stream_loaded( } let cluster = pli.item_type.cluster(); - pli.url = match resolve_initial_stalker_playback_url( - app_state, - &input, - &user, - pli.get_provider_id().unwrap_or_default(), - cluster, - pli.item_type, - &pli.url, - ) - .await - { - Ok(url) => url, - Err(err) => { - error!("Failed to resolve initial Stalker playback URL: {}", sanitize_sensitive_info(&err.to_string())); - return axum::http::StatusCode::BAD_GATEWAY.into_response(); - } - }; + if !is_group { + pli.url = match resolve_initial_stalker_playback_url( + app_state, + &input, + &user, + pli.get_provider_id().unwrap_or_default(), + cluster, + pli.item_type, + &pli.url, + ) + .await + { + Ok(url) => url, + Err(err) => { + error!("Failed to resolve initial Stalker playback URL: {}", sanitize_sensitive_info(&err.to_string())); + return axum::http::StatusCode::BAD_GATEWAY.into_response(); + } + }; + } let stream_channel = || { let mut channel = pli.to_stream_channel(target.id); @@ -355,11 +381,13 @@ pub(in crate::api) async fn m3u_api_stream_loaded( } if session.virtual_id == virtual_id.get() && is_seekable_media_request(cluster, req_headers, Some(extension)) { // partial request means we are in reverse proxy mode, seek happened + let (input, stream_channel) = + crate::api::api_utils::sticky_playback_context(app_state, session, &input, stream_channel()); return force_provider_stream_response( fingerprint, app_state, session, - stream_channel(), + stream_channel, crate::api::api_utils::ForceStreamRequestContext { req_headers, input: &input, @@ -382,7 +410,9 @@ pub(in crate::api) async fn m3u_api_stream_loaded( pli.url.clone() }; - if !is_running_playback(user_session.as_ref()) && blocks_new_playback(app_state, &input, &user, false) { + // A group checks maintenance per member during selection; its representative input says nothing. + if !is_group && !is_running_playback(user_session.as_ref()) && blocks_new_playback(app_state, &input, &user, false) + { return maintenance_rejection_response( app_state, fingerprint, @@ -527,16 +557,47 @@ pub(in crate::api) async fn m3u_api_stream_loaded( None }; + let bound_prefer = user_session + .as_ref() + .and_then(|session| session.member_binding.as_ref()) + .map(|b| (b.group_vid, b.member_index)); + + let mut source = source.unwrap_or_else(|| match resolved_target { + tuliprox_repository::target_stream_item::ResolvedStreamTarget::Group(group) => { + let prefer = bound_prefer.filter(|(gvid, _)| *gvid == group.group_vid.get()).map(|(_, idx)| idx); + PlaybackSource::Group { group, mode: tuliprox_session::channel_group::SelectionMode::Live, prefer } + } + tuliprox_repository::target_stream_item::ResolvedStreamTarget::Member { group, index } => { + PlaybackSource::Group { + group, + mode: tuliprox_session::channel_group::SelectionMode::Live, + prefer: Some(index), + } + } + tuliprox_repository::target_stream_item::ResolvedStreamTarget::Item => { + PlaybackSource::Single { input: &input, stream_url: &session_url } + } + }); + + if let PlaybackSource::Group { group, prefer, .. } = &mut source { + if prefer.is_none() { + if let Some((gvid, idx)) = bound_prefer { + if gvid == group.group_vid.get() { + *prefer = Some(idx); + } + } + } + } + stream_response_with_provider_handle( fingerprint, app_state, &session_key, Some(request_class), stream_channel().with_epg_reference_ts(archive_reference), - &session_url, + source, pinned_provider, req_headers, - &input, &target, &user, connection_permission, @@ -716,6 +777,7 @@ async fn m3u_api_stream( Some(archive.extension()), Some(&discriminator), None, + None, ) .await .into_response(); @@ -723,10 +785,25 @@ async fn m3u_api_stream( let (action_stream_id, stream_ext) = separate_number_and_remainder(stream_req.stream_id); let req_virtual_id: u32 = try_result_bad_request!(action_stream_id.trim().parse()); - let pli = - match m3u_get_item_for_stream_id(req_virtual_id, &app_state.app_config, &app_state.playlists, &target).await { - Ok(pli) => pli, - Err(err) => { + let resolved_target = tuliprox_repository::target_stream_item::classify_virtual_id( + &app_state.app_config, + &app_state.playlists, + &target, + shared::model::VirtualId::new(req_virtual_id), + ) + .await; + let pli_res = + m3u_get_item_for_stream_id(req_virtual_id, &app_state.app_config, &app_state.playlists, &target).await; + let pli = match pli_res { + Ok(pli) => pli, + Err(err) => match &resolved_target { + tuliprox_repository::target_stream_item::ResolvedStreamTarget::Group(group) => { + tuliprox_repository::target_stream_item::member_to_m3u_item(group, 0, &target) + } + tuliprox_repository::target_stream_item::ResolvedStreamTarget::Member { group, index } => { + tuliprox_repository::target_stream_item::member_to_m3u_item(group, *index, &target) + } + tuliprox_repository::target_stream_item::ResolvedStreamTarget::Item => { error!("Failed to read m3u item for stream id {req_virtual_id}: {err}"); if stream_ext == Some(HLS_EXT) { return axum::http::StatusCode::NOT_FOUND.into_response(); @@ -738,7 +815,8 @@ async fn m3u_api_stream( ) .into_response(); } - }; + }, + }; let input = try_option_bad_request!( app_state.app_config.get_input_by_name(&pli.input_name), @@ -756,6 +834,21 @@ async fn m3u_api_stream( } else { (pli, None) }; + let source = match resolved_target { + tuliprox_repository::target_stream_item::ResolvedStreamTarget::Group(group) => Some(PlaybackSource::Group { + group, + mode: tuliprox_session::channel_group::SelectionMode::Live, + prefer: None, + }), + tuliprox_repository::target_stream_item::ResolvedStreamTarget::Member { group, index } => { + Some(PlaybackSource::Group { + group, + mode: tuliprox_session::channel_group::SelectionMode::Live, + prefer: Some(index), + }) + } + tuliprox_repository::target_stream_item::ResolvedStreamTarget::Item => None, + }; m3u_api_stream_loaded( user, target, @@ -767,6 +860,7 @@ async fn m3u_api_stream( stream_ext, archive_discriminator.as_deref(), None, + source, ) .await .into_response() @@ -843,6 +937,7 @@ async fn m3u_api_stream_nested( stream_ext, archive_discriminator.as_deref(), None, + None, ) .await .into_response() @@ -911,6 +1006,7 @@ async fn m3u_api_catchup( None, Some(&archive_discriminator), None, + None, ) .await .into_response() diff --git a/backend/app/src/api/endpoints/v1_api.rs b/backend/app/src/api/endpoints/v1_api.rs index b19058625..08cef8ffc 100644 --- a/backend/app/src/api/endpoints/v1_api.rs +++ b/backend/app/src/api/endpoints/v1_api.rs @@ -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, diff --git a/backend/app/src/api/endpoints/v1_api_playlist/recording.rs b/backend/app/src/api/endpoints/v1_api_playlist/recording.rs index a5f8a6864..8ae54d9bc 100644 --- a/backend/app/src/api/endpoints/v1_api_playlist/recording.rs +++ b/backend/app/src/api/endpoints/v1_api_playlist/recording.rs @@ -313,6 +313,7 @@ pub(super) async fn playlist_recording_stream( None, None, query.provider_allocation_id, + None, ) .await .into_response() diff --git a/backend/app/src/api/endpoints/v1_api_playlist/tests.rs b/backend/app/src/api/endpoints/v1_api_playlist/tests.rs index 8897e75b6..17d6124a3 100644 --- a/backend/app/src/api/endpoints/v1_api_playlist/tests.rs +++ b/backend/app/src/api/endpoints/v1_api_playlist/tests.rs @@ -17,3 +17,4 @@ mod behavior_epg_preview; mod behavior_manual_update; mod behavior_provider_resolution; mod behavior_recording_routes; +mod channel_groups; diff --git a/backend/app/src/api/endpoints/v1_api_playlist/tests/channel_groups.rs b/backend/app/src/api/endpoints/v1_api_playlist/tests/channel_groups.rs new file mode 100644 index 000000000..950bddbbb --- /dev/null +++ b/backend/app/src/api/endpoints/v1_api_playlist/tests/channel_groups.rs @@ -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#""#), "XMLTV must contain group EPG channel: {xmltv_text}"); + assert!( + xmltv_text.contains("ZDF HD"), + "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("Heute Journal"), "XMLTV must contain programme title: {xmltv_text}"); +} diff --git a/backend/app/src/api/endpoints/v1_api_playlist/tests/support.rs b/backend/app/src/api/endpoints/v1_api_playlist/tests/support.rs index 09125f35a..713661d39 100644 --- a/backend/app/src/api/endpoints/v1_api_playlist/tests/support.rs +++ b/backend/app/src/api/endpoints/v1_api_playlist/tests/support.rs @@ -145,6 +145,7 @@ pub(in crate::api::endpoints::v1_api_playlist::tests) fn test_app_state_with_man qos_aggregation: CancellationToken::new(), recordings: CancellationToken::new(), hls_cache: CancellationToken::new(), + channel_groups: tokio_util::sync::CancellationToken::new(), }; let metadata_manager = Arc::new(MetadataUpdateManager::new(tokens.metadata.clone())); @@ -172,5 +173,6 @@ pub(in crate::api::endpoints::v1_api_playlist::tests) fn test_app_state_with_man auth: crate::api::model::AuthState::for_tests(), playlist_updates: crate::api::model::PlaylistUpdateControl::for_tests_with_sender(manual_update_sender), maintenance: crate::api::model::create_test_maintenance(""), + member_health: Arc::new(tuliprox_session::MemberHealthIndex::default()), }) } diff --git a/backend/app/src/api/endpoints/v1_api_playlist/webplayer.rs b/backend/app/src/api/endpoints/v1_api_playlist/webplayer.rs index ecc0045e5..a1f58d9e5 100644 --- a/backend/app/src/api/endpoints/v1_api_playlist/webplayer.rs +++ b/backend/app/src/api/endpoints/v1_api_playlist/webplayer.rs @@ -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() } diff --git a/backend/app/src/api/endpoints/websocket_api.rs b/backend/app/src/api/endpoints/websocket_api.rs index bd58467de..c1a00e16b 100644 --- a/backend/app/src/api/endpoints/websocket_api.rs +++ b/backend/app/src/api/endpoints/websocket_api.rs @@ -540,7 +540,11 @@ fn to_protocol_message(event: EventMessage) -> Option<(ProtocolMessage, &'static // Auth decisions reach notification channels and plugins, not the // Web UI socket: there is no panel that renders them, and pushing // every sign-in to every connected admin is noise. - | EventMessage::AuthAudit(_) => return None, + | EventMessage::AuthAudit(_) + | EventMessage::ChannelGroupMemberSelected { .. } + | EventMessage::ChannelGroupExhausted { .. } + | EventMessage::ChannelGroupCircuitChanged { .. } + | EventMessage::ChannelGroupMemberSwitched { .. } => return None, }) } @@ -748,7 +752,7 @@ mod tests { // every notification-only kind against `expected = true` and the // test failed on `DiskAlert` - it has been red since the lifecycle // events joined the bus. - const NOT_ON_THE_WIRE: [EventKind; 28] = [ + const NOT_ON_THE_WIRE: [EventKind; 32] = [ EventKind::ConnectionDenied, EventKind::ScheduledTaskFailed, EventKind::ProviderFetchFailed, @@ -777,6 +781,10 @@ mod tests { EventKind::AuthSignInFailed, EventKind::AuthSignInThrottled, EventKind::AuthPermissionDenied, + EventKind::ChannelGroupMemberSelected, + EventKind::ChannelGroupExhausted, + EventKind::ChannelGroupCircuitChanged, + EventKind::ChannelGroupMemberSwitched, ]; for (event, kind) in sample_event_of_every_kind() { @@ -941,6 +949,25 @@ mod tests { auth_audit(AuthAuditOutcome::SignInThrottled), auth_audit(AuthAuditOutcome::PermissionDenied), EventMessage::MaintenanceChanged(Arc::default()), + EventMessage::ChannelGroupMemberSelected { + target_id: 1, + group_vid: 10, + member_vid: 11, + tier: shared::model::channel_group::CapacityTier::Free, + }, + EventMessage::ChannelGroupExhausted { target_id: 1, group_vid: 10 }, + EventMessage::ChannelGroupMemberSwitched { + target_id: 1, + group_vid: 10, + from_member_vid: 11, + to_member_vid: 12, + reason: shared::model::channel_group::SwitchReason::UpstreamEof, + }, + EventMessage::ChannelGroupCircuitChanged { + input_name: "test".into(), + input_stream_id: "1".into(), + state: shared::model::channel_group::CircuitState::Closed, + }, ]; assert_eq!(samples.len(), EventKind::ALL.len(), "add the new variant to this list"); samples diff --git a/backend/app/src/api/endpoints/xtream_api/mod.rs b/backend/app/src/api/endpoints/xtream_api/mod.rs index 962cd4d2b..a09bb10b5 100644 --- a/backend/app/src/api/endpoints/xtream_api/mod.rs +++ b/backend/app/src/api/endpoints/xtream_api/mod.rs @@ -14,7 +14,7 @@ use crate::{ redirect, redirect_response, reentry_suppressed_response, resolve_initial_stalker_playback_url, resource_proxy_response, resource_redirect_or_proxy, separate_number_and_remainder, should_allow_exhausted_shared_reconnect, stream_response, stream_response_with_provider_handle, - try_option_bad_request, try_result_bad_request, try_unwrap_body, RedirectParams, + try_option_bad_request, try_result_bad_request, try_unwrap_body, PlaybackSource, RedirectParams, }, endpoints::{ hls_api::{ @@ -295,27 +295,53 @@ async fn xtream_player_api_stream( } let req_virtual_id: u32 = try_result_bad_request!(action_stream_id.trim().parse()); - let Ok(mut pli) = - xtream_get_item_for_stream_id(req_virtual_id, &app_state.app_config, &app_state.playlists, &target, None).await - else { - error!("Failed to read xtream item for stream id {req_virtual_id}"); - if is_hls_manifest_request { - return hls_custom_video_manifest_response( - app_state, - &user, - CustomVideoStreamType::ChannelUnavailable, - axum::http::StatusCode::NOT_FOUND, - ) - .await; - } - return create_custom_video_stream_response( - &app_state.provider_stream_ctx(), - &fingerprint.addr, - CustomVideoStreamType::ChannelUnavailable, - ) - .into_response(); + let resolved_target = tuliprox_repository::target_stream_item::classify_virtual_id( + &app_state.app_config, + &app_state.playlists, + &target, + VirtualId::new(req_virtual_id), + ) + .await; + + let is_group = matches!( + resolved_target, + tuliprox_repository::target_stream_item::ResolvedStreamTarget::Group(_) + | tuliprox_repository::target_stream_item::ResolvedStreamTarget::Member { .. } + ); + + let pli_res = + xtream_get_item_for_stream_id(req_virtual_id, &app_state.app_config, &app_state.playlists, &target, None).await; + let mut pli = match pli_res { + Ok(pli) => pli, + Err(err) => match &resolved_target { + tuliprox_repository::target_stream_item::ResolvedStreamTarget::Group(group) => { + tuliprox_repository::target_stream_item::member_to_xtream_item(group, 0, &target) + } + tuliprox_repository::target_stream_item::ResolvedStreamTarget::Member { group, index } => { + tuliprox_repository::target_stream_item::member_to_xtream_item(group, *index, &target) + } + tuliprox_repository::target_stream_item::ResolvedStreamTarget::Item => { + error!("Failed to read xtream item for stream id {req_virtual_id}: {err}"); + if is_hls_manifest_request { + return hls_custom_video_manifest_response( + app_state, + &user, + CustomVideoStreamType::ChannelUnavailable, + axum::http::StatusCode::NOT_FOUND, + ) + .await; + } + return create_custom_video_stream_response( + &app_state.provider_stream_ctx(), + &fingerprint.addr, + CustomVideoStreamType::ChannelUnavailable, + ) + .into_response(); + } + }, }; + let virtual_id = pli.virtual_id; let input_option = app_state.app_config.get_input_by_name(&pli.input_name); let stream_ext = input_option .as_deref() @@ -346,8 +372,12 @@ async fn xtream_player_api_stream( .into_response(); } - let virtual_id = pli.virtual_id; - if app_state.active_users.is_user_blocked_for_stream(&user.username, virtual_id).await { + let check_vid = match &resolved_target { + tuliprox_repository::target_stream_item::ResolvedStreamTarget::Group(group) + | tuliprox_repository::target_stream_item::ResolvedStreamTarget::Member { group, .. } => group.group_vid, + tuliprox_repository::target_stream_item::ResolvedStreamTarget::Item => virtual_id, + }; + if app_state.active_users.is_user_blocked_for_stream(&user.username, check_vid).await { return axum::http::StatusCode::BAD_REQUEST.into_response(); } @@ -441,23 +471,25 @@ async fn xtream_player_api_stream( (pli.xtream_cluster, pli.item_type) }; - pli.url = match resolve_initial_stalker_playback_url( - app_state, - &input, - &user, - pli.provider_id, - pli.xtream_cluster, - item_type, - &pli.url, - ) - .await - { - Ok(url) => url, - Err(err) => { - error!("Failed to resolve initial Stalker playback URL: {}", sanitize_sensitive_info(&err.to_string())); - return axum::http::StatusCode::BAD_GATEWAY.into_response(); - } - }; + if !is_group { + pli.url = match resolve_initial_stalker_playback_url( + app_state, + &input, + &user, + pli.provider_id, + pli.xtream_cluster, + item_type, + &pli.url, + ) + .await + { + Ok(url) => url, + Err(err) => { + error!("Failed to resolve initial Stalker playback URL: {}", sanitize_sensitive_info(&err.to_string())); + return axum::http::StatusCode::BAD_GATEWAY.into_response(); + } + }; + } let requested_extension = if m3u_timeshift.is_some() { extract_extension_from_url(&pli.url).map(|ext| concat_string!(".", ext)) @@ -555,6 +587,8 @@ async fn xtream_player_api_stream( if session.virtual_id == virtual_id.get() && is_seekable_media_request(cluster, req_headers, Some(playback_ext)) { // partial request means we are in reverse proxy mode, seek happened + let (input, stream_channel) = + crate::api::api_utils::sticky_playback_context(app_state, session, &input, stream_channel); return force_provider_stream_response( fingerprint, app_state, @@ -579,7 +613,9 @@ async fn xtream_player_api_stream( pli.url.clone() }; - if !is_running_playback(user_session.as_ref()) && blocks_new_playback(app_state, &input, &user, false) { + // A group checks maintenance per member during selection; its representative input says nothing. + if !is_group && !is_running_playback(user_session.as_ref()) && blocks_new_playback(app_state, &input, &user, false) + { let stream_channel = create_stream_channel_with_type(target.id, &pli, item_type); return maintenance_rejection_response( app_state, @@ -715,16 +751,40 @@ async fn xtream_player_api_stream( let pinned_provider = user_session.as_ref().filter(|_| item_type.requires_provider_affinity()).map(|session| &session.provider); + let source = match resolved_target { + tuliprox_repository::target_stream_item::ResolvedStreamTarget::Group(group) => { + let bound_prefer = user_session + .as_ref() + .and_then(|session| session.member_binding.as_ref()) + .filter(|b| b.group_vid == group.group_vid.get()) + .map(|b| b.member_index); + PlaybackSource::Group { + group, + mode: tuliprox_session::channel_group::SelectionMode::Live, + prefer: bound_prefer, + } + } + tuliprox_repository::target_stream_item::ResolvedStreamTarget::Member { group, index } => { + PlaybackSource::Group { + group, + mode: tuliprox_session::channel_group::SelectionMode::Live, + prefer: Some(index), + } + } + tuliprox_repository::target_stream_item::ResolvedStreamTarget::Item => { + PlaybackSource::Single { input: &input, stream_url: &stream_url } + } + }; + stream_response( fingerprint, app_state, session_key.as_str(), Some(request_class), stream_channel, - &stream_url, + source, pinned_provider, req_headers, - &input, &target, &user, connection_permission, @@ -1077,10 +1137,9 @@ pub(in crate::api) async fn xtream_player_api_stream_with_resolved_target( session_key.as_str(), None, pli.to_stream_channel(target.id), - &stream_url, + PlaybackSource::Single { input: &input, stream_url: &stream_url }, None, req_headers, - &input, &target, &user, UserConnectionPermission::Allowed, diff --git a/backend/app/src/api/endpoints/xtream_api/tests.rs b/backend/app/src/api/endpoints/xtream_api/tests.rs index 09e43d1ca..9422ee5fb 100644 --- a/backend/app/src/api/endpoints/xtream_api/tests.rs +++ b/backend/app/src/api/endpoints/xtream_api/tests.rs @@ -912,6 +912,7 @@ async fn occupy_the_only_connection(app_state: &Arc, 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, diff --git a/backend/app/src/api/main_api.rs b/backend/app/src/api/main_api.rs index 3a698103d..a81360c9e 100644 --- a/backend/app/src/api/main_api.rs +++ b/backend/app/src/api/main_api.rs @@ -19,18 +19,19 @@ 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, sys_usage::exec_system_usage, tasks::{ - exec_config_watch, exec_interner_prune, exec_scheduler, exec_xtream_expiry_sync, spawn_ssdp_discover_task, + exec_channel_group_member_keys, exec_config_watch, exec_interner_prune, exec_scheduler, + exec_xtream_expiry_sync, spawn_ssdp_discover_task, }, }, model::{ @@ -291,6 +292,49 @@ pub(crate) async fn load_maintenance(app_config: &AppConfig) -> Arc) -> Result, 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, forced_targets: &Arc, @@ -352,11 +396,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 +412,16 @@ async fn create_shared_data( })?; let maintenance = load_maintenance(app_config).await; + let member_health = Arc::new(tuliprox_session::MemberHealthIndex::new( + tuliprox_session::channel_group::CircuitPolicy::from(&config.channel_groups.circuit), + )); + event_manager.bind_member_health(&member_health); + member_health.set_event_sink(Arc::clone(&event_manager) as Arc); + 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 +443,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)) @@ -459,6 +500,7 @@ async fn cancel_all_service_tokens(app_state: &Arc) { cancel_tokens.qos_aggregation.cancel(); cancel_tokens.recordings.cancel(); cancel_tokens.hls_cache.cancel(); + cancel_tokens.channel_groups.cancel(); app_state.active_users.shutdown(); // Use the manager's shutdown() rather than cancelling the token directly so // the is_shutdown flag is set and workers do not attempt to restart after cancellation. @@ -709,7 +751,12 @@ pub async fn start_server(app_config: Arc, targets: Arc Self { @@ -21,6 +23,7 @@ impl Default for CancelTokens { qos_aggregation: CancellationToken::new(), recordings: CancellationToken::new(), hls_cache: CancellationToken::new(), + channel_groups: CancellationToken::new(), } } } diff --git a/backend/app/src/api/model/app_state/mod.rs b/backend/app/src/api/model/app_state/mod.rs index d704af3f4..0fe2d4e0a 100644 --- a/backend/app/src/api/model/app_state/mod.rs +++ b/backend/app/src/api/model/app_state/mod.rs @@ -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, + pub member_health: Arc, } impl AppState { @@ -121,3 +122,33 @@ pub struct HdHomerunAppState { pub device: Arc, pub hd_scan_state: Arc, } + +pub struct AppQosAggregationListener { + pub member_health: Arc, +} + +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); + } +} diff --git a/backend/app/src/api/model/app_state/reload.rs b/backend/app/src/api/model/app_state/reload.rs index 400366f6b..478fe079b 100644 --- a/backend/app/src/api/model/app_state/reload.rs +++ b/backend/app/src/api/model/app_state/reload.rs @@ -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) => { @@ -131,6 +133,7 @@ fn cancel_services(app_state: &Arc, changes: &UpdateChanges) { qos_aggregation, recordings, hls_cache: cancel_tokens.hls_cache.clone(), + channel_groups: cancel_tokens.channel_groups.clone(), }; app_state.cancel_tokens.store(Arc::new(tokens)); @@ -166,7 +169,11 @@ fn start_services(app_state: &Arc, 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); @@ -214,6 +221,8 @@ impl AppState { let storage_dir = config.storage_dir.clone(); self.active_users.update_config(&config); + self.member_health + .set_policy(tuliprox_session::channel_group::CircuitPolicy::from(&config.channel_groups.circuit)); self.app_config.set_config(config)?; reload_logger(config_log_level.as_deref()); self.active_provider.update_config(&self.app_config); diff --git a/backend/app/src/api/model/app_state/test_support.rs b/backend/app/src/api/model/app_state/test_support.rs index 5dbf3e16a..1b12a3205 100644 --- a/backend/app/src/api/model/app_state/test_support.rs +++ b/backend/app/src/api/model/app_state/test_support.rs @@ -88,6 +88,7 @@ pub fn create_test_app_state(config: Config) -> Arc { auth: AuthState::for_tests(), playlist_updates: PlaylistUpdateControl::for_tests(), maintenance, + member_health: Arc::new(tuliprox_session::MemberHealthIndex::default()), }) } diff --git a/backend/app/src/api/model/app_state/tests.rs b/backend/app/src/api/model/app_state/tests.rs index 14180c2f8..e18becb77 100644 --- a/backend/app/src/api/model/app_state/tests.rs +++ b/backend/app/src/api/model/app_state/tests.rs @@ -276,3 +276,19 @@ async fn contactable_input_hides_inputs_in_maintenance() { app_state.maintenance.add("provider_a", None, 1).await.unwrap(); assert!(app_state.contactable_input(&name).is_none(), "an input in maintenance is not contacted"); } + +#[tokio::test] +async fn config_reload_applies_channel_group_circuit_settings() { + let state = super::create_test_app_state(Config::default()); + let circuit = shared::model::ChannelGroupCircuitConfig { + source_failures: 5, + ..shared::model::ChannelGroupCircuitConfig::default() + }; + let config = Config { + channel_groups: shared::model::ChannelGroupsConfig { circuit, ..shared::model::ChannelGroupsConfig::default() }, + ..Config::default() + }; + + assert!(state.set_config(config).await.is_ok()); + assert_eq!(*state.member_health.policy(), tuliprox_session::channel_group::CircuitPolicy::from(&circuit)); +} diff --git a/backend/app/src/api/model/streams/active_client_stream.rs b/backend/app/src/api/model/streams/active_client_stream.rs index b75023b18..80e9d31ae 100644 --- a/backend/app/src/api/model/streams/active_client_stream.rs +++ b/backend/app/src/api/model/streams/active_client_stream.rs @@ -24,6 +24,8 @@ pub(crate) struct ActiveClientStreamParams<'a> { pub req_headers: &'a HeaderMap, pub meter_uid: u32, pub meter_stream: bool, + /// Replaces a failing member of a direct group playback underneath this response. + pub group_failover: Option, } pub(in crate::api) struct ActiveClientStream { diff --git a/backend/app/src/api/model/streams/active_client_stream/admission.rs b/backend/app/src/api/model/streams/active_client_stream/admission.rs index bfc78ac0f..18369936c 100644 --- a/backend/app/src/api/model/streams/active_client_stream/admission.rs +++ b/backend/app/src/api/model/streams/active_client_stream/admission.rs @@ -5,9 +5,12 @@ use super::{ GraceProvisioningInfo, StreamAdmissionError, StreamMode, TimedStreamContext, }; use crate::{ - api::model::{ - connection_manager::PROVIDER_END_NOT_SET, AppState, BoxedProviderStream, ConnectionManager, EventManager, - MeteringStream, StreamDetails, StreamMeterHandle, + api::{ + api_utils::channel_group_failover::GroupSwitcher, + model::{ + connection_manager::PROVIDER_END_NOT_SET, AppState, BoxedProviderStream, ConnectionManager, EventManager, + MeteringStream, StreamDetails, StreamMeterHandle, + }, }, auth::Fingerprint, model::ProxyUserCredentials, @@ -107,6 +110,11 @@ pub(super) struct ActiveClientStreamState { /// finishes it exactly once with the real provider outcome, so a full cleanup queue can /// never leak the user request or provider lease. pub(super) request_cleanup: Option, + /// Runtime member failover of a direct group playback. Boxed: most streams are not group + /// streams and should not carry it inline. + pub(super) group_switcher: Option>, + /// Lease owner re-armed for the confirmation of every member a switch opens. + pub(super) group_lease_owner: Option>, } #[allow(clippy::too_many_lines)] @@ -126,6 +134,7 @@ pub(crate) async fn create_active_client_stream( req_headers, meter_uid, meter_stream, + group_failover, } = request; if connection_permission == UserConnectionPermission::Exhausted { error!("Something is wrong this should not happen"); @@ -341,13 +350,19 @@ pub(crate) async fn create_active_client_stream( } else { stream }; - Some(wrap_timed_client_stream_if_needed( - app_state, - stream, - fingerprint.addr, - VirtualId::new(virtual_id), - display_stream_uid, - )) + // A group stream replaces its member body on failure, so the sleep timer goes around + // the whole response instead; its end would otherwise count as a member failure. + if group_failover.is_some() { + Some(stream) + } else { + Some(wrap_timed_client_stream_if_needed( + app_state, + stream, + fingerprint.addr, + VirtualId::new(virtual_id), + display_stream_uid, + )) + } } }; @@ -392,6 +407,8 @@ pub(crate) async fn create_active_client_stream( provider_error_class: None, provider_http_status: None, provider_reconnect_count: AtomicU8::new(0), + group_lease_owner: group_failover.as_ref().and(lease_owner.clone()), + group_switcher: group_failover.map(|driver| Box::new(GroupSwitcher::new(driver))), lease_owner, media_started, lease_confirmed: false, @@ -399,5 +416,16 @@ pub(crate) async fn create_active_client_stream( request_cleanup: registered_request.into_body_cleanup(), }; - Ok(ActiveClientStream { state }.boxed()) + let timed_whole_response = state.group_switcher.is_some(); + let stream = ActiveClientStream { state }.boxed(); + if timed_whole_response { + return Ok(wrap_timed_client_stream_if_needed( + app_state, + stream, + fingerprint.addr, + VirtualId::new(virtual_id), + display_stream_uid, + )); + } + Ok(stream) } diff --git a/backend/app/src/api/model/streams/active_client_stream/provider_open.rs b/backend/app/src/api/model/streams/active_client_stream/provider_open.rs index a37513e0e..c66bce99f 100644 --- a/backend/app/src/api/model/streams/active_client_stream/provider_open.rs +++ b/backend/app/src/api/model/streams/active_client_stream/provider_open.rs @@ -71,6 +71,8 @@ impl ActiveClientStreamState { self.terminate_quietly(); return false; } + // A preempted slot belongs to another playback now: no failover to another member. + self.group_switcher = None; self.provider_stopped = true; self.preempt_cancelled = None; self.stop_grace_task(); @@ -134,53 +136,60 @@ impl ActiveClientStreamState { self.provider_stopped = true; self.preempt_cancelled = None; self.stop_grace_task(); + // A member switch in flight is abandoned with the provider stream; while it runs no + // provider handle is held, but the stop is still published. + let switch_abandoned = self.group_switcher.take().is_some_and(|switcher| switcher.is_switching()); if self.provider_handle.is_some() { // Synchronous provider-slot release; the custom-video detail update is a // separate, best-effort UI effect. self.release_provider_handle_and_detach_body(); + self.publish_provider_stop(mode); + } else if switch_abandoned { + self.publish_provider_stop(mode); + } + } - if mode == StreamMode::ChannelUnavailable { - if let Some(flag) = &self.send_custom_stream_flag { - let _ = flag.compare_exchange( - StreamMode::Inner as u8, - StreamMode::ChannelUnavailable as u8, - Ordering::AcqRel, - Ordering::Relaxed, - ); - } + /// Publishes the end of the provider stream: custom-video mode, waker, panel detail or the + /// release of the stream claim. + pub(super) fn publish_provider_stop(&mut self, mode: StreamMode) { + if mode == StreamMode::ChannelUnavailable { + if let Some(flag) = &self.send_custom_stream_flag { + let _ = flag.compare_exchange( + StreamMode::Inner as u8, + StreamMode::ChannelUnavailable as u8, + Ordering::AcqRel, + Ordering::Relaxed, + ); } + } - if let Some(waker) = &self.waker { - waker.wake(); - } + if let Some(waker) = &self.waker { + waker.wake(); + } - let addr = self.fingerprint.addr; - let reason = match mode { - StreamMode::ChannelUnavailable => "unavailable provider channel", - StreamMode::UserExhausted => "user grace period exhaustion", - StreamMode::ProviderExhausted => "provider grace period exhaustion", - StreamMode::Provisioning => "provider grace period provisioning", - StreamMode::LowPriorityPreempted => "low-priority preemption", - StreamMode::ReentrySuppressed => "reentry suppression", - StreamMode::Inner | StreamMode::GracePending => "stream mode transition", - }; - debug_if_enabled!( - "Provider stream stopped due to {reason} for {}", - sanitize_sensitive_info(&addr.to_string()) - ); - if let Some(video_type) = Self::custom_video_type_for_mode(mode) { - self.connection_manager.send_cleanup(CleanupEvent::UpdateDetailAndReleaseProvider { - addr, - stream_uid: self.stream_uid, - video_type, - handle: None, - }); - } else { - // No custom video for this mode: release the request without publishing - // a detail update so no error clip is shown. - self.release_user_stream(); - } + let addr = self.fingerprint.addr; + let reason = match mode { + StreamMode::ChannelUnavailable => "unavailable provider channel", + StreamMode::UserExhausted => "user grace period exhaustion", + StreamMode::ProviderExhausted => "provider grace period exhaustion", + StreamMode::Provisioning => "provider grace period provisioning", + StreamMode::LowPriorityPreempted => "low-priority preemption", + StreamMode::ReentrySuppressed => "reentry suppression", + StreamMode::Inner | StreamMode::GracePending => "stream mode transition", + }; + debug_if_enabled!("Provider stream stopped due to {reason} for {}", sanitize_sensitive_info(&addr.to_string())); + if let Some(video_type) = Self::custom_video_type_for_mode(mode) { + self.connection_manager.send_cleanup(CleanupEvent::UpdateDetailAndReleaseProvider { + addr, + stream_uid: self.stream_uid, + video_type, + handle: None, + }); + } else { + // No custom video for this mode: release the request without publishing + // a detail update so no error clip is shown. + self.release_user_stream(); } } } diff --git a/backend/app/src/api/model/streams/active_client_stream/stream.rs b/backend/app/src/api/model/streams/active_client_stream/stream.rs index f04c21085..087479b4b 100644 --- a/backend/app/src/api/model/streams/active_client_stream/stream.rs +++ b/backend/app/src/api/model/streams/active_client_stream/stream.rs @@ -2,21 +2,31 @@ use super::{ ActiveClientStream, ActiveClientStreamState, DeferredProviderOpenOutcome, DeferredProviderOpenState, StreamMode, TimedStreamContext, }; -use crate::api::model::{ - connection_manager::{PROVIDER_END_CLOSED, PROVIDER_END_ERROR, PROVIDER_END_NOT_SET}, - open_provider_stream_with_lifecycle, AppState, BoxedProviderStream, ProviderStreamOpen, - ProviderStreamOpenLifecycle, StreamError, TimedClientStream, +use crate::api::{ + api_utils::channel_group_failover::{GroupEnd, GroupPoll, MemberHost, RuntimeMember}, + model::{ + connection_manager::{PROVIDER_END_CLOSED, PROVIDER_END_ERROR, PROVIDER_END_NOT_SET}, + open_provider_stream_with_lifecycle, AppState, BoxedProviderStream, ProviderStreamOpen, + ProviderStreamOpenLifecycle, StreamError, TimedClientStream, + }, }; use bytes::Bytes; use futures::{Future, Stream, StreamExt}; use log::{error, info}; -use shared::{model::VirtualId, utils::sanitize_sensitive_info}; +use shared::{ + model::{SwitchReason, VirtualId}, + utils::sanitize_sensitive_info, +}; use std::{ net::SocketAddr, pin::Pin, - sync::{atomic::Ordering, Arc}, + sync::{ + atomic::{AtomicU8, Ordering}, + Arc, + }, task::{Context, Poll}, }; +use tuliprox_session::stream_options::StreamResponseMode; impl ActiveClientStreamState { /// Drops the provider handle at most once (releasing the provider slot @@ -74,6 +84,30 @@ impl ActiveClientStreamState { } } + /// No member could take over within the budget: the response ends like a provider stream + /// that ended without failover. + /// Returns true when the channel-unavailable video follows instead of the end of the body. + pub(super) fn end_failed_group_switch(&mut self, reason: SwitchReason) -> bool { + // The provider ended cleanly only when its body ended; otherwise it failed. + let end_reason = if reason == SwitchReason::UpstreamEof { PROVIDER_END_CLOSED } else { PROVIDER_END_ERROR }; + let _ = self.provider_end_reason.compare_exchange( + PROVIDER_END_NOT_SET, + end_reason, + Ordering::Relaxed, + Ordering::Relaxed, + ); + self.preempt_cancelled = None; + self.stop_grace_task(); + self.provider_stopped = true; + let shows_video = self.response_mode == StreamResponseMode::Stream && self.custom_video.unavailable.is_some(); + if shows_video { + // The mode flag only exists for streams admitted with grace or preemption video. + self.send_custom_stream_flag.get_or_insert_with(|| Arc::new(AtomicU8::new(StreamMode::Inner as u8))); + } + self.publish_provider_stop(StreamMode::ChannelUnavailable); + shows_video + } + pub(super) fn release_stream_and_provider_handle_once(&mut self) { self.stop_grace_task(); self.release_provider_handle_and_detach_body(); @@ -98,6 +132,40 @@ impl ActiveClientStreamState { } } +impl MemberHost for ActiveClientStreamState { + fn member_body(&mut self) -> Option<&mut BoxedProviderStream> { self.inner.as_mut() } + + fn member_preempted(&self) -> bool { + self.provider_handle + .as_ref() + .and_then(|managed| managed.handle()) + .and_then(|handle| handle.cancel_token.as_ref()) + .is_some_and(tokio_util::sync::CancellationToken::is_cancelled) + } + + fn release_member(&mut self) { + self.preempt_cancelled = None; + self.release_provider_handle_and_detach_body(); + } + + fn install_member(&mut self, member: RuntimeMember) { + self.preempt_cancelled = member + .handle + .as_ref() + .and_then(|managed| managed.handle()) + .and_then(|handle| handle.cancel_token.as_ref()) + .map(|token| Box::pin(token.clone().cancelled_owned())); + self.provider_handle = member.handle; + self.provider_handle_released = false; + self.inner = Some(self.wrap_provider_stream(member.stream)); + self.provider_reconnect_count.fetch_add(1, Ordering::Relaxed); + if self.group_lease_owner.is_some() { + self.lease_owner.clone_from(&self.group_lease_owner); + self.lease_confirmed = false; + } + } +} + pub(super) fn wrap_timed_client_stream_if_needed( app_state: &Arc, stream: BoxedProviderStream, @@ -185,100 +253,125 @@ impl Stream for ActiveClientStream { StreamMode::Inner => { self.state.reset_custom_video_timeout(); - if self.state.inner.is_none() { - if let Some(deferred_provider_open) = self.state.deferred_provider_open.take() { - match deferred_provider_open { - DeferredProviderOpenState::Pending(context) => { - let app_state = Arc::clone(&context.app_state); - let client = { - let http_client = app_state.http_clients.default.load(); - http_client.as_ref().clone() - }; - let lifecycle = self - .state - .provider_handle - .as_ref() - .and_then(ProviderStreamOpenLifecycle::from_managed); - let future = Box::pin(async move { - match open_provider_stream_with_lifecycle( - &app_state.provider_stream_ctx(), - &client, - context.provider_stream_factory_options, - lifecycle, - ) - .await - { - Some(ProviderStreamOpen::Stream(response)) - if matches!(response.info, Some((_, _, _, Some(_)))) => + let polled = if let Some(mut switcher) = self.state.group_switcher.take() { + let polled = switcher.poll_member(&mut self.state, cx); + if !matches!(polled, GroupPoll::Ended(_)) { + self.state.group_switcher = Some(switcher); + } + polled + } else { + if self.state.inner.is_none() { + if let Some(deferred_provider_open) = self.state.deferred_provider_open.take() { + match deferred_provider_open { + DeferredProviderOpenState::Pending(context) => { + let app_state = Arc::clone(&context.app_state); + let client = { + let http_client = app_state.http_clients.default.load(); + http_client.as_ref().clone() + }; + let lifecycle = self + .state + .provider_handle + .as_ref() + .and_then(ProviderStreamOpenLifecycle::from_managed); + let future = Box::pin(async move { + match open_provider_stream_with_lifecycle( + &app_state.provider_stream_ctx(), + &client, + context.provider_stream_factory_options, + lifecycle, + ) + .await { - let Some((_headers, _status, _response_url, Some(custom_video_type))) = - response.info - else { - return DeferredProviderOpenOutcome::Failed; - }; - ActiveClientStreamState::mode_for_custom_video_type(custom_video_type) + Some(ProviderStreamOpen::Stream(response)) + if matches!(response.info, Some((_, _, _, Some(_)))) => + { + let Some(( + _headers, + _status, + _response_url, + Some(custom_video_type), + )) = response.info + else { + return DeferredProviderOpenOutcome::Failed; + }; + ActiveClientStreamState::mode_for_custom_video_type( + custom_video_type, + ) .map_or( DeferredProviderOpenOutcome::Failed, DeferredProviderOpenOutcome::Mode, ) + } + Some(ProviderStreamOpen::Stream(response)) => { + DeferredProviderOpenOutcome::Stream(response.stream) + } + Some(ProviderStreamOpen::UpstreamStatus { .. }) | None => { + DeferredProviderOpenOutcome::Failed + } } - Some(ProviderStreamOpen::Stream(response)) => { - DeferredProviderOpenOutcome::Stream(response.stream) - } - Some(ProviderStreamOpen::UpstreamStatus { .. }) | None => { - DeferredProviderOpenOutcome::Failed - } - } - }); - self.state.deferred_provider_open = - Some(DeferredProviderOpenState::Opening(future)); - continue; - } - DeferredProviderOpenState::Opening(mut future) => match future.as_mut().poll(cx) { - Poll::Pending => { + }); self.state.deferred_provider_open = Some(DeferredProviderOpenState::Opening(future)); - return Poll::Pending; - } - Poll::Ready(DeferredProviderOpenOutcome::Stream(stream)) => { - self.state.provider_reconnect_count.fetch_add(1, Ordering::Relaxed); - self.state.inner = Some(self.state.wrap_provider_stream(stream)); - self.state.mark_direct_body_progress(); continue; } - Poll::Ready(DeferredProviderOpenOutcome::Mode(mode)) => { - if !self.state.enter_custom_mode(mode) { - return Poll::Ready(None); + DeferredProviderOpenState::Opening(mut future) => match future.as_mut().poll(cx) { + Poll::Pending => { + self.state.deferred_provider_open = + Some(DeferredProviderOpenState::Opening(future)); + return Poll::Pending; } - continue; - } - Poll::Ready(DeferredProviderOpenOutcome::Failed) => { - if !self.state.enter_custom_mode(StreamMode::ChannelUnavailable) { - return Poll::Ready(None); + Poll::Ready(DeferredProviderOpenOutcome::Stream(stream)) => { + self.state.provider_reconnect_count.fetch_add(1, Ordering::Relaxed); + self.state.inner = Some(self.state.wrap_provider_stream(stream)); + self.state.mark_direct_body_progress(); + continue; } - continue; - } - }, + Poll::Ready(DeferredProviderOpenOutcome::Mode(mode)) => { + if !self.state.enter_custom_mode(mode) { + return Poll::Ready(None); + } + continue; + } + Poll::Ready(DeferredProviderOpenOutcome::Failed) => { + if !self.state.enter_custom_mode(StreamMode::ChannelUnavailable) { + return Poll::Ready(None); + } + continue; + } + }, + } } + + if self.state.grace_task_handle.is_none() { + self.state.stop_provider_stream(StreamMode::ChannelUnavailable); + return Poll::Ready(None); + } + + return Poll::Pending; } - if self.state.grace_task_handle.is_none() { - self.state.stop_provider_stream(StreamMode::ChannelUnavailable); - return Poll::Ready(None); + match self.state.inner.as_mut().map(|inner| Pin::new(inner).poll_next(cx)) { + Some(Poll::Ready(Some(Ok(bytes)))) => GroupPoll::Data(bytes), + Some(Poll::Ready(Some(Err(e)))) => GroupPoll::Ended(GroupEnd::Error(e)), + Some(Poll::Ready(None)) | None => GroupPoll::Ended(GroupEnd::Eof), + Some(Poll::Pending) => GroupPoll::Pending, } + }; - return Poll::Pending; - } - - match self.state.inner.as_mut().map(|inner| Pin::new(inner).poll_next(cx)) { - Some(Poll::Ready(Some(Ok(bytes)))) => { + match polled { + GroupPoll::Keepalive(packets) => { + self.state.mark_direct_body_progress(); + return Poll::Ready(Some(Ok(packets))); + } + GroupPoll::Data(bytes) => { if !bytes.is_empty() { self.state.confirm_lease_once(); } self.state.mark_direct_body_progress(); return Poll::Ready(Some(Ok(bytes))); } - Some(Poll::Ready(Some(Err(e)))) => { + GroupPoll::Ended(GroupEnd::Error(e)) => { error!("Inner stream error: {e:?}"); self.state.provider_error_class = Some(e.provider_error_class()); self.state.provider_http_status = e.provider_http_status(); @@ -295,7 +388,7 @@ impl Stream for ActiveClientStream { return Poll::Pending; } - Some(Poll::Ready(None)) | None => { + GroupPoll::Ended(GroupEnd::Eof) => { let _ = self.state.provider_end_reason.compare_exchange( PROVIDER_END_NOT_SET, PROVIDER_END_CLOSED, @@ -309,7 +402,24 @@ impl Stream for ActiveClientStream { return Poll::Pending; } - Some(Poll::Pending) => { + // A stalled group member that no other member could replace. + GroupPoll::Ended(GroupEnd::Stalled) => { + let _ = self.state.provider_end_reason.compare_exchange( + PROVIDER_END_NOT_SET, + PROVIDER_END_ERROR, + Ordering::Relaxed, + Ordering::Relaxed, + ); + self.state.stop_provider_stream(StreamMode::ChannelUnavailable); + return Poll::Ready(None); + } + GroupPoll::Ended(GroupEnd::SwitchFailed { reason }) => { + if self.state.end_failed_group_switch(reason) { + continue; + } + return Poll::Ready(None); + } + GroupPoll::Pending => { if self.state.direct_body_idle_timeout.poll_expired(cx) { self.state.stop_direct_body_idle_timeout(); return Poll::Ready(None); diff --git a/backend/app/src/api/model/streams/active_client_stream/tests/admission.rs b/backend/app/src/api/model/streams/active_client_stream/tests/admission.rs index 5a71afef0..d94da7544 100644 --- a/backend/app/src/api/model/streams/active_client_stream/tests/admission.rs +++ b/backend/app/src/api/model/streams/active_client_stream/tests/admission.rs @@ -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()), }) } @@ -422,6 +423,7 @@ async fn test_active_client_stream_deferred_provider_grace_retains_provider_hand req_headers: &HeaderMap::default(), meter_uid: 0, meter_stream: false, + group_failover: None, }) .await .expect("deferred test stream admission should succeed"); @@ -499,6 +501,7 @@ async fn test_active_client_stream_shared_deferred_provider_grace_retains_provid req_headers: &HeaderMap::default(), meter_uid: 0, meter_stream: false, + group_failover: None, }) .await .expect("shared deferred test stream admission should succeed"); diff --git a/backend/app/src/api/model/streams/active_client_stream/tests/behavior.rs b/backend/app/src/api/model/streams/active_client_stream/tests/behavior.rs index d6b9d83a5..f5cf4bb45 100644 --- a/backend/app/src/api/model/streams/active_client_stream/tests/behavior.rs +++ b/backend/app/src/api/model/streams/active_client_stream/tests/behavior.rs @@ -185,6 +185,8 @@ async fn test_active_client_stream_deferred_provider_open_applies_sleep_timer_ti provider_error_class: None, provider_http_status: None, provider_reconnect_count: AtomicU8::new(0), + group_switcher: None, + group_lease_owner: None, lease_owner: None, media_started: None, lease_confirmed: false, @@ -238,6 +240,7 @@ async fn test_active_client_stream_immediate_provider_stream_emits_meter_batches req_headers: &HeaderMap::default(), meter_uid: 55, meter_stream: true, + group_failover: None, }) .await .expect("metered test stream admission should succeed"); diff --git a/backend/app/src/api/model/streams/active_client_stream/tests/lifecycle.rs b/backend/app/src/api/model/streams/active_client_stream/tests/lifecycle.rs index f19092b3a..4d41f0060 100644 --- a/backend/app/src/api/model/streams/active_client_stream/tests/lifecycle.rs +++ b/backend/app/src/api/model/streams/active_client_stream/tests/lifecycle.rs @@ -141,6 +141,7 @@ pub(in crate::api::model::streams::active_client_stream::tests) async fn create_ req_headers: &HeaderMap::default(), meter_uid: 0, meter_stream: false, + group_failover: None, }) .await .expect("direct test stream admission should succeed"); diff --git a/backend/app/src/api/model/streams/active_client_stream/tests/support.rs b/backend/app/src/api/model/streams/active_client_stream/tests/support.rs index b85a40a93..4d1fca848 100644 --- a/backend/app/src/api/model/streams/active_client_stream/tests/support.rs +++ b/backend/app/src/api/model/streams/active_client_stream/tests/support.rs @@ -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, @@ -232,6 +234,8 @@ pub(in crate::api::model::streams::active_client_stream::tests) fn custom_video_ provider_error_class: None, provider_http_status: None, provider_reconnect_count: AtomicU8::new(0), + group_switcher: None, + group_lease_owner: None, lease_owner: None, media_started: None, lease_confirmed: false, diff --git a/backend/app/src/api/model/streams/provider_stream_factory.rs b/backend/app/src/api/model/streams/provider_stream_factory.rs index 97be489b9..6e5b142bd 100644 --- a/backend/app/src/api/model/streams/provider_stream_factory.rs +++ b/backend/app/src/api/model/streams/provider_stream_factory.rs @@ -44,7 +44,8 @@ create_bitset!( RangeRequested, PublicDestinationRequired, HlsResource, - FlussonicAudioTracks + FlussonicAudioTracks, + GroupMember ); #[derive(Debug, Clone)] @@ -101,7 +102,9 @@ mod response; pub use opening::{create_provider_stream, open_provider_stream_with_lifecycle, ProviderStreamOpen}; pub(crate) use options::ProviderStreamFactoryParams; #[cfg(test)] -use request::{prepare_client, provider_headers_require_manual_redirects, send_with_manual_redirects}; +use request::{ + initial_open_retry_exhausted, prepare_client, provider_headers_require_manual_redirects, send_with_manual_redirects, +}; #[cfg(test)] use response::normalize_hls_resource_content_type; #[cfg(test)] diff --git a/backend/app/src/api/model/streams/provider_stream_factory/opening.rs b/backend/app/src/api/model/streams/provider_stream_factory/opening.rs index 703a28e4a..cfed6f566 100644 --- a/backend/app/src/api/model/streams/provider_stream_factory/opening.rs +++ b/backend/app/src/api/model/streams/provider_stream_factory/opening.rs @@ -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(), diff --git a/backend/app/src/api/model/streams/provider_stream_factory/options.rs b/backend/app/src/api/model/streams/provider_stream_factory/options.rs index 86713f8d6..24924d757 100644 --- a/backend/app/src/api/model/streams/provider_stream_factory/options.rs +++ b/backend/app/src/api/model/streams/provider_stream_factory/options.rs @@ -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, diff --git a/backend/app/src/api/model/streams/provider_stream_factory/request.rs b/backend/app/src/api/model/streams/provider_stream_factory/request.rs index fa58c5281..63c33b2c7 100644 --- a/backend/app/src/api/model/streams/provider_stream_factory/request.rs +++ b/backend/app/src/api/model/streams/provider_stream_factory/request.rs @@ -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, @@ -426,6 +430,16 @@ fn request_provider_account_probe(ctx: &ProviderStreamCtx, options: &ProviderStr } } +/// `connect_err` counts the attempt that just ran. A group member never retries itself: the +/// group selection moves on to the next member instead. +pub(super) fn initial_open_retry_exhausted(is_group_member: bool, connect_err: u32) -> bool { + if is_group_member { + connect_err >= 1 + } else { + connect_err > ERR_MAX_RETRY_COUNT + } +} + #[allow(clippy::too_many_lines)] pub(super) async fn get_provider_stream( ctx: &ProviderStreamCtx, @@ -436,6 +450,8 @@ 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 mut last_failure: Option = None; let handle_cancel = stream_options.get_cancel_token(); while stream_options.should_continue() { @@ -464,7 +480,7 @@ pub(super) async fn get_provider_stream( return Ok(Some(stream_response)); } Ok(None) => { - if connect_err > ERR_MAX_RETRY_COUNT { + if initial_open_retry_exhausted(is_group_member, connect_err) { warn!( "The stream could be unavailable. {}", sanitize_sensitive_info(stream_options.get_log_url().as_ref()) @@ -495,18 +511,21 @@ 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 initial_open_retry_exhausted(is_group_member, connect_err) { 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() || initial_open_retry_exhausted(is_group_member, connect_err) { break; } if !stream_options.should_retry_initial_open_loop() { @@ -533,6 +552,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", diff --git a/backend/app/src/api/model/streams/provider_stream_factory/tests/recovery.rs b/backend/app/src/api/model/streams/provider_stream_factory/tests/recovery.rs index aac89f9c8..2e7bef387 100644 --- a/backend/app/src/api/model/streams/provider_stream_factory/tests/recovery.rs +++ b/backend/app/src/api/model/streams/provider_stream_factory/tests/recovery.rs @@ -74,3 +74,17 @@ async fn legacy_hls_stream_decoder_failure_aborts_without_retry() { assert!(matches!(error, StreamError::ContentDecoding(_))); assert_eq!(requests.load(Ordering::SeqCst), 1, "a body-stream failure cannot trigger a new request"); } + +#[test] +fn non_group_open_retries_until_retry_limit() { + // The attempt counter starts at 1, so a single source gets ERR_MAX_RETRY_COUNT + 1 attempts. + for attempt in 1..=ERR_MAX_RETRY_COUNT { + assert!(!initial_open_retry_exhausted(false, attempt), "attempt {attempt} must allow a retry"); + } + assert!(initial_open_retry_exhausted(false, ERR_MAX_RETRY_COUNT + 1)); +} + +#[test] +fn group_member_open_does_not_retry() { + assert!(initial_open_retry_exhausted(true, 1)); +} diff --git a/backend/app/src/api/model/streams/provider_stream_factory/tests/streaming.rs b/backend/app/src/api/model/streams/provider_stream_factory/tests/streaming.rs index b453c5361..77afa62f6 100644 --- a/backend/app/src/api/model/streams/provider_stream_factory/tests/streaming.rs +++ b/backend/app/src/api/model/streams/provider_stream_factory/tests/streaming.rs @@ -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, diff --git a/backend/app/src/api/serve.rs b/backend/app/src/api/serve.rs index c61d5c8e1..2c0f93d38 100644 --- a/backend/app/src/api/serve.rs +++ b/backend/app/src/api/serve.rs @@ -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, @@ -337,6 +338,7 @@ mod tests { req_headers: &HeaderMap::new(), meter_uid: 0, meter_stream: false, + group_failover: None, }) .await .expect("test stream admission should succeed"); diff --git a/backend/app/src/api/tasks/channel_group_members.rs b/backend/app/src/api/tasks/channel_group_members.rs new file mode 100644 index 000000000..4f4a249dd --- /dev/null +++ b/backend/app/src/api/tasks/channel_group_members.rs @@ -0,0 +1,65 @@ +use crate::api::model::AppState; +use log::warn; +use shared::model::{ConfigType, EventMessage, PlaylistUpdateState}; +use std::{collections::HashSet, sync::Arc}; +use tokio::sync::broadcast::error::RecvError; +use tokio_util::sync::CancellationToken; +use tuliprox_repository::target_stream_item::channel_groups_enabled; + +/// Restricts the member health probe store to the members of all channel groups. +/// Returns the number of member keys. +pub(in crate::api) async fn refresh_member_keys(app_state: &AppState) -> usize { + let targets: Vec<_> = app_state + .app_config + .sources + .load() + .sources + .iter() + .flat_map(|source| source.targets.iter()) + .filter(|target| channel_groups_enabled(target)) + .map(Arc::clone) + .collect(); + let mut keys = HashSet::new(); + for target in targets { + let data = + app_state.playlists.channel_groups.get_or_load(&app_state.app_config, &app_state.playlists, &target).await; + keys.extend(data.member_keys()); + } + let count = keys.len(); + app_state.member_health.set_member_keys(keys); + count +} + +/// Refreshes the member keys at startup, after every playlist update and after a sources or +/// config reload, until `cancel` fires. +pub(in crate::api) fn exec_channel_group_member_keys( + app_state: &Arc, + cancel: &CancellationToken, +) -> tokio::task::JoinHandle<()> { + let app_state = Arc::clone(app_state); + let cancel = cancel.clone(); + tokio::spawn(async move { + let mut rx = app_state.event_manager.get_event_channel(); + refresh_member_keys(&app_state).await; + loop { + let event = tokio::select! { + () = cancel.cancelled() => break, + event = rx.recv() => event, + }; + match event { + Ok(EventMessage::PlaylistUpdate(summary)) if summary.state != PlaylistUpdateState::Failure => { + refresh_member_keys(&app_state).await; + } + Ok(EventMessage::ConfigChange(ConfigType::Sources | ConfigType::Config)) => { + refresh_member_keys(&app_state).await; + } + Ok(_) => {} + Err(RecvError::Lagged(skipped)) => { + warn!("Channel group member key refresh lagged by {skipped} events"); + refresh_member_keys(&app_state).await; + } + Err(RecvError::Closed) => break, + } + } + }) +} diff --git a/backend/app/src/api/tasks/mod.rs b/backend/app/src/api/tasks/mod.rs index 3228363e9..9ab3d1230 100644 --- a/backend/app/src/api/tasks/mod.rs +++ b/backend/app/src/api/tasks/mod.rs @@ -15,6 +15,7 @@ //! would gain `session` for an event publish - and 194 lines is not worth //! degrading a leaf crate or minting a package to hold. +mod channel_group_members; mod config_watch; mod hdhomerun_ssdp; mod library_scan; @@ -22,6 +23,7 @@ mod notification_bridge; mod scheduler; mod xtream_expiry; +pub(in crate::api) use channel_group_members::*; pub(in crate::api) use config_watch::*; pub(in crate::api) use hdhomerun_ssdp::*; pub(in crate::api) use library_scan::*; diff --git a/backend/app/src/api/tasks/notification_bridge.rs b/backend/app/src/api/tasks/notification_bridge.rs index 575d88c12..36159d0f2 100644 --- a/backend/app/src/api/tasks/notification_bridge.rs +++ b/backend/app/src/api/tasks/notification_bridge.rs @@ -287,7 +287,11 @@ pub fn to_notification(message: &EventMessage) -> Option { // which is the single place that decision is made. EventMessage::PlaylistUpdateProgress(_) | EventMessage::SystemInfoUpdate(_) - | EventMessage::RecordingProgress => None, + | EventMessage::RecordingProgress + | EventMessage::ChannelGroupMemberSelected { .. } + | EventMessage::ChannelGroupExhausted { .. } + | EventMessage::ChannelGroupCircuitChanged { .. } + | EventMessage::ChannelGroupMemberSwitched { .. } => None, } } @@ -631,6 +635,25 @@ 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::ChannelGroupMemberSwitched { + target_id: 1, + group_vid: 10, + from_member_vid: 11, + to_member_vid: 12, + reason: shared::model::channel_group::SwitchReason::UpstreamEof, + }, + EventMessage::ChannelGroupCircuitChanged { + input_name: "test".into(), + input_stream_id: "1".into(), + state: shared::model::channel_group::CircuitState::Closed, + }, ]; assert_eq!(samples.len(), EventKind::ALL.len(), "add the new variant to this list"); samples diff --git a/backend/config-loader/src/runtime_config_report.rs b/backend/config-loader/src/runtime_config_report.rs index dd990fc62..066c9574d 100644 --- a/backend/config-loader/src/runtime_config_report.rs +++ b/backend/config-loader/src/runtime_config_report.rs @@ -103,6 +103,7 @@ fn runtime_config_to_dto(config: &Config) -> ConfigDto { proxy: config.proxy.as_ref().map(shared::model::ProxyConfigDto::from), ipcheck: config.ipcheck.as_ref().map(shared::model::IpCheckConfigDto::from), library: config.library.as_ref().map(library_config_to_dto), + channel_groups: (!config.channel_groups.is_default()).then(|| config.channel_groups.clone()), } } diff --git a/backend/core/src/model/config/base.rs b/backend/core/src/model/config/base.rs index e8c7cba13..e4b8fb17e 100644 --- a/backend/core/src/model/config/base.rs +++ b/backend/core/src/model/config/base.rs @@ -15,7 +15,7 @@ use shared::{ DEFAULT_RECORDING_DIR, DEFAULT_STORAGE_DIR, DEFAULT_STORAGE_TEMP_DIR, DEFAULT_USER_CONFIG_DIR, }, error::TuliproxError, - model::{ConfigDto, GeoIpUnavailablePolicy, HdHomeRunDeviceOverview}, + model::{ChannelGroupsConfig, ConfigDto, GeoIpUnavailablePolicy, HdHomeRunDeviceOverview}, utils::set_sanitize_sensitive_info, }; use std::{ @@ -115,6 +115,7 @@ pub struct Config { pub proxy: Option, pub ipcheck: Option, pub library: Option, + pub channel_groups: ChannelGroupsConfig, } /// Resolve a configured recording directory the way `Config::prepare` does: @@ -367,6 +368,7 @@ impl From<&ConfigDto> for Config { proxy: dto.proxy.as_ref().map(Into::into), ipcheck: dto.ipcheck.as_ref().map(Into::into), library: dto.library.as_ref().map(Into::into), + channel_groups: dto.channel_groups.clone().unwrap_or_default(), } } } diff --git a/backend/core/src/model/config/target.rs b/backend/core/src/model/config/target.rs index bf1b86976..4f453b400 100644 --- a/backend/core/src/model/config/target.rs +++ b/backend/core/src/model/config/target.rs @@ -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, + ChannelGroupsTargetConfig, 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,8 @@ pub struct TargetExecutionPlan { pub transform_stages: Vec, pub pre_transform_identity_dedup: bool, pub post_merge_content_dedup: Option, + /// The target's channel group switch and overrides; resolved against `config.yml` when used. + pub channel_groups: Option, } impl Default for TargetExecutionPlan { @@ -39,6 +41,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 +60,7 @@ 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).filter(|cfg| cfg.enabled), } } } diff --git a/backend/core/src/model/provider.rs b/backend/core/src/model/provider.rs index 175540064..6fff618b9 100644 --- a/backend/core/src/model/provider.rs +++ b/backend/core/src/model/provider.rs @@ -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> { if self.inner.get_next(grace, grace_period_timeout_secs) { return Some(Arc::clone(&self.inner)); diff --git a/backend/iptv/src/error.rs b/backend/iptv/src/error.rs index 1ae52ce45..9f84bec06 100644 --- a/backend/iptv/src/error.rs +++ b/backend/iptv/src/error.rs @@ -103,6 +103,7 @@ impl ProviderErrorKind { | ErrorKind::ConfigTarget | ErrorKind::ConfigWebUi | ErrorKind::TargetBouquet + | ErrorKind::ChannelGroup | ErrorKind::UrlParse | ErrorKind::FilterParse | ErrorKind::RegexCompile => Self::Config, diff --git a/backend/metadata/src/manager/task_processing.rs b/backend/metadata/src/manager/task_processing.rs index e6673d85f..7094b3ff8 100644 --- a/backend/metadata/src/manager/task_processing.rs +++ b/backend/metadata/src/manager/task_processing.rs @@ -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), _ => {} } diff --git a/backend/mpegts/Cargo.toml b/backend/mpegts/Cargo.toml index 82fd7c980..523b2b868 100644 --- a/backend/mpegts/Cargo.toml +++ b/backend/mpegts/Cargo.toml @@ -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"] } + diff --git a/backend/mpegts/src/lib.rs b/backend/mpegts/src/lib.rs index 1e7c9dbb1..92852b379 100644 --- a/backend/mpegts/src/lib.rs +++ b/backend/mpegts/src/lib.rs @@ -10,5 +10,7 @@ //! layers parse live ones. Nothing here may name `api`, `AppState` or a //! repository. +pub mod packet; +pub mod splice; pub mod transport_stream_buffer; pub mod ts_inspector; diff --git a/backend/mpegts/src/packet.rs b/backend/mpegts/src/packet.rs new file mode 100644 index 000000000..9ab3f8315 --- /dev/null +++ b/backend/mpegts/src/packet.rs @@ -0,0 +1,255 @@ +//! MPEG-TS packet basics shared by the buffer, the inspector and the splicer: sizes, sync search, +//! header fields, PSI sections and keyframe detection. + +use bytes::Bytes; + +pub const TS_PACKET_SIZE: usize = 188; +pub const TS_SYNC_BYTE: u8 = 0x47; +pub(crate) const NULL_PID: u16 = 0x1FFF; +/// PCR flag in the adaptation field flags. +pub(crate) const ADAPTATION_FIELD_FLAG_PCR: u8 = 0x10; +const NULL_PID_HIGH: u8 = 0x1F; +const NULL_PID_LOW: u8 = 0xFF; + +const fn null_packet() -> [u8; TS_PACKET_SIZE] { + let mut packet = [0xFF; TS_PACKET_SIZE]; + packet[0] = TS_SYNC_BYTE; + packet[1] = NULL_PID_HIGH; + packet[2] = NULL_PID_LOW; + packet[3] = 0x10; + packet +} + +/// A null packet (PID 0x1FFF): decoders discard it, players and proxies see bytes flowing. +pub static TS_NULL_PACKET: [u8; TS_PACKET_SIZE] = null_packet(); + +pub fn ts_null_packet_bytes() -> Bytes { Bytes::from_static(&TS_NULL_PACKET) } + +/// First offset in `offsets` with a sync byte at it and at the next `confirmations - 1` packet +/// starts. Packet starts past the end of `buf` count as matching only with `partial_tail`. +#[must_use] +pub(crate) fn find_sync( + buf: &[u8], + offsets: impl IntoIterator, + confirmations: usize, + partial_tail: bool, +) -> Option { + offsets.into_iter().take_while(|offset| *offset < buf.len()).find(|offset| { + (0..confirmations).all(|index| match buf.get(offset + index * TS_PACKET_SIZE) { + Some(byte) => *byte == TS_SYNC_BYTE, + None => partial_tail, + }) + }) +} + +/// 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 { find_sync(buf, 0..TS_PACKET_SIZE, 3, true) } + +/// A 13-bit PID from the two bytes that carry it (packet header, PAT and PMT entries). +#[must_use] +pub(crate) fn pid_from_bytes(high: u8, low: u8) -> u16 { (u16::from(high & 0x1F) << 8) | u16::from(low) } + +#[must_use] +pub(crate) fn ts_packet_pid(packet: &[u8]) -> u16 { pid_from_bytes(packet[1], packet[2]) } + +/// CRC-32/MPEG-2 of PSI sections: polynomial 0x04C11DB7, initial value 0xFFFFFFFF, no reflection, +/// no final xor. A section including its CRC yields 0. +#[must_use] +pub fn mpeg2_crc32(data: &[u8]) -> u32 { + let mut crc = 0xFFFF_FFFF_u32; + for &byte in data { + crc ^= u32::from(byte) << 24; + for _ in 0..8 { + crc = if crc & 0x8000_0000 == 0 { crc << 1 } else { (crc << 1) ^ 0x04C1_1DB7 }; + } + } + crc +} + +/// Read access to the header fields of one 188-byte packet. +#[derive(Clone, Copy)] +pub(crate) struct TsPacket<'a>(pub(crate) &'a [u8]); + +impl<'a> TsPacket<'a> { + pub(crate) fn pid(self) -> u16 { ts_packet_pid(self.0) } + + pub(crate) fn payload_unit_start(self) -> bool { self.0[1] & 0x40 != 0 } + + /// `adaptation_field_control`: 0b01 payload only, 0b10 adaptation field only, 0b11 both. + pub(crate) fn adaptation_field_control(self) -> u8 { (self.0[3] >> 4) & 0b11 } + + pub(crate) fn has_payload(self) -> bool { self.adaptation_field_control() & 0b01 != 0 } + + /// Length of the adaptation field (after its length byte), `None` without one. + pub(crate) fn adaptation_len(self) -> Option { (self.0[3] & 0x20 != 0).then(|| usize::from(self.0[4])) } + + /// Whether the adaptation field carries a PCR (flag set and room for its 6 bytes). + pub(crate) fn has_pcr(self) -> bool { + self.adaptation_len().is_some_and(|len| len >= 7 && self.0[5] & ADAPTATION_FIELD_FLAG_PCR != 0) + } + + pub(crate) fn random_access(self) -> bool { + self.adaptation_len().is_some_and(|len| len >= 1 && self.0[5] & 0x40 != 0) + } + + pub(crate) fn payload(self) -> &'a [u8] { + if self.0[3] & 0x10 == 0 { + return &[]; + } + let start = self.adaptation_len().map_or(4, |len| 5 + len); + self.0.get(start..).unwrap_or(&[]) + } + + pub(crate) fn payload_offset(self) -> Option { + (self.0[3] & 0x10 != 0) + .then(|| self.adaptation_len().map_or(4, |len| 5 + len)) + .filter(|start| *start < TS_PACKET_SIZE) + } +} + +/// Bounds of the PSI section that starts in `payload`, when it lies completely inside it. +pub(crate) fn single_packet_section(payload: &[u8]) -> Option> { + let pointer = usize::from(*payload.first()?); + let start = 1 + pointer; + let header = payload.get(start..start + 3)?; + let length = 3 + ((usize::from(header[1] & 0x0F) << 8) | usize::from(header[2])); + (length >= 12 && start + length <= payload.len()).then_some(start..start + length) +} + +/// `version_number` of a long-form section. +pub(crate) fn section_version(section: &[u8]) -> u8 { (section[5] >> 1) & 0x1F } + +/// Sets `version_number` of a long-form section and recomputes its CRC. +pub(crate) fn rewrite_section_version(section: &mut [u8], version: u8) { + section[5] = (section[5] & 0xC1) | ((version & 0x1F) << 1); + let crc_at = section.len() - 4; + let crc = mpeg2_crc32(§ion[..crc_at]); + section[crc_at..].copy_from_slice(&crc.to_be_bytes()); +} + +/// Most elementary-stream bytes of a PES searched for a keyframe. +pub(crate) const KEYFRAME_SCAN_BYTES: usize = 512; + +/// Whether the PES payload starting in a packet begins an intra-coded picture, judged from the +/// NAL units (H.264, HEVC) or sequence header (MPEG-2 video) in this packet's elementary data. +#[must_use] +pub fn is_idr_start(stream_type: u8, pes_payload: &[u8]) -> bool { + pes_elementary_data(pes_payload).is_some_and(|data| es_has_keyframe(stream_type, data)) +} + +/// The elementary data after the PES header in the payload of a PES start packet. +pub(crate) fn pes_elementary_data(pes_payload: &[u8]) -> Option<&[u8]> { + if pes_payload.len() < 9 || pes_payload[..3] != [0, 0, 1] { + return None; + } + pes_payload.get(9 + usize::from(pes_payload[8])..) +} + +/// Whether `data`, the start of a PES's elementary data, opens an intra-coded picture: an IDR +/// slice or the SPS that opens an IDR access unit (H.264), IDR/CRA pictures or the VPS/SPS that +/// open them (HEVC), a sequence header (MPEG-2). +pub(crate) fn es_has_keyframe(stream_type: u8, data: &[u8]) -> bool { + let data = &data[..data.len().min(KEYFRAME_SCAN_BYTES)]; + let mut index = 0; + while index + 3 < data.len() { + if data[index..index + 3] == [0, 0, 1] { + let unit = data[index + 3]; + let keyframe = match stream_type { + 0x1B => matches!(unit & 0x1F, 5 | 7), + 0x24 => matches!((unit >> 1) & 0x3F, 19..=21 | 32 | 33), + 0x01 | 0x02 => unit == 0xB3, + _ => false, + }; + if keyframe { + return true; + } + index += 3; + } else { + index += 1; + } + } + false +} + +pub(crate) fn is_video_stream_type(stream_type: u8) -> bool { matches!(stream_type, 0x01 | 0x02 | 0x1B | 0x24) } + +#[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); + } + + fn with_sync_at(len: usize, positions: &[usize]) -> Vec { + let mut buf = vec![0u8; len]; + for position in positions { + buf[*position] = 0x47; + } + buf + } + + #[test] + fn find_sync_without_partial_tail_needs_every_confirmation() { + let buf = with_sync_at(300, &[0, 188]); + assert_eq!(find_sync(&buf, 0..=187, 3, false), None); + assert_eq!(find_sync(&buf, 0..=187, 3, true), Some(0)); + } + + #[test] + fn find_sync_searches_the_given_offsets() { + let buf = with_sync_at(400 + 3 * TS_PACKET_SIZE, &[400, 588, 776]); + assert_eq!(find_sync(&buf, 0..=500, 3, false), Some(400)); + assert_eq!(find_sync(&buf, 0..=399, 3, false), None); + } + + #[test] + fn packet_view_reads_pid_and_pcr_flag() { + let mut packet = vec![0x47, 0x41, 0x00, 0x30, 7, 0x10]; + packet.resize(TS_PACKET_SIZE, 0); + assert_eq!(ts_packet_pid(&packet), 0x100); + assert!(TsPacket(&packet).has_pcr()); + packet[4] = 6; + assert!(!TsPacket(&packet).has_pcr(), "a PCR needs 7 adaptation bytes"); + } +} diff --git a/backend/mpegts/src/splice/aligner.rs b/backend/mpegts/src/splice/aligner.rs new file mode 100644 index 000000000..05d181f66 --- /dev/null +++ b/backend/mpegts/src/splice/aligner.rs @@ -0,0 +1,429 @@ +use crate::packet::{ + es_has_keyframe, is_idr_start, is_video_stream_type, pes_elementary_data, pid_from_bytes, rewrite_section_version, + section_version, single_packet_section, ts_sync_offset, TsPacket, KEYFRAME_SCAN_BYTES, TS_PACKET_SIZE, + TS_SYNC_BYTE, +}; +use bytes::{Buf, Bytes, BytesMut}; +use std::{ + collections::HashMap, + time::{Duration, Instant}, +}; + +const PAT_PID: u16 = 0; +const PAT_TABLE_ID: u8 = 0x00; +const PMT_TABLE_ID: u8 = 0x02; + +/// A change to an emitted packet, applied after the chunk was observed. +enum PacketEdit { + Discontinuity { offset: usize }, + Version { payload: std::ops::Range, version: u8 }, +} + +pub enum SpliceOutput { + /// Nothing to send yet. + Hold, + /// Whole TS packets to send. + Emit(Bytes), + /// Whole TS packets in two pieces, sent in order: the packet completed from carried-over + /// bytes and the aligned rest of the chunk, which is passed on without copying. + EmitTwo(Bytes, Bytes), +} + +impl SpliceOutput { + fn of(first: Option, second: Option) -> Self { + match (first.filter(|bytes| !bytes.is_empty()), second.filter(|bytes| !bytes.is_empty())) { + (Some(first), Some(second)) => Self::EmitTwo(first, second), + (Some(single), None) | (None, Some(single)) => Self::Emit(single), + (None, None) => Self::Hold, + } + } +} + +/// Set of 13-bit PIDs. +struct PidSet(Box<[u64; 128]>); + +impl PidSet { + fn new() -> Self { Self(Box::new([0; 128])) } + + fn clear(&mut self) { self.0.fill(0); } + + /// Adds `pid`; true when it was not in the set. + fn insert(&mut self, pid: u16) -> bool { + let (word, bit) = (usize::from(pid >> 6) & 127, 1u64 << (pid & 63)); + let added = self.0[word] & bit == 0; + self.0[word] |= bit; + added + } +} + +/// Program structure of the member currently read, learned from its PAT and PMT. +#[derive(Default)] +struct Programs { + /// PMT PID → program number. + pmt_pids: HashMap, + /// First video elementary stream: PID and stream type. + video: Option<(u16, u8)>, + /// CRC of the PAT the program map was built from. + pat_crc: Option, +} + +/// Version rewrite of one member: per table, the offset added to the source version. +#[derive(Default)] +struct VersionOffsets { + pat: Option, + pmt: HashMap, +} + +struct Aligning { + started: Instant, + buffer: BytesMut, + first_pat: Option, + last_pat: Option, + pmt_after_last_pat: bool, + /// Video PES whose start packet showed no keyframe yet: the PAT before its start and the + /// elementary data read so far, searched on as its next packets arrive. + open_pes: Option<(usize, Vec)>, +} + +/// Aligns provider bytes to whole TS packets and splices a new member into the output: after +/// `begin_member` the new member is held back until it can start at a PAT/PMT followed by a +/// keyframe, its PSI versions continue the ones already emitted, and the first packet per PID +/// announces the discontinuity. +pub struct TsSpliceAligner { + carry: BytesMut, + budget_bytes: usize, + budget: Duration, + programs: Programs, + aligning: Option, + /// Number of members started; the first one is never rewritten. + members: u32, + offsets: VersionOffsets, + emitted_pat_version: Option, + emitted_pmt_versions: HashMap, + discontinuity_seen: PidSet, +} + +impl TsSpliceAligner { + #[must_use] + pub fn new(budget_bytes: usize, budget: Duration) -> Self { + Self { + carry: BytesMut::new(), + budget_bytes, + budget, + programs: Programs::default(), + aligning: None, + members: 1, + offsets: VersionOffsets::default(), + emitted_pat_version: None, + emitted_pmt_versions: HashMap::new(), + discontinuity_seen: PidSet::new(), + } + } + + /// Starts the next member: its bytes are held until the splice point. + pub fn begin_member(&mut self, now: Instant) { + self.carry.clear(); + self.programs = Programs::default(); + self.offsets = VersionOffsets::default(); + self.discontinuity_seen.clear(); + self.members += 1; + self.aligning = Some(Aligning { + started: now, + buffer: BytesMut::new(), + first_pat: None, + last_pat: None, + pmt_after_last_pat: false, + open_pes: None, + }); + } + + #[must_use] + pub fn is_aligning(&self) -> bool { self.aligning.is_some() } + + pub fn push(&mut self, chunk: Bytes, now: Instant) -> SpliceOutput { + let (head, body) = self.whole_packets(chunk); + let head = head.and_then(|head| self.emit_packets(head, now)); + let body = self.emit_packets(body, now); + SpliceOutput::of(head, body) + } + + fn emit_packets(&mut self, packets: Bytes, now: Instant) -> Option { + if self.aligning.is_some() { + return self.push_aligning(&packets, now); + } + (!packets.is_empty()).then(|| self.process_emitted(packets)) + } + + /// Whole packets of `chunk` plus what was carried over: the packet completed from the carry + /// (copied, at most 187 bytes from the chunk) and the aligned rest of the chunk (not copied). + fn whole_packets(&mut self, mut chunk: Bytes) -> (Option, Bytes) { + let mut head = None; + if !self.carry.is_empty() { + if self.carry[0] != TS_SYNC_BYTE { + return (None, self.resync(&chunk)); + } + let need = TS_PACKET_SIZE - self.carry.len(); + if chunk.len() < need { + self.carry.extend_from_slice(&chunk); + return (None, Bytes::new()); + } + self.carry.extend_from_slice(&chunk[..need]); + chunk.advance(need); + head = Some(self.carry.split().freeze()); + } + if chunk.first().is_some_and(|byte| *byte != TS_SYNC_BYTE) { + return (head, self.resync(&chunk)); + } + let whole = chunk.len() / TS_PACKET_SIZE * TS_PACKET_SIZE; + self.carry.extend_from_slice(&chunk[whole..]); + chunk.truncate(whole); + (head, chunk) + } + + /// Copying path for input without packet sync at its start: finds the sync and returns the + /// whole packets after it. + fn resync(&mut self, chunk: &[u8]) -> Bytes { + self.carry.extend_from_slice(chunk); + if self.carry.first() != Some(&TS_SYNC_BYTE) { + let Some(offset) = ts_sync_offset(&self.carry) else { + let keep = self.carry.len().min(TS_PACKET_SIZE - 1); + let drop = self.carry.len() - keep; + self.carry.advance(drop); + return Bytes::new(); + }; + self.carry.advance(offset); + } + let whole = self.carry.len() / TS_PACKET_SIZE * TS_PACKET_SIZE; + self.carry.split_to(whole).freeze() + } + + fn push_aligning(&mut self, packets: &[u8], now: Instant) -> Option { + let mut aligning = self.aligning.take()?; + let mut start = None; + for packet in packets.as_chunks::().0 { + let position = aligning.buffer.len(); + aligning.buffer.extend_from_slice(packet); + if start.is_some() { + continue; + } + let view = TsPacket(packet); + if view.pid() == PAT_PID && view.payload_unit_start() { + aligning.first_pat.get_or_insert(position); + aligning.last_pat = Some(position); + aligning.pmt_after_last_pat = false; + } + if self.observe(view) && aligning.last_pat.is_some() { + aligning.pmt_after_last_pat = true; + if self.programs.video.is_none() { + start = aligning.last_pat; + continue; + } + } + if !aligning.pmt_after_last_pat { + continue; + } + if self.is_keyframe(view) { + start = aligning.last_pat; + } else if let Some(pat) = self.continue_open_pes(&mut aligning, view) { + start = Some(pat); + } + } + let over_budget = + aligning.buffer.len() > self.budget_bytes || now.saturating_duration_since(aligning.started) >= self.budget; + let start = match start { + Some(start) => start, + None if over_budget => aligning.first_pat.unwrap_or(0), + None => { + self.aligning = Some(aligning); + return None; + } + }; + // Dropping the skipped head keeps the buffer the only owner, so the PSI rewrite below + // edits it in place instead of copying it. + let mut buffer = aligning.buffer; + buffer.advance(start); + let output = buffer.freeze(); + // The emitted part is observed again from its PAT on for versions and discontinuity. + self.programs = Programs::default(); + Some(self.process_emitted(output)) + } + + /// Follows the elementary data of a video PES across its packets; returns the PAT before the + /// PES start once a keyframe shows up within its first bytes. + fn continue_open_pes(&self, aligning: &mut Aligning, view: TsPacket<'_>) -> Option { + let (pid, stream_type) = self.programs.video?; + if view.pid() != pid { + return None; + } + if view.payload_unit_start() { + // A new PES without keyframe in its start packet: its next packets may hold it. + aligning.open_pes = aligning.last_pat.zip(pes_elementary_data(view.payload()).map(<[u8]>::to_vec)); + return None; + } + let (pat, data) = aligning.open_pes.as_mut()?; + let room = KEYFRAME_SCAN_BYTES.saturating_sub(data.len()); + let payload = view.payload(); + data.extend_from_slice(&payload[..payload.len().min(room)]); + if es_has_keyframe(stream_type, data) { + let pat = *pat; + aligning.open_pes = None; + return Some(pat); + } + if data.len() >= KEYFRAME_SCAN_BYTES { + aligning.open_pes = None; + } + None + } + + fn is_keyframe(&self, view: TsPacket<'_>) -> bool { + let Some((pid, stream_type)) = self.programs.video else { + return false; + }; + view.pid() == pid + && view.payload_unit_start() + && (view.random_access() || is_idr_start(stream_type, view.payload())) + } + + /// Learns PAT/PMT structure from `view`; returns true when it was a PMT of a known program. + fn observe(&mut self, view: TsPacket<'_>) -> bool { + if !view.payload_unit_start() { + return false; + } + let pid = view.pid(); + let is_pmt = self.programs.pmt_pids.contains_key(&pid); + if pid != PAT_PID && !is_pmt { + return false; + } + let payload = view.payload(); + let Some(range) = single_packet_section(payload) else { + return false; + }; + let section = &payload[range]; + let entries = §ion[8..section.len() - 4]; + if pid == PAT_PID && section[0] == PAT_TABLE_ID { + // A repeated PAT (several per second) leaves the program map as it is. + let crc = u32::from_be_bytes([ + section[section.len() - 4], + section[section.len() - 3], + section[section.len() - 2], + section[section.len() - 1], + ]); + if self.programs.pat_crc == Some(crc) { + return false; + } + self.programs.pat_crc = Some(crc); + self.programs.pmt_pids = entries + .as_chunks::<4>() + .0 + .iter() + .filter_map(|entry| { + let program = u16::from_be_bytes([entry[0], entry[1]]); + let pmt_pid = pid_from_bytes(entry[2], entry[3]); + (program != 0).then_some((pmt_pid, program)) + }) + .collect(); + return false; + } + if is_pmt && section[0] == PMT_TABLE_ID && entries.len() >= 4 { + let info_len = (usize::from(entries[2] & 0x0F) << 8) | usize::from(entries[3]); + let mut streams = entries.get(4 + info_len..).unwrap_or(&[]); + self.programs.video = None; + while streams.len() >= 5 { + let stream_type = streams[0]; + let es_pid = pid_from_bytes(streams[1], streams[2]); + let es_info_len = (usize::from(streams[3] & 0x0F) << 8) | usize::from(streams[4]); + if self.programs.video.is_none() && is_video_stream_type(stream_type) { + self.programs.video = Some((es_pid, stream_type)); + } + streams = streams.get(5 + es_info_len..).unwrap_or(&[]); + } + return true; + } + false + } + + /// Observes packets about to be emitted and applies the version rewrite and discontinuity + /// flags of a member after the first. Copies only when a packet changes. + fn process_emitted(&mut self, packets: Bytes) -> Bytes { + let rewrite = self.members > 1; + let mut edits: Vec = Vec::new(); + for (index, packet) in packets.as_chunks::().0.iter().enumerate() { + let view = TsPacket(packet); + self.observe(view); + let offset = index * TS_PACKET_SIZE; + let pid = view.pid(); + if rewrite && self.discontinuity_seen.insert(pid) && view.adaptation_len().is_some_and(|len| len >= 1) { + edits.push(PacketEdit::Discontinuity { offset }); + } + if let Some(version) = self.emitted_version(view, rewrite) { + if let Some(payload_offset) = view.payload_offset() { + edits.push(PacketEdit::Version { + payload: offset + payload_offset..offset + TS_PACKET_SIZE, + version, + }); + } + } + } + if edits.is_empty() { + return packets; + } + // In place when the chunk is ours alone, a copy only when it is shared. + let mut buffer = packets.try_into_mut().unwrap_or_else(|shared| BytesMut::from(&shared[..])); + for edit in edits { + match edit { + PacketEdit::Discontinuity { offset } => buffer[offset + 5] |= 0x80, + PacketEdit::Version { payload, version } => { + let payload = &mut buffer[payload]; + if let Some(range) = single_packet_section(payload) { + rewrite_section_version(&mut payload[range], version); + } + } + } + } + buffer.freeze() + } + + /// Records the version a PSI section in `view` is emitted with; returns it when it differs from + /// the source version and the packet has to be rewritten. + fn emitted_version(&mut self, view: TsPacket<'_>, rewrite: bool) -> Option { + if !view.payload_unit_start() { + return None; + } + let pid = view.pid(); + let program = if pid == PAT_PID { None } else { Some(*self.programs.pmt_pids.get(&pid)?) }; + let payload = view.payload(); + let range = single_packet_section(payload)?; + let section = &payload[range]; + let expected_table = if program.is_some() { PMT_TABLE_ID } else { PAT_TABLE_ID }; + if section[0] != expected_table { + return None; + } + let source = section_version(section); + let (offset, last_emitted) = match program { + None => (&mut self.offsets.pat, &mut self.emitted_pat_version), + Some(program) => { + let last = self.emitted_pmt_versions.get(&program).copied(); + let offset = self.offsets.pmt.entry(program).or_insert_with(|| { + if rewrite { + last.map_or(0, |last| (last + 1 + 32 - source) % 32) + } else { + 0 + } + }); + let version = (source + *offset) % 32; + self.emitted_pmt_versions.insert(program, version); + return (version != source).then_some(version); + } + }; + let offset = *offset.get_or_insert_with(|| { + if rewrite { + last_emitted.map_or(0, |last| (last + 1 + 32 - source) % 32) + } else { + 0 + } + }); + let version = (source + offset) % 32; + *last_emitted = Some(version); + (version != source).then_some(version) + } +} diff --git a/backend/mpegts/src/splice/mod.rs b/backend/mpegts/src/splice/mod.rs new file mode 100644 index 000000000..dad8b6c53 --- /dev/null +++ b/backend/mpegts/src/splice/mod.rs @@ -0,0 +1,12 @@ +//! Splicing of MPEG-TS from several sources into one continuous output. + +mod aligner; +mod realtime; + +pub use aligner::{SpliceOutput, TsSpliceAligner}; +pub use realtime::RealtimeMeter; + +#[cfg(test)] +mod realtime_tests; +#[cfg(test)] +mod tests; diff --git a/backend/mpegts/src/splice/realtime.rs b/backend/mpegts/src/splice/realtime.rs new file mode 100644 index 000000000..73ea71d59 --- /dev/null +++ b/backend/mpegts/src/splice/realtime.rs @@ -0,0 +1,105 @@ +use crate::{ + packet::{TsPacket, TS_PACKET_SIZE}, + transport_stream_buffer::decode_pcr, +}; +use std::time::{Duration, Instant}; + +const PCR_HZ: u64 = 27_000_000; +/// PCR wraps at 2^33 * 300. +const PCR_MODULUS: u64 = (1 << 33) * 300; +const BUCKET: Duration = Duration::from_secs(1); +/// A bucket in which the consumer waited less than this never counts as an underrun: the +/// provider delivered faster than the consumer read. +const MIN_BUCKET_WAIT: Duration = Duration::from_millis(100); + +/// Detects a provider that delivers media slower than real time, from the PCR advance against the +/// time the consumer waited for data. +pub struct RealtimeMeter { + ratio_pct: u8, + window: Duration, + pcr_pid: Option, + last_pcr: Option, + bucket_start: Option, + bucket_media: Duration, + bucket_waited: Duration, + streak_start: Option, +} + +impl RealtimeMeter { + #[must_use] + pub fn new(ratio_pct: u8, window: Duration) -> Self { + Self { + ratio_pct, + window, + pcr_pid: None, + last_pcr: None, + bucket_start: None, + bucket_media: Duration::ZERO, + bucket_waited: Duration::ZERO, + streak_start: None, + } + } + + /// `packets` are whole TS packets just delivered, `waited` the time the consumer waited for + /// them. Returns true once media arrived slower than `ratio_pct` of real time for `window`. + pub fn observe(&mut self, packets: &[u8], waited: Duration, now: Instant) -> bool { + if self.ratio_pct == 0 { + return false; + } + for packet in packets.as_chunks::().0 { + self.observe_pcr(TsPacket(packet), now); + } + if self.pcr_pid.is_none() { + return false; + } + self.bucket_waited += waited; + let bucket_start = *self.bucket_start.get_or_insert(now); + if now.saturating_duration_since(bucket_start) >= BUCKET { + let slow = self.bucket_waited >= MIN_BUCKET_WAIT + && self.bucket_media.as_micros() * 100 < u128::from(self.ratio_pct) * self.bucket_waited.as_micros(); + if slow { + self.streak_start.get_or_insert(bucket_start); + } else { + self.streak_start = None; + } + self.bucket_start = Some(now); + self.bucket_media = Duration::ZERO; + self.bucket_waited = Duration::ZERO; + } + self.streak_start.is_some_and(|start| now.saturating_duration_since(start) >= self.window) + } + + /// Starts measuring a new member: its PCR may run on another PID than the previous one's. + pub fn begin_member(&mut self, now: Instant) { + self.pcr_pid = None; + self.reset(now); + } + + pub fn reset(&mut self, now: Instant) { + self.last_pcr = None; + self.bucket_start = Some(now); + self.bucket_media = Duration::ZERO; + self.bucket_waited = Duration::ZERO; + self.streak_start = None; + } + + fn observe_pcr(&mut self, packet: TsPacket<'_>, now: Instant) { + if !packet.has_pcr() { + return; + } + let pid = packet.pid(); + if *self.pcr_pid.get_or_insert(pid) != pid { + return; + } + let pcr = decode_pcr(&packet.0[6..12]); + let Some(last) = self.last_pcr.replace(pcr) else { return }; + let delta = (pcr + PCR_MODULUS - last) % PCR_MODULUS; + if delta > PCR_HZ { + // A jump forward or backwards is a new timeline, not delivery speed. + self.reset(now); + self.last_pcr = Some(pcr); + return; + } + self.bucket_media += Duration::from_nanos(delta * 1_000 / 27); + } +} diff --git a/backend/mpegts/src/splice/realtime_tests.rs b/backend/mpegts/src/splice/realtime_tests.rs new file mode 100644 index 000000000..793c5f0bf --- /dev/null +++ b/backend/mpegts/src/splice/realtime_tests.rs @@ -0,0 +1,115 @@ +#![allow(clippy::cast_possible_truncation, reason = "test builders write PCR fields into single bytes")] + +use super::RealtimeMeter; +use crate::packet::TS_PACKET_SIZE; +use std::time::{Duration, Instant}; + +const PCR_PID: u16 = 0x100; +const STEP: Duration = Duration::from_millis(100); + +/// One packet on `pid` with a PCR of `pcr_27mhz` in its adaptation field. +fn pcr_packet(pid: u16, pcr_27mhz: u64) -> Vec { + let base = pcr_27mhz / 300; + let ext = pcr_27mhz % 300; + let mut packet = vec![0x47, (pid >> 8) as u8 & 0x1F, pid as u8, 0x20, 183, 0x10]; + packet.extend([ + (base >> 25) as u8, + (base >> 17) as u8, + (base >> 9) as u8, + (base >> 1) as u8, + (((base & 1) << 7) as u8) | 0x7E | ((ext >> 8) as u8), + ext as u8, + ]); + packet.resize(TS_PACKET_SIZE, 0xFF); + packet +} + +fn plain_packet(pid: u16) -> Vec { + let mut packet = vec![0x47, (pid >> 8) as u8 & 0x1F, pid as u8, 0x10]; + packet.resize(TS_PACKET_SIZE, 0); + packet +} + +/// Feeds `steps` steps of 100 ms wall time, each advancing the PCR by `media` and reporting +/// `waited`; returns the first step that reported an underrun. +fn run( + meter: &mut RealtimeMeter, + start: Instant, + pcr: &mut u64, + steps: u32, + media: Duration, + waited: Duration, +) -> Option { + let mut detected = None; + for step in 0..steps { + *pcr += u64::try_from(media.as_micros()).expect("micros") * 27; + let now = start + STEP * step; + if meter.observe(&pcr_packet(PCR_PID, *pcr), waited, now) && detected.is_none() { + detected = Some(step); + } + } + detected +} + +#[test] +fn realtime_meter_detects_slow_provider() { + let mut meter = RealtimeMeter::new(90, Duration::from_secs(15)); + let start = Instant::now(); + let mut pcr = 27_000_000; + let detected = run(&mut meter, start, &mut pcr, 200, Duration::from_millis(70), STEP).expect("underrun detected"); + assert!((150..=170).contains(&detected), "detected at step {detected}"); +} + +#[test] +fn realtime_meter_ignores_slow_consumer() { + let mut meter = RealtimeMeter::new(90, Duration::from_secs(15)); + let mut pcr = 27_000_000; + assert_eq!(run(&mut meter, Instant::now(), &mut pcr, 300, STEP, Duration::from_millis(5)), None); +} + +#[test] +fn realtime_meter_inactive_without_pcr() { + let mut meter = RealtimeMeter::new(90, Duration::from_secs(2)); + let start = Instant::now(); + for step in 0..100 { + assert!(!meter.observe(&plain_packet(PCR_PID), STEP, start + STEP * step)); + } +} + +#[test] +fn realtime_meter_resets_on_pcr_jump() { + let mut meter = RealtimeMeter::new(90, Duration::from_secs(15)); + let start = Instant::now(); + let mut pcr = 27_000_000; + assert_eq!(run(&mut meter, start, &mut pcr, 100, Duration::from_millis(70), STEP), None); + pcr += 27_000_000 * 5; + let resumed = start + STEP * 100; + assert_eq!(run(&mut meter, resumed, &mut pcr, 100, Duration::from_millis(70), STEP), None); +} + +#[test] +fn realtime_meter_disabled_with_zero_ratio() { + let mut meter = RealtimeMeter::new(0, Duration::from_secs(1)); + let mut pcr = 27_000_000; + assert_eq!(run(&mut meter, Instant::now(), &mut pcr, 100, Duration::from_millis(10), STEP), None); +} + +#[test] +fn realtime_meter_follows_the_pcr_pid_of_a_new_member() { + let mut meter = RealtimeMeter::new(90, Duration::from_secs(5)); + let start = Instant::now(); + let mut pcr = 27_000_000; + assert_eq!(run(&mut meter, start, &mut pcr, 20, STEP, STEP), None); + // The next member carries its PCR on another PID and plays in real time. + meter.begin_member(start + STEP * 20); + let mut detected = None; + let mut member_pcr = 54_000_000_u64; + for step in 20..120 { + member_pcr += 27_000 * 100; + if meter.observe(&pcr_packet(0x200, member_pcr), STEP, start + STEP * step) { + detected = Some(step); + break; + } + } + assert_eq!(detected, None, "a real-time member is no underrun"); +} diff --git a/backend/mpegts/src/splice/tests.rs b/backend/mpegts/src/splice/tests.rs new file mode 100644 index 000000000..a407bbe41 --- /dev/null +++ b/backend/mpegts/src/splice/tests.rs @@ -0,0 +1,415 @@ +#![allow(clippy::cast_possible_truncation, reason = "test builders write PIDs and lengths into single bytes")] + +use super::{SpliceOutput, TsSpliceAligner}; +use crate::packet::{is_idr_start, mpeg2_crc32, ts_null_packet_bytes, TS_NULL_PACKET, TS_PACKET_SIZE}; +use bytes::Bytes; +use std::time::{Duration, Instant}; + +const PMT_PID: u16 = 0x1000; +const VIDEO_PID: u16 = 0x100; +const AUDIO_PID: u16 = 0x101; +const H264: u8 = 0x1B; +const HEVC: u8 = 0x24; +const AAC: u8 = 0x0F; + +/// One TS packet. With `af_flags` the adaptation field fills the room the payload leaves; +/// without, the payload is padded with 0xFF to 184 bytes. +fn ts(pid: u16, pusi: bool, af_flags: Option, payload: &[u8]) -> Vec { + let mut packet = vec![0x47, (if pusi { 0x40 } else { 0 }) | ((pid >> 8) as u8 & 0x1F), pid as u8]; + if let Some(flags) = af_flags { + let af_len = 184 - 1 - payload.len(); + assert!(af_len >= 1); + packet.push(0x30); + packet.push(af_len as u8); + packet.push(flags); + packet.extend(std::iter::repeat_n(0xFF, af_len - 1)); + packet.extend_from_slice(payload); + } else { + packet.push(0x10); + packet.extend_from_slice(payload); + packet.resize(TS_PACKET_SIZE, 0xFF); + } + assert_eq!(packet.len(), TS_PACKET_SIZE); + packet +} + +fn finish_section(mut section: Vec) -> Vec { + let length = section.len() - 3 + 4; + section[1] = 0xB0 | ((length >> 8) as u8 & 0x0F); + section[2] = length as u8; + let crc = mpeg2_crc32(§ion); + section.extend_from_slice(&crc.to_be_bytes()); + section +} + +fn pat_section(version: u8, program: u16, pmt_pid: u16) -> Vec { + let section = vec![ + 0x00, + 0, + 0, + 0x00, + 0x01, + 0xC1 | (version << 1), + 0x00, + 0x00, + (program >> 8) as u8, + program as u8, + 0xE0 | (pmt_pid >> 8) as u8, + pmt_pid as u8, + ]; + finish_section(section) +} + +fn pmt_section(version: u8, program: u16, streams: &[(u8, u16)]) -> Vec { + let pcr_pid = streams.first().map_or(0x1FFF, |(_, pid)| *pid); + let mut section = vec![ + 0x02, + 0, + 0, + (program >> 8) as u8, + program as u8, + 0xC1 | (version << 1), + 0x00, + 0x00, + 0xE0 | (pcr_pid >> 8) as u8, + pcr_pid as u8, + 0xF0, + 0x00, + ]; + for (stream_type, pid) in streams { + section.extend([*stream_type, 0xE0 | (pid >> 8) as u8, *pid as u8, 0xF0, 0x00]); + } + finish_section(section) +} + +fn psi(pid: u16, section: &[u8]) -> Vec { + let mut payload = vec![0x00]; + payload.extend_from_slice(section); + ts(pid, true, None, &payload) +} + +fn pat(version: u8) -> Vec { psi(0, &pat_section(version, 1, PMT_PID)) } + +fn pmt(version: u8, streams: &[(u8, u16)]) -> Vec { psi(PMT_PID, &pmt_section(version, 1, streams)) } + +fn video_pmt(version: u8) -> Vec { pmt(version, &[(H264, VIDEO_PID), (AAC, AUDIO_PID)]) } + +/// PES start on `pid` whose elementary data holds NAL units of the given header bytes. +fn pes(pid: u16, rai: bool, af: bool, nal_headers: &[&[u8]]) -> Vec { + let mut payload = vec![0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x80, 0x05, 0x21, 0x00, 0x01, 0x00, 0x01]; + for header in nal_headers { + payload.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]); + payload.extend_from_slice(header); + payload.extend_from_slice(&[0x88, 0x84]); + } + let flags = if rai { + Some(0x40) + } else if af { + Some(0x00) + } else { + None + }; + ts(pid, true, flags, &payload) +} + +fn h264(nal_type: u8) -> Vec { vec![0x60 | nal_type] } + +fn hevc(nal_type: u8) -> Vec { vec![nal_type << 1, 0x01] } + +fn video_cont(af: bool) -> Vec { ts(VIDEO_PID, false, af.then_some(0x00), &[0x11; 100]) } + +fn join(packets: &[Vec]) -> Bytes { Bytes::from(packets.concat()) } + +fn emitted(output: SpliceOutput) -> Bytes { + match output { + SpliceOutput::Emit(bytes) => bytes, + SpliceOutput::EmitTwo(first, second) => Bytes::from([first, second].concat()), + SpliceOutput::Hold => panic!("expected emitted packets"), + } +} + +fn packets(bytes: &[u8]) -> Vec<&[u8]> { + assert_eq!(bytes.len() % TS_PACKET_SIZE, 0, "output is whole packets"); + bytes.chunks(TS_PACKET_SIZE).collect() +} + +fn pid_of(packet: &[u8]) -> u16 { (u16::from(packet[1] & 0x1F) << 8) | u16::from(packet[2]) } + +/// Version of the single-packet section in `packet`, after checking its CRC. +fn section_version(packet: &[u8]) -> u8 { + let payload_start = if packet[3] & 0x20 != 0 { 5 + usize::from(packet[4]) } else { 4 }; + let section = &packet[payload_start + 1 + usize::from(packet[payload_start])..]; + let length = 3 + ((usize::from(section[1] & 0x0F) << 8) | usize::from(section[2])); + assert_eq!(mpeg2_crc32(§ion[..length]), 0, "section CRC verifies"); + (section[5] >> 1) & 0x1F +} + +fn discontinuity(packet: &[u8]) -> bool { packet[3] & 0x20 != 0 && packet[4] >= 1 && packet[5] & 0x80 != 0 } + +fn aligner() -> TsSpliceAligner { TsSpliceAligner::new(1024 * 1024, Duration::from_secs(5)) } + +/// An aligner that already streamed a first member with PAT/PMT `version` and is switching. +fn switched_aligner(version: u8, now: Instant) -> TsSpliceAligner { + let mut aligner = aligner(); + emitted(aligner.push(join(&[pat(version), video_pmt(version), pes(VIDEO_PID, true, false, &[&h264(5)])]), now)); + aligner.begin_member(now); + aligner +} + +#[test] +fn null_packet_is_valid_ts() { + assert_eq!(TS_NULL_PACKET.len(), TS_PACKET_SIZE); + assert_eq!(TS_NULL_PACKET[0], 0x47); + assert_eq!(pid_of(&TS_NULL_PACKET), 0x1FFF); + assert_eq!(ts_null_packet_bytes().as_ptr(), TS_NULL_PACKET.as_ptr()); +} + +#[test] +fn crc32_matches_known_pat() { + let section = [0x00, 0xB0, 0x0D, 0x00, 0x01, 0xC1, 0x00, 0x00, 0x00, 0x01, 0xF0, 0x00]; + assert_eq!(mpeg2_crc32(§ion), 0x2AB1_04B2); +} + +#[test] +fn passthrough_emits_aligned_chunk_zero_copy() { + let mut aligner = aligner(); + let chunk = join(&[pat(0), video_pmt(0), video_cont(false)]); + let out = emitted(aligner.push(chunk.clone(), Instant::now())); + assert_eq!(out.as_ptr(), chunk.as_ptr()); + assert_eq!(out, chunk); +} + +#[test] +fn realigns_unaligned_input_to_188() { + let mut aligner = aligner(); + let stream = join(&[pat(0), video_pmt(0), video_cont(false)]); + let now = Instant::now(); + let first = emitted(aligner.push(stream.slice(..200), now)); + assert_eq!(&first[..], &stream[..188]); + assert!(matches!(aligner.push(stream.slice(200..300), now), SpliceOutput::Hold)); + let rest = emitted(aligner.push(stream.slice(300..), now)); + assert_eq!(&rest[..], &stream[188..]); +} + +#[test] +fn drops_garbage_before_sync() { + let mut aligner = aligner(); + let packets_in = [pat(0), video_pmt(0), video_cont(false), video_cont(false)].concat(); + let mut input = vec![0x00; 17]; + input.extend_from_slice(&packets_in); + let out = emitted(aligner.push(Bytes::from(input), Instant::now())); + assert_eq!(&out[..], &packets_in[..]); +} + +#[test] +fn waits_for_keyframe_with_rai() { + let now = Instant::now(); + let mut aligner = switched_aligner(0, now); + assert!(matches!(aligner.push(join(&[pat(0), video_pmt(0), video_cont(false)]), now), SpliceOutput::Hold)); + let out = emitted(aligner.push(join(&[pes(VIDEO_PID, true, false, &[&h264(1)])]), now)); + let out = packets(&out); + assert_eq!(out.len(), 4); + assert_eq!((pid_of(out[0]), pid_of(out[1])), (0, PMT_PID)); +} + +#[test] +fn detects_h264_idr_without_rai() { + assert!(is_idr_start(H264, &pes(VIDEO_PID, false, false, &[&h264(9), &h264(7), &h264(8), &h264(5)])[4..])); + let now = Instant::now(); + let mut aligner = switched_aligner(0, now); + let out = aligner.push(join(&[pat(0), video_pmt(0), pes(VIDEO_PID, false, false, &[&h264(7), &h264(5)])]), now); + assert_eq!(packets(&emitted(out)).len(), 3); +} + +#[test] +fn detects_hevc_idr_without_rai() { + for nal_type in [19, 20, 21] { + assert!(is_idr_start(HEVC, &pes(VIDEO_PID, false, false, &[&hevc(32), &hevc(nal_type)])[4..])); + } + let now = Instant::now(); + let mut aligner = switched_aligner(0, now); + let pmt = pmt(0, &[(HEVC, VIDEO_PID)]); + let out = aligner.push(join(&[pat(0), pmt, pes(VIDEO_PID, false, false, &[&hevc(19)])]), now); + assert_eq!(packets(&emitted(out)).len(), 3); +} + +#[test] +fn ignores_non_idr_slices() { + assert!(!is_idr_start(H264, &pes(VIDEO_PID, false, false, &[&h264(9), &h264(1)])[4..])); + assert!(!is_idr_start(HEVC, &pes(VIDEO_PID, false, false, &[&hevc(1)])[4..])); + let now = Instant::now(); + let mut aligner = switched_aligner(0, now); + let out = aligner.push(join(&[pat(0), video_pmt(0), pes(VIDEO_PID, false, false, &[&h264(1)])]), now); + assert!(matches!(out, SpliceOutput::Hold)); +} + +#[test] +fn emits_from_pat_before_keyframe() { + let now = Instant::now(); + let mut aligner = switched_aligner(0, now); + let input = [ + video_cont(false), + pat(0), + video_pmt(0), + video_cont(false), + pat(0), + video_pmt(0), + pes(VIDEO_PID, true, false, &[&h264(5)]), + video_cont(false), + ]; + let out = emitted(aligner.push(join(&input), now)); + let out = packets(&out); + assert_eq!(out.len(), 4); + assert_eq!(pid_of(out[0]), 0); + assert_eq!(pid_of(out[1]), PMT_PID); + assert_eq!(pid_of(out[2]), VIDEO_PID); +} + +#[test] +fn audio_only_member_starts_at_pmt() { + let now = Instant::now(); + let mut aligner = switched_aligner(0, now); + let audio_pmt = pmt(0, &[(AAC, AUDIO_PID)]); + let out = aligner.push(join(&[ts(AUDIO_PID, false, None, &[1; 10]), pat(0), audio_pmt]), now); + let out = emitted(out); + let out = packets(&out); + assert_eq!(out.len(), 2); + assert_eq!(pid_of(out[0]), 0); +} + +#[test] +fn falls_back_after_byte_budget_from_first_pat() { + let now = Instant::now(); + let mut aligner = TsSpliceAligner::new(5 * TS_PACKET_SIZE, Duration::from_secs(5)); + emitted(aligner.push(join(&[pat(0), video_pmt(0)]), now)); + aligner.begin_member(now); + let input = [video_cont(false), pat(0), video_pmt(0), video_cont(false), video_cont(false), video_cont(false)]; + let out = emitted(aligner.push(join(&input), now)); + let out = packets(&out); + assert_eq!(out.len(), 5); + assert_eq!(pid_of(out[0]), 0); +} + +#[test] +fn falls_back_after_time_budget() { + let now = Instant::now(); + let mut aligner = switched_aligner(0, now); + assert!(matches!(aligner.push(join(&[video_cont(false), pat(0)]), now), SpliceOutput::Hold)); + let later = now + Duration::from_secs(6); + let out = emitted(aligner.push(join(&[video_cont(false)]), later)); + let out = packets(&out); + assert_eq!(out.len(), 2); + assert_eq!(pid_of(out[0]), 0); +} + +#[test] +fn sets_discontinuity_only_with_existing_adaptation_field() { + let now = Instant::now(); + let mut aligner = switched_aligner(0, now); + let input = [ + pat(0), + video_pmt(0), + pes(VIDEO_PID, true, false, &[&h264(5)]), + ts(AUDIO_PID, true, None, &[1; 20]), + ts(AUDIO_PID, false, Some(0x00), &[1; 20]), + video_cont(true), + ]; + let out = emitted(aligner.push(join(&input), now)); + let out = packets(&out); + assert!(discontinuity(out[2]), "first video packet has an adaptation field"); + assert!(!discontinuity(out[3]), "first audio packet has none"); + assert!(!discontinuity(out[4]), "audio was already seen"); + assert!(!discontinuity(out[5]), "video was already seen"); +} + +#[test] +fn second_member_pat_and_pmt_get_new_version_and_valid_crc() { + let now = Instant::now(); + let mut aligner = switched_aligner(0, now); + let out = emitted(aligner.push(join(&[pat(0), video_pmt(0), pes(VIDEO_PID, true, false, &[&h264(5)])]), now)); + let out = packets(&out); + assert_eq!(section_version(out[0]), 1); + assert_eq!(section_version(out[1]), 1); + let later = emitted(aligner.push(join(&[pat(0), video_pmt(0)]), now)); + let later = packets(&later); + assert_eq!((section_version(later[0]), section_version(later[1])), (1, 1)); +} + +#[test] +fn member_version_change_is_preserved_with_offset() { + let now = Instant::now(); + let mut aligner = switched_aligner(4, now); + emitted(aligner.push(join(&[pat(31), video_pmt(31), pes(VIDEO_PID, true, false, &[&h264(5)])]), now)); + let out = emitted(aligner.push(join(&[pat(0), video_pmt(0)]), now)); + let out = packets(&out); + assert_eq!((section_version(out[0]), section_version(out[1])), (6, 6)); +} + +#[test] +fn first_member_is_not_rewritten() { + let mut aligner = aligner(); + let input = join(&[pat(7), video_pmt(9), pes(VIDEO_PID, true, false, &[&h264(5)])]); + let out = emitted(aligner.push(input.clone(), Instant::now())); + assert_eq!(out, input); +} + +#[test] +fn multi_packet_section_passes_unchanged() { + let now = Instant::now(); + let mut aligner = switched_aligner(0, now); + let mut long_pmt = video_pmt(0); + long_pmt[7] = 0xBF; // section_length claims more bytes than the packet holds + emitted(aligner.push(join(&[pat(0), video_pmt(0), pes(VIDEO_PID, true, false, &[&h264(5)])]), now)); + let out = emitted(aligner.push(Bytes::from(long_pmt.clone()), now)); + assert_eq!(&out[..], &long_pmt[..]); +} + +#[test] +fn passthrough_splits_unaligned_chunk_without_copying_body() { + let mut aligner = aligner(); + let stream = join(&[pat(0), video_pmt(0), video_cont(false), video_cont(false), video_cont(false)]); + let now = Instant::now(); + let first = emitted(aligner.push(stream.slice(..300), now)); + assert_eq!(&first[..], &stream[..188]); + let second = stream.slice(300..); + let SpliceOutput::EmitTwo(head, body) = aligner.push(second.clone(), now) else { + panic!("expected the completed packet and the aligned rest") + }; + assert_eq!(&head[..], &stream[188..376]); + assert_eq!(&body[..], &stream[376..]); + assert_eq!(body.as_ptr(), second[76..].as_ptr(), "the aligned rest is not copied"); +} + +#[test] +fn h264_sps_counts_as_keyframe_start() { + assert!(is_idr_start(H264, &pes(VIDEO_PID, false, false, &[&h264(9), &h264(7)])[4..])); + assert!(is_idr_start(HEVC, &pes(VIDEO_PID, false, false, &[&hevc(32)])[4..])); +} + +#[test] +fn detects_idr_in_second_packet_of_pes() { + let now = Instant::now(); + let mut aligner = switched_aligner(0, now); + // AUD and a large SEI fill the PES start packet; the IDR slice follows in the next packet. + let sei = [vec![0x06], vec![0x05; 150]].concat(); + let pes_start = pes(VIDEO_PID, false, false, &[&h264(9), &sei]); + let idr_continuation = ts(VIDEO_PID, false, None, &[0x00, 0x00, 0x00, 0x01, 0x65, 0x88, 0x84]); + let input = [pat(0), video_pmt(0), pes_start, idr_continuation, video_cont(false)]; + let out = emitted(aligner.push(join(&input), now)); + let out = packets(&out); + assert_eq!(out.len(), 5, "the splice starts at the PAT before the PES start"); + assert_eq!(pid_of(out[0]), 0); +} + +#[test] +fn psi_rewrite_of_unique_chunk_does_not_copy() { + let now = Instant::now(); + let mut aligner = switched_aligner(0, now); + emitted(aligner.push(join(&[pat(0), video_pmt(0), pes(VIDEO_PID, true, false, &[&h264(5)])]), now)); + let chunk = join(&[pat(0), video_pmt(0), video_cont(false)]); + let input_ptr = chunk.as_ptr(); + let out = emitted(aligner.push(chunk, now)); + assert_eq!(out.as_ptr(), input_ptr, "the versions are rewritten in place"); + let out = packets(&out); + assert_eq!((section_version(out[0]), section_version(out[1])), (1, 1)); +} diff --git a/backend/mpegts/src/transport_stream_buffer.rs b/backend/mpegts/src/transport_stream_buffer.rs index 05f3adfdf..b65fc6e10 100644 --- a/backend/mpegts/src/transport_stream_buffer.rs +++ b/backend/mpegts/src/transport_stream_buffer.rs @@ -109,6 +109,7 @@ mod tests; mod buffer; mod clock; +pub(crate) use clock::decode_pcr; mod layout; mod pes; mod profile; diff --git a/backend/mpegts/src/transport_stream_buffer/buffer.rs b/backend/mpegts/src/transport_stream_buffer/buffer.rs index 9be7b754a..6fe132e78 100644 --- a/backend/mpegts/src/transport_stream_buffer/buffer.rs +++ b/backend/mpegts/src/transport_stream_buffer/buffer.rs @@ -1,12 +1,10 @@ use super::{ - clock::{ - add_pts_dts_offset, decode_pcr_at_location, ticks_90khz_to_rounded_millis, ts_chunk_packet_count, - TS_PACKET_SIZE, - }, + clock::{add_pts_dts_offset, decode_pcr_at_location, ticks_90khz_to_rounded_millis, ts_chunk_packet_count}, layout::{build_hls_finite_ts_layout, find_ts_alignment, finite_hls_presentation_duration}, profile::timestamp_profile_from_finite_layout, HlsTsTimestampProfile, TransportStreamBuffer, }; +use crate::packet::TS_PACKET_SIZE; use bytes::{Bytes, BytesMut}; use futures::task::AtomicWaker; use sha2::{Digest, Sha256}; @@ -200,10 +198,10 @@ impl TransportStreamBuffer { let packet_layout = self.finite_hls_layout.as_ref().ok().and_then(|layout| layout.packets.get(current_pos)).copied(); let packet_has_payload = packet_layout - .map_or_else(|| matches!((packet[3] >> 4) & 0b11, 0b01 | 0b11), |layout| layout.has_payload); + .map_or_else(|| crate::packet::TsPacket(packet).has_payload(), |layout| layout.has_payload); // O(1) PID lookup — PID is at most 13 bits (0–8191). - let pid = (u16::from(packet[1] & 0x1F) << 8) | u16::from(packet[2]); + let pid = crate::packet::ts_packet_pid(packet); let entry = &mut self.cc_entries[pid as usize]; // Normalize payload continuity per PID to a clean local sequence. let (counter, discontinuity_sent) = entry.get_or_insert((0, false)); diff --git a/backend/mpegts/src/transport_stream_buffer/clock.rs b/backend/mpegts/src/transport_stream_buffer/clock.rs index 5d795da2d..0758ab222 100644 --- a/backend/mpegts/src/transport_stream_buffer/clock.rs +++ b/backend/mpegts/src/transport_stream_buffer/clock.rs @@ -36,9 +36,7 @@ pub(super) fn add_pcr_offset_27mhz(timestamp_27mhz: u64, offset_27mhz: u64) -> u ((u128::from(timestamp_27mhz) + u128::from(offset_27mhz)) % u128::from(MAX_PCR)) as u64 } -pub(super) const TS_PACKET_SIZE: usize = 188; - -pub(super) const SYNC_BYTE: u8 = 0x47; +use crate::packet::{TS_PACKET_SIZE, TS_SYNC_BYTE}; pub(super) const PACKET_COUNT: usize = 7; @@ -58,11 +56,6 @@ pub(super) fn ts_chunk_packet_count() -> usize { *PACKET_COUNT_OVERRIDE } -pub(super) const ADAPTATION_FIELD_FLAG_PCR: u8 = 0x10; - -// PCR flag bit in adaptation field flags -pub(super) const NULL_PID: u16 = 0x1FFF; - pub(super) const HLS_TS_MAX_PENDING_PES_HEADERS: usize = 64; pub(super) const HLS_TS_TIMESTAMP_HEADER_BYTES: usize = 19; @@ -97,7 +90,7 @@ pub(super) fn encode_timestamp(ts: u64) -> [u8; 5] { /// Decode PCR from 6 bytes (adaptation field) into 42-bit PCR base + 9-bit extension as u64 #[inline] -pub(super) fn decode_pcr(pcr_bytes: &[u8]) -> u64 { +pub(crate) fn decode_pcr(pcr_bytes: &[u8]) -> u64 { let pcr_base = (u64::from(pcr_bytes[0]) << 25) | ((u64::from(pcr_bytes[1])) << 17) | ((u64::from(pcr_bytes[2])) << 9) @@ -113,21 +106,18 @@ pub(super) enum HlsTsTimestampKind { Pcr, } -#[inline] -pub(super) fn ts_packet_pid(packet: &[u8]) -> u16 { (u16::from(packet[1] & 0x1F) << 8) | u16::from(packet[2]) } - pub(super) fn same_finite_ts_packet_layout(source: &[u8], prepared: &[u8]) -> bool { if source.len() != TS_PACKET_SIZE || prepared.len() != TS_PACKET_SIZE - || source[0] != SYNC_BYTE - || prepared[0] != SYNC_BYTE + || source[0] != TS_SYNC_BYTE + || prepared[0] != TS_SYNC_BYTE || source[1] & 0x7F != prepared[1] & 0x7F || source[2] != prepared[2] || source[3] & 0xF0 != prepared[3] & 0xF0 { return false; } - let adaptation_field_control = (source[3] >> 4) & 0b11; + let adaptation_field_control = crate::packet::TsPacket(source).adaptation_field_control(); !matches!(adaptation_field_control, 0b10 | 0b11) || source[4] == prepared[4] } @@ -137,7 +127,7 @@ pub(super) fn append_finite_discontinuity_packet(following_packet: &[u8], output let start = output.len(); output.resize(start.saturating_add(TS_PACKET_SIZE), 0xFF); let marker = &mut output[start..start + TS_PACKET_SIZE]; - marker[0] = SYNC_BYTE; + marker[0] = TS_SYNC_BYTE; marker[1] = following_packet[1] & 0x1F; marker[2] = following_packet[2]; // FFmpeg retains this adaptation-only marker as the per-PID CC baseline. diff --git a/backend/mpegts/src/transport_stream_buffer/layout.rs b/backend/mpegts/src/transport_stream_buffer/layout.rs index 5a2bff05a..f8a8cf3a9 100644 --- a/backend/mpegts/src/transport_stream_buffer/layout.rs +++ b/backend/mpegts/src/transport_stream_buffer/layout.rs @@ -1,28 +1,17 @@ use super::{ - clock::{decode_pcr_at_location, decode_timestamp_at_location, MAX_PTS_DTS, SYNC_BYTE, TS_PACKET_SIZE}, + clock::{decode_pcr_at_location, decode_timestamp_at_location, MAX_PTS_DTS}, pes::{inspect_hls_ts_packet, HlsFiniteTsLayoutError}, HlsFiniteTsLayout, HlsFiniteTsPacketLayout, HlsPesHeaderAssembler, HlsTsTimestampFieldKind, }; +use crate::packet::TS_PACKET_SIZE; use std::{ collections::{HashMap, HashSet}, sync::Arc, }; -/// Finds TS alignment by checking for 0x47 sync byte every 188 bytes +/// Finds TS alignment by checking for 0x47 sync byte every 188 bytes, five packets in a row. pub(super) fn find_ts_alignment(buf: &[u8]) -> Option { - for offset in 0..TS_PACKET_SIZE { - let mut valid = true; - for i in 0..5 { - if buf.get(offset + i * TS_PACKET_SIZE) != Some(&SYNC_BYTE) { - valid = false; - break; - } - } - if valid { - return Some(offset); - } - } - None + crate::packet::find_sync(buf, 0..TS_PACKET_SIZE, 5, false) } pub(super) fn build_hls_finite_ts_layout(buffer: &[u8]) -> Result { diff --git a/backend/mpegts/src/transport_stream_buffer/pes.rs b/backend/mpegts/src/transport_stream_buffer/pes.rs index 804563eb7..736aef82f 100644 --- a/backend/mpegts/src/transport_stream_buffer/pes.rs +++ b/backend/mpegts/src/transport_stream_buffer/pes.rs @@ -1,11 +1,9 @@ use super::{ - clock::{ - ts_packet_pid, ADAPTATION_FIELD_FLAG_PCR, HLS_TS_MAX_PENDING_PES_HEADERS, HLS_TS_TIMESTAMP_HEADER_BYTES, - PES_DTS_OFFSET, PES_PTS_OFFSET, SYNC_BYTE, TS_PACKET_SIZE, - }, + clock::{HLS_TS_MAX_PENDING_PES_HEADERS, HLS_TS_TIMESTAMP_HEADER_BYTES, PES_DTS_OFFSET, PES_PTS_OFFSET}, HlsCompletedPesTimestamps, HlsCompletedTimestampField, HlsPendingPesHeader, HlsPesHeaderAssembler, HlsTsPacketEvidence, }; +use crate::packet::{ts_packet_pid, ADAPTATION_FIELD_FLAG_PCR, TS_PACKET_SIZE, TS_SYNC_BYTE}; use std::collections::HashMap; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -77,11 +75,11 @@ pub(super) fn inspect_hls_ts_packet( packet: &[u8], packet_start: usize, ) -> Result { - if packet.len() != TS_PACKET_SIZE || packet[0] != SYNC_BYTE || packet[1] & 0x80 != 0 || packet[3] & 0xC0 != 0 { + if packet.len() != TS_PACKET_SIZE || packet[0] != TS_SYNC_BYTE || packet[1] & 0x80 != 0 || packet[3] & 0xC0 != 0 { return Err(HlsFiniteTsLayoutError::InvalidTransportPacket); } let pid = ts_packet_pid(packet); - let adaptation_field_control = (packet[3] >> 4) & 0b11; + let adaptation_field_control = crate::packet::TsPacket(packet).adaptation_field_control(); let continuity_counter = packet[3] & 0x0F; let (payload_offset, adaptation_length) = match adaptation_field_control { 0b01 => (Some(4), None), diff --git a/backend/mpegts/src/transport_stream_buffer/profile.rs b/backend/mpegts/src/transport_stream_buffer/profile.rs index 7d8466e90..dbfe03d55 100644 --- a/backend/mpegts/src/transport_stream_buffer/profile.rs +++ b/backend/mpegts/src/transport_stream_buffer/profile.rs @@ -2,11 +2,12 @@ use super::{ clock::{ add_pts_dts_offset, decode_pcr, decode_pcr_at_location, decode_timestamp, decode_timestamp_at_location, forward_clock_distance_90khz, HlsTsTimestampKind, HLS_TS_PROFILE_MIN_TOLERANCE_TICKS_90KHZ, - HLS_TS_SPLICE_MIN_GAP_TICKS_90KHZ, MAX_PTS_DTS, TS_PACKET_SIZE, + HLS_TS_SPLICE_MIN_GAP_TICKS_90KHZ, MAX_PTS_DTS, }, pes::inspect_hls_ts_packet, HlsFiniteTsLayout, HlsPesHeaderAssembler, HlsTsTimestampProfileAccumulator, HlsTsTimestampProfileScanner, }; +use crate::packet::TS_PACKET_SIZE; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct HlsTsTimestampProfile { diff --git a/backend/mpegts/src/transport_stream_buffer/render.rs b/backend/mpegts/src/transport_stream_buffer/render.rs index 0046f0f23..66745274b 100644 --- a/backend/mpegts/src/transport_stream_buffer/render.rs +++ b/backend/mpegts/src/transport_stream_buffer/render.rs @@ -2,11 +2,12 @@ use super::{ clock::{ add_pcr_offset_27mhz, add_pts_dts_offset, append_finite_discontinuity_packet, decode_pcr_at_location, decode_timestamp, encode_pcr, encode_timestamp, gather_timestamp_bytes, pcr_offset_27mhz, - same_finite_ts_packet_layout, ts_packet_pid, ADAPTATION_FIELD_FLAG_PCR, NULL_PID, SYNC_BYTE, TS_PACKET_SIZE, + same_finite_ts_packet_layout, }, pes::HlsFiniteTsLayoutError, HlsFiniteTsLayout, HlsFiniteTsPacketLayout, TransportStreamBuffer, }; +use crate::packet::{ts_packet_pid, NULL_PID, TS_PACKET_SIZE, TS_SYNC_BYTE}; use bytes::{Bytes, BytesMut}; use std::task::Waker; @@ -179,20 +180,12 @@ impl TransportStreamBuffer { out.resize(start + TS_PACKET_SIZE, 0xFF); let pkt = &mut out[start..start + TS_PACKET_SIZE]; - pkt[0] = SYNC_BYTE; + pkt[0] = TS_SYNC_BYTE; pkt[1] = new_packet[1] & 0x1F; pkt[2] = new_packet[2]; // Check if the current packet has a PCR (need at least 7 adaptation bytes: 1 flag + 6 PCR). - let new_pkt_has_pcr = { - let afc = (new_packet[3] >> 4) & 0b11; - if afc == 2 || afc == 3 { - let adaptation_len = new_packet[4] as usize; - adaptation_len >= 7 && (new_packet[5] & ADAPTATION_FIELD_FLAG_PCR) != 0 - } else { - false - } - }; + let new_pkt_has_pcr = crate::packet::TsPacket(new_packet).has_pcr(); // AFC=2 (Adaptation Only), Scrambling=00 (Unscrambled), CC=cc pkt[3] = 0x20 | (cc & 0x0F); diff --git a/backend/mpegts/src/transport_stream_buffer/tests.rs b/backend/mpegts/src/transport_stream_buffer/tests.rs index fac39525b..80a285ec5 100644 --- a/backend/mpegts/src/transport_stream_buffer/tests.rs +++ b/backend/mpegts/src/transport_stream_buffer/tests.rs @@ -2,18 +2,18 @@ use super::{ clock::{ append_finite_discontinuity_packet, decode_pcr, decode_pcr_at_location, decode_timestamp, decode_timestamp_at_location, encode_pcr, encode_timestamp, forward_clock_distance_90khz, - gather_timestamp_bytes, pcr_offset_27mhz, ts_packet_pid, ADAPTATION_FIELD_FLAG_PCR, - HLS_TS_SPLICE_MIN_GAP_TICKS_90KHZ, MAX_PTS_DTS, NULL_PID, SYNC_BYTE, TS_PACKET_SIZE, + gather_timestamp_bytes, pcr_offset_27mhz, HLS_TS_SPLICE_MIN_GAP_TICKS_90KHZ, MAX_PTS_DTS, }, layout::build_hls_finite_ts_layout, pes::inspect_hls_ts_packet, *, }; +use crate::packet::{ts_packet_pid, ADAPTATION_FIELD_FLAG_PCR, NULL_PID, TS_PACKET_SIZE, TS_SYNC_BYTE}; use bytes::BytesMut; fn build_pts_dts_payload_packet(pid: u16, cc: u8, pts: u64, dts: u64) -> [u8; TS_PACKET_SIZE] { let mut packet = [0xFF_u8; TS_PACKET_SIZE]; - packet[0] = SYNC_BYTE; + packet[0] = TS_SYNC_BYTE; packet[1] = 0x40 | ((pid >> 8) as u8 & 0x1F); // PUSI + PID high bits packet[2] = (pid & 0xFF) as u8; packet[3] = 0x10 | (cc & 0x0F); // payload only @@ -42,7 +42,7 @@ fn build_pts_dts_payload_packet(pid: u16, cc: u8, pts: u64, dts: u64) -> [u8; TS fn build_pts_only_payload_packet(pid: u16, cc: u8, pts: u64) -> [u8; TS_PACKET_SIZE] { let mut packet = [0xFF_u8; TS_PACKET_SIZE]; - packet[0] = SYNC_BYTE; + packet[0] = TS_SYNC_BYTE; packet[1] = 0x40 | ((pid >> 8) as u8 & 0x1F); packet[2] = (pid & 0xFF) as u8; packet[3] = 0x10 | (cc & 0x0F); @@ -86,7 +86,7 @@ fn build_split_pes_header_packets( assert!((1..=184).contains(&payload_length)); let mut packet = [0xFF_u8; TS_PACKET_SIZE]; let packet_index = u8::try_from(packet_index).expect("test packet index fits"); - packet[0] = SYNC_BYTE; + packet[0] = TS_SYNC_BYTE; packet[1] = ((pid >> 8) as u8 & 0x1F) | (u8::from(packet_index == 0) * 0x40); packet[2] = (pid & 0xFF) as u8; packet[3] = if payload_length == 184 { @@ -114,7 +114,7 @@ fn build_split_pes_header_packets( fn build_pts_dts_pcr_packet(pid: u16, cc: u8, pts: u64, dts: u64, pcr_90khz: u64) -> [u8; TS_PACKET_SIZE] { let mut packet = [0xFF_u8; TS_PACKET_SIZE]; - packet[0] = SYNC_BYTE; + packet[0] = TS_SYNC_BYTE; packet[1] = 0x40 | ((pid >> 8) as u8 & 0x1F); packet[2] = (pid & 0xFF) as u8; packet[3] = 0x30 | (cc & 0x0F); @@ -170,7 +170,7 @@ fn packet_timestamps(packet: &[u8]) -> (u64, u64, u64) { fn build_adaptation_only_packet(pid: u16, cc: u8) -> [u8; TS_PACKET_SIZE] { let mut packet = [0xFF_u8; TS_PACKET_SIZE]; - packet[0] = SYNC_BYTE; + packet[0] = TS_SYNC_BYTE; packet[1] = (pid >> 8) as u8 & 0x1F; packet[2] = (pid & 0xFF) as u8; packet[3] = 0x20 | (cc & 0x0F); // adaptation only @@ -188,7 +188,7 @@ fn build_pcr_only_packet(pid: u16, cc: u8, pcr_90khz: u64) -> [u8; TS_PACKET_SIZ fn build_payload_packet(pid: u16, cc: u8) -> [u8; TS_PACKET_SIZE] { let mut packet = [0xFF_u8; TS_PACKET_SIZE]; - packet[0] = SYNC_BYTE; + packet[0] = TS_SYNC_BYTE; packet[1] = (pid >> 8) as u8 & 0x1F; packet[2] = (pid & 0xFF) as u8; packet[3] = 0x10 | (cc & 0x0F); diff --git a/backend/mpegts/src/ts_inspector.rs b/backend/mpegts/src/ts_inspector.rs index af3e13bed..b24bed1d1 100644 --- a/backend/mpegts/src/ts_inspector.rs +++ b/backend/mpegts/src/ts_inspector.rs @@ -2,7 +2,7 @@ use crate::transport_stream_buffer::HlsTsTimestampProfileScanner; use aes::Aes128; use mpeg2ts_reader::{ demultiplex::{self, FilterChangeset}, - packet::{Packet, Pid}, + packet::Pid, psi::{self, BufferSectionSyntaxParser, SectionPacketConsumer, SectionProcessor, SectionSyntaxSectionProcessor}, }; use std::{ @@ -13,7 +13,7 @@ use zeroize::Zeroizing; const AES_128_BLOCK_BYTES: usize = 16; -const TS_PACKET_BYTES: usize = Packet::SIZE; +use crate::packet::TS_PACKET_SIZE; const TS_ALIGNMENT_CONFIRMATION_PACKETS: usize = 2; @@ -23,7 +23,7 @@ const HLS_TS_PROBE_MAX_PACKETS: u64 = 8_192; const HLS_TS_PROBE_READ_CHUNK_BYTES: usize = 64 * 1024; -const HLS_TS_PROBE_MAX_RESYNC_BYTES: usize = TS_PACKET_BYTES * 4; +const HLS_TS_PROBE_MAX_RESYNC_BYTES: usize = TS_PACKET_SIZE * 4; const PSI_SYNTAX_HEADER_BYTES: usize = psi::SectionCommonHeader::SIZE + psi::TableSyntaxHeader::SIZE; diff --git a/backend/mpegts/src/ts_inspector/inspector.rs b/backend/mpegts/src/ts_inspector/inspector.rs index 4c12ced29..6e109be47 100644 --- a/backend/mpegts/src/ts_inspector/inspector.rs +++ b/backend/mpegts/src/ts_inspector/inspector.rs @@ -2,10 +2,10 @@ use super::{ protection::HlsTsSourceDecoder, HlsTsDemuxContext, HlsTsInspector, HlsTsMalformedReason, HlsTsMediaEvidence, HlsTsMediaStreamInspector, HlsTsProbeBudget, HlsTsProbeError, HlsTsProbeOutcome, HlsTsProbeProtection, HlsTsProtectionReason, HlsTsSpliceEvidence, HlsTsSpliceIncompatibility, HlsTsTrackSignature, - HlsTsTransportStreamInspector, AES_128_BLOCK_BYTES, TS_ALIGNMENT_CONFIRMATION_PACKETS, TS_PACKET_BYTES, + HlsTsTransportStreamInspector, AES_128_BLOCK_BYTES, TS_ALIGNMENT_CONFIRMATION_PACKETS, TS_PACKET_SIZE, }; use crate::transport_stream_buffer::{HlsTsTimestampProfile, HlsTsTimestampProfileScanner}; -use mpeg2ts_reader::{demultiplex, packet::Packet}; +use mpeg2ts_reader::demultiplex; use std::io::Read; use tokio::io::{AsyncRead, AsyncReadExt}; use zeroize::Zeroizing; @@ -22,7 +22,7 @@ impl HlsTsMediaStreamInspector { budget, timestamp_scanner: HlsTsTimestampProfileScanner::new(expected_duration_ticks_90khz), transport_scanner: HlsTsTransportStreamInspector::new(), - pending: Vec::with_capacity(budget.bounded_read_chunk_bytes().saturating_add(TS_PACKET_BYTES)), + pending: Vec::with_capacity(budget.bounded_read_chunk_bytes().saturating_add(TS_PACKET_SIZE)), aligned: false, packets_examined: 0, invalid: false, @@ -35,17 +35,14 @@ impl HlsTsMediaStreamInspector { } self.pending.extend_from_slice(bytes); if !self.aligned { - let confirmation_bytes = TS_PACKET_BYTES.saturating_mul(TS_ALIGNMENT_CONFIRMATION_PACKETS); + let confirmation_bytes = TS_PACKET_SIZE.saturating_mul(TS_ALIGNMENT_CONFIRMATION_PACKETS); if self.pending.len() < confirmation_bytes { return; } let available_search = self.pending.len().saturating_sub(confirmation_bytes); let search_end = available_search.min(self.budget.max_resync_bytes); - let alignment = (0..=search_end).find(|offset| { - (0..TS_ALIGNMENT_CONFIRMATION_PACKETS).all(|index| { - self.pending[offset.saturating_add(index.saturating_mul(TS_PACKET_BYTES))] == Packet::SYNC_BYTE - }) - }); + let alignment = + crate::packet::find_sync(&self.pending, 0..=search_end, TS_ALIGNMENT_CONFIRMATION_PACKETS, false); let Some(alignment) = alignment else { if available_search >= self.budget.max_resync_bytes { self.invalid = true; @@ -57,12 +54,12 @@ impl HlsTsMediaStreamInspector { } let mut consumed = 0usize; - while self.pending.len().saturating_sub(consumed) >= TS_PACKET_BYTES { + while self.pending.len().saturating_sub(consumed) >= TS_PACKET_SIZE { if self.packets_examined >= self.budget.max_packets { self.invalid = true; break; } - let packet_end = consumed.saturating_add(TS_PACKET_BYTES); + let packet_end = consumed.saturating_add(TS_PACKET_SIZE); let packet = &self.pending[consumed..packet_end]; self.timestamp_scanner.push_aligned_packet(packet); self.transport_scanner.push_packet(packet, self.packets_examined); @@ -122,7 +119,7 @@ impl HlsTsInspector { budget, demux, context, - pending: Vec::with_capacity(budget.bounded_read_chunk_bytes().saturating_add(TS_PACKET_BYTES)), + pending: Vec::with_capacity(budget.bounded_read_chunk_bytes().saturating_add(TS_PACKET_SIZE)), aligned: false, source_bytes_examined: 0, packets_examined: 0, @@ -156,17 +153,14 @@ impl HlsTsInspector { ) -> Option { self.pending.extend_from_slice(bytes); if !self.aligned { - let confirmation_bytes = TS_PACKET_BYTES.saturating_mul(TS_ALIGNMENT_CONFIRMATION_PACKETS); + let confirmation_bytes = TS_PACKET_SIZE.saturating_mul(TS_ALIGNMENT_CONFIRMATION_PACKETS); if self.pending.len() < confirmation_bytes { return None; } let available_search = self.pending.len().saturating_sub(confirmation_bytes); let search_end = available_search.min(self.budget.max_resync_bytes); - let alignment = (0..=search_end).find(|offset| { - (0..TS_ALIGNMENT_CONFIRMATION_PACKETS).all(|index| { - self.pending[offset.saturating_add(index.saturating_mul(TS_PACKET_BYTES))] == Packet::SYNC_BYTE - }) - }); + let alignment = + crate::packet::find_sync(&self.pending, 0..=search_end, TS_ALIGNMENT_CONFIRMATION_PACKETS, false); let Some(alignment) = alignment else { if available_search >= self.budget.max_resync_bytes { return Some(HlsTsProbeOutcome::Malformed(HlsTsMalformedReason::InvalidSynchronization)); @@ -178,14 +172,14 @@ impl HlsTsInspector { } let mut consumed = 0usize; - while self.pending.len().saturating_sub(consumed) >= TS_PACKET_BYTES { + while self.pending.len().saturating_sub(consumed) >= TS_PACKET_SIZE { if self.packets_examined >= self.budget.max_packets { self.pending.drain(..consumed); return Some(self.budget_exhausted()); } - let mut packet_bytes = [0_u8; TS_PACKET_BYTES]; - packet_bytes.copy_from_slice(&self.pending[consumed..consumed.saturating_add(TS_PACKET_BYTES)]); - consumed = consumed.saturating_add(TS_PACKET_BYTES); + let mut packet_bytes = [0_u8; TS_PACKET_SIZE]; + packet_bytes.copy_from_slice(&self.pending[consumed..consumed.saturating_add(TS_PACKET_SIZE)]); + consumed = consumed.saturating_add(TS_PACKET_SIZE); self.packets_examined = self.packets_examined.saturating_add(1); if let Some(outcome) = self.validate_packet(&packet_bytes) { self.pending.drain(..consumed); @@ -207,8 +201,8 @@ impl HlsTsInspector { None } - pub(super) fn validate_packet(&self, bytes: &[u8; TS_PACKET_BYTES]) -> Option { - if bytes[0] != Packet::SYNC_BYTE { + pub(super) fn validate_packet(&self, bytes: &[u8; TS_PACKET_SIZE]) -> Option { + if bytes[0] != crate::packet::TS_SYNC_BYTE { return Some(HlsTsProbeOutcome::Malformed(HlsTsMalformedReason::InvalidSynchronization)); } if bytes[1] & 0x80 != 0 { @@ -217,17 +211,17 @@ impl HlsTsInspector { if bytes[3] & 0xC0 != 0 { return Some(HlsTsProbeOutcome::UnsupportedProtection(HlsTsProtectionReason::TransportScrambling)); } - let adaptation_control = (bytes[3] >> 4) & 0b11; + let adaptation_control = crate::packet::TsPacket(bytes).adaptation_field_control(); let payload_offset = match adaptation_control { 0b01 => 4, - 0b10 if bytes[4] == 183 => TS_PACKET_BYTES, + 0b10 if bytes[4] == 183 => TS_PACKET_SIZE, 0b11 if bytes[4] <= 182 => 5usize.saturating_add(usize::from(bytes[4])), 0b00 | 0b10 | 0b11 => { return Some(HlsTsProbeOutcome::Malformed(HlsTsMalformedReason::InvalidPacketHeader)); } _ => return Some(HlsTsProbeOutcome::Malformed(HlsTsMalformedReason::InvalidPacketHeader)), }; - let pid = (u16::from(bytes[1] & 0x1F) << 8) | u16::from(bytes[2]); + let pid = crate::packet::ts_packet_pid(bytes); if self.context.is_psi_pid(pid) && bytes[1] & 0x40 != 0 { let Some(payload) = bytes.get(payload_offset..) else { return Some(HlsTsProbeOutcome::Malformed(HlsTsMalformedReason::InvalidPsiPointer)); diff --git a/backend/mpegts/src/ts_inspector/sections.rs b/backend/mpegts/src/ts_inspector/sections.rs index e75c5507e..93eab07a8 100644 --- a/backend/mpegts/src/ts_inspector/sections.rs +++ b/backend/mpegts/src/ts_inspector/sections.rs @@ -1,7 +1,7 @@ use super::{ demux::HlsPatSectionParser, HlsPatPacketFilter, HlsPmtEvidence, HlsPmtPacketFilter, HlsPmtSectionEvidence, HlsPmtSectionParser, HlsPsiSectionConsumer, HlsTsDemuxContext, HlsTsMalformedReason, HlsTsProgramTopology, - PSI_SYNTAX_HEADER_BYTES, TS_PACKET_BYTES, + PSI_SYNTAX_HEADER_BYTES, TS_PACKET_SIZE, }; use mpeg2ts_reader::{ demultiplex, @@ -191,12 +191,12 @@ where } } -pub(super) fn synthetic_psi_packet(pid: Pid, starts_section: bool, payload: &[u8]) -> [u8; TS_PACKET_BYTES] { +pub(super) fn synthetic_psi_packet(pid: Pid, starts_section: bool, payload: &[u8]) -> [u8; TS_PACKET_SIZE] { let pointer_bytes = usize::from(starts_section); let payload_length = payload.len().saturating_add(pointer_bytes).min(184); let pid = u16::from(pid); - let mut packet = [0xFF_u8; TS_PACKET_BYTES]; - packet[0] = Packet::SYNC_BYTE; + let mut packet = [0xFF_u8; TS_PACKET_SIZE]; + packet[0] = crate::packet::TS_SYNC_BYTE; packet[1] = (u8::try_from(pid >> 8).unwrap_or(0) & 0x1F) | if starts_section { 0x40 } else { 0 }; packet[2] = pid.to_be_bytes()[1]; let payload_offset = if payload_length == 184 { @@ -217,7 +217,7 @@ pub(super) fn synthetic_psi_packet(pid: Pid, starts_section: bool, payload: &[u8 } else { payload_offset }; - let copy_length = payload.len().min(TS_PACKET_BYTES.saturating_sub(data_offset)); + let copy_length = payload.len().min(TS_PACKET_SIZE.saturating_sub(data_offset)); packet[data_offset..data_offset.saturating_add(copy_length)].copy_from_slice(&payload[..copy_length]); packet } diff --git a/backend/mpegts/src/ts_inspector/tests.rs b/backend/mpegts/src/ts_inspector/tests.rs index ced1f700a..bc4779e98 100644 --- a/backend/mpegts/src/ts_inspector/tests.rs +++ b/backend/mpegts/src/ts_inspector/tests.rs @@ -100,8 +100,8 @@ fn packetize_section(pid: u16, section: &[u8], first_counter: u8) -> Vec { let mut counter = first_counter & 0x0F; let mut first = true; while offset < section.len() { - let mut packet = [0xFF_u8; TS_PACKET_BYTES]; - packet[0] = Packet::SYNC_BYTE; + let mut packet = [0xFF_u8; TS_PACKET_SIZE]; + packet[0] = crate::packet::TS_SYNC_BYTE; packet[1] = u8::try_from(pid >> 8).unwrap_or(0) & 0x1F; packet[2] = u8::try_from(pid & 0xFF).unwrap_or(0); packet[3] = 0x10 | counter; @@ -112,7 +112,7 @@ fn packetize_section(pid: u16, section: &[u8], first_counter: u8) -> Vec { } else { 4 }; - let copied = section.len().saturating_sub(offset).min(TS_PACKET_BYTES.saturating_sub(payload_start)); + let copied = section.len().saturating_sub(offset).min(TS_PACKET_SIZE.saturating_sub(payload_start)); packet[payload_start..payload_start.saturating_add(copied)] .copy_from_slice(§ion[offset..offset.saturating_add(copied)]); output.extend_from_slice(&packet); @@ -133,10 +133,10 @@ fn packetize_section_with_split_syntax_header(pid: u16, section: &[u8], first_he output } -fn psi_start_packet_with_pointer(pid: u16, previous_section: &[u8], section_start: &[u8]) -> [u8; TS_PACKET_BYTES] { +fn psi_start_packet_with_pointer(pid: u16, previous_section: &[u8], section_start: &[u8]) -> [u8; TS_PACKET_SIZE] { let payload_length = 1usize.saturating_add(previous_section.len()).saturating_add(section_start.len()).min(184); - let mut packet = [0xFF_u8; TS_PACKET_BYTES]; - packet[0] = Packet::SYNC_BYTE; + let mut packet = [0xFF_u8; TS_PACKET_SIZE]; + packet[0] = crate::packet::TS_SYNC_BYTE; packet[1] = (u8::try_from(pid >> 8).unwrap_or(0) & 0x1F) | 0x40; packet[2] = u8::try_from(pid & 0xFF).unwrap_or(0); packet[3] = 0x30; @@ -155,9 +155,9 @@ fn psi_start_packet_with_pointer(pid: u16, previous_section: &[u8], section_star packet } -fn null_packet() -> [u8; TS_PACKET_BYTES] { - let mut packet = [0xFF_u8; TS_PACKET_BYTES]; - packet[0] = Packet::SYNC_BYTE; +fn null_packet() -> [u8; TS_PACKET_SIZE] { + let mut packet = [0xFF_u8; TS_PACKET_SIZE]; + packet[0] = crate::packet::TS_SYNC_BYTE; packet[1] = 0x1F; packet[2] = 0xFF; packet[3] = 0x10; @@ -181,10 +181,10 @@ fn media_payload_packet( payload_unit_start: bool, discontinuity: bool, payload: &[u8], -) -> [u8; TS_PACKET_BYTES] { +) -> [u8; TS_PACKET_SIZE] { assert!(!payload.is_empty() && payload.len() <= 182); - let mut packet = [0xFF_u8; TS_PACKET_BYTES]; - packet[0] = Packet::SYNC_BYTE; + let mut packet = [0xFF_u8; TS_PACKET_SIZE]; + packet[0] = crate::packet::TS_SYNC_BYTE; packet[1] = u8::try_from(pid >> 8).unwrap_or(0) & 0x1F; if payload_unit_start { packet[1] |= 0x40; @@ -201,9 +201,9 @@ fn media_payload_packet( packet } -fn media_adaptation_only_packet(pid: u16, continuity_counter: u8, discontinuity: bool) -> [u8; TS_PACKET_BYTES] { - let mut packet = [0xFF_u8; TS_PACKET_BYTES]; - packet[0] = Packet::SYNC_BYTE; +fn media_adaptation_only_packet(pid: u16, continuity_counter: u8, discontinuity: bool) -> [u8; TS_PACKET_SIZE] { + let mut packet = [0xFF_u8; TS_PACKET_SIZE]; + packet[0] = crate::packet::TS_SYNC_BYTE; packet[1] = u8::try_from(pid >> 8).unwrap_or(0) & 0x1F; packet[2] = u8::try_from(pid & 0xFF).unwrap_or(0); packet[3] = 0x20 | (continuity_counter & 0x0F); diff --git a/backend/mpegts/src/ts_inspector/tests/transport.rs b/backend/mpegts/src/ts_inspector/tests/transport.rs index a71e49227..ee0b76c62 100644 --- a/backend/mpegts/src/ts_inspector/tests/transport.rs +++ b/backend/mpegts/src/ts_inspector/tests/transport.rs @@ -424,10 +424,10 @@ fn ts_inspector_reports_probe_budget_without_reading_whole_source() { } let count = Rc::new(Cell::new(0)); let reader = CountingReader { inner: Cursor::new(bytes), bytes_read: Rc::clone(&count) }; - let budget = HlsTsProbeBudget { max_bytes: (TS_PACKET_BYTES * 4) as u64, ..HlsTsProbeBudget::default() }; + let budget = HlsTsProbeBudget { max_bytes: (TS_PACKET_SIZE * 4) as u64, ..HlsTsProbeBudget::default() }; let outcome = inspect_mpeg_ts(reader, HlsTsProbeProtection::Clear, budget).expect("probe succeeds"); assert!(matches!(outcome, HlsTsProbeOutcome::ProbeBudgetExhausted { .. })); - assert_eq!(count.get(), TS_PACKET_BYTES * 4); + assert_eq!(count.get(), TS_PACKET_SIZE * 4); } #[test] @@ -496,7 +496,7 @@ fn ts_inspector_resynchronizes_only_within_named_budget() { #[test] fn ts_inspector_types_framing_crc_and_pmt_errors() { - let invalid_sync = vec![0_u8; TS_PACKET_BYTES * 3]; + let invalid_sync = vec![0_u8; TS_PACKET_SIZE * 3]; assert_eq!( inspect_mpeg_ts(Cursor::new(invalid_sync), HlsTsProbeProtection::Clear, HlsTsProbeBudget::default()) .expect("probe completes"), diff --git a/backend/mpegts/src/ts_inspector/transport.rs b/backend/mpegts/src/ts_inspector/transport.rs index 605f9ffe9..49842dc53 100644 --- a/backend/mpegts/src/ts_inspector/transport.rs +++ b/backend/mpegts/src/ts_inspector/transport.rs @@ -1,6 +1,6 @@ use super::{ HlsTsCompatibleSpliceEvidence, HlsTsPidBoundaryEvidence, HlsTsPidContinuityState, HlsTsSpliceEvidence, - HlsTsSpliceIncompatibility, HlsTsTrackSignature, HlsTsTransportStreamInspector, TS_PACKET_BYTES, + HlsTsSpliceIncompatibility, HlsTsTrackSignature, HlsTsTransportStreamInspector, TS_PACKET_SIZE, }; use mpeg2ts_reader::packet::Packet; @@ -38,7 +38,7 @@ impl HlsTsTransportStreamInspector { if self.incompatible.is_some() { return; } - if bytes.len() != TS_PACKET_BYTES { + if bytes.len() != TS_PACKET_SIZE { self.incompatible = Some(HlsTsSpliceIncompatibility::InvalidPacket { packet_index }); return; } diff --git a/backend/processing/src/processor/channel_group/assemble.rs b/backend/processing/src/processor/channel_group/assemble.rs new file mode 100644 index 000000000..026c48302 --- /dev/null +++ b/backend/processing/src/processor/channel_group/assemble.rs @@ -0,0 +1,544 @@ +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, + MIN_GROUP_MEMBERS, + }, + 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 { + 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, + epg_split: bool, + name_override: Option, + logo_override: Option, + epg_override: Option, + playlist_group_override: Option, +} + +struct AutoBucket { + positions: Vec<(usize, usize)>, + has_dsl_forced: bool, + epg_split: bool, +} + +struct GroupSlot { + id: u32, + title: Arc, + xtream_cluster: XtreamCluster, + had_channels: bool, + channels: Vec>, +} + +fn build_member_index(playlist: &[PlaylistGroup]) -> HashMap { + 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, + taken_positions: &mut HashSet<(usize, usize)>, + candidates: &mut Vec, +) { + 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() < MIN_GROUP_MEMBERS { + continue; + } + + candidates.push(GroupCandidate { + key: mg.id.clone(), + origin: ChannelGroupOrigin::Manual, + policy: mg.policy.unwrap_or(config.policy), + 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 { + let normalizer = MatchKeyNormalizer::new(&config.match_normalize, config.match_as_ascii); + let mut auto_buckets: IndexMap = 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, +) -> IndexMap { + let mut split_buckets: IndexMap = IndexMap::new(); + for (key, bucket) in auto_buckets { + if bucket.has_dsl_forced { + split_buckets.insert(key, bucket); + continue; + } + + let mut epg_groups: IndexMap> = 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, + 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 = 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 { + 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() < MIN_GROUP_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), + 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, + deferred_placements: Vec<(String, PlaylistItem)>, + playlist: &mut Vec, +) { + for slot in slots { + let channels: Vec = 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, + playlist: &mut Vec, +) -> Vec { + 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, +) -> Vec { + if playlist.is_empty() { + return Vec::new(); + } + + let candidates = collect_group_candidates(config, overrides, playlist); + if candidates.is_empty() { + return Vec::new(); + } + + let slots: Vec = 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) +} diff --git a/backend/processing/src/processor/channel_group/mod.rs b/backend/processing/src/processor/channel_group/mod.rs new file mode 100644 index 000000000..c98f27afa --- /dev/null +++ b/backend/processing/src/processor/channel_group/mod.rs @@ -0,0 +1,5 @@ +mod assemble; +#[cfg(test)] +mod tests; + +pub(in crate::processor) use assemble::*; diff --git a/backend/processing/src/processor/channel_group/tests.rs b/backend/processing/src/processor/channel_group/tests.rs new file mode 100644 index 000000000..5bf7827fb --- /dev/null +++ b/backend/processing/src/processor/channel_group/tests.rs @@ -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) -> PlaylistGroup { + PlaylistGroup { id: 1, title: title.intern(), channels, xtream_cluster: XtreamCluster::Live } +} + +fn cfg() -> ChannelGroupsConfig { ChannelGroupsConfig::default() } + +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 { + 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 a single member 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_with_one_member_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![uuid_a, uuid_b]); + assert_eq!(playlist[0].channels[0].header.channel_group_uuid, Some(pending[0].group_uuid)); +} diff --git a/backend/processing/src/processor/deduplicate.rs b/backend/processing/src/processor/deduplicate.rs index 411dfd42d..59c48f414 100644 --- a/backend/processing/src/processor/deduplicate.rs +++ b/backend/processing/src/processor/deduplicate.rs @@ -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) -> 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, + skip: &HashSet, ) -> 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 = [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); } } diff --git a/backend/processing/src/processor/match_key.rs b/backend/processing/src/processor/match_key.rs new file mode 100644 index 000000000..8e2340668 --- /dev/null +++ b/backend/processing/src/processor/match_key.rs @@ -0,0 +1,195 @@ +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, + aliases: HashMap, + 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 { + 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() + }, + ..ChannelGroupsConfig::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"); + } +} diff --git a/backend/processing/src/processor/mod.rs b/backend/processing/src/processor/mod.rs index 114f7a975..6d1f60cdc 100644 --- a/backend/processing/src/processor/mod.rs +++ b/backend/processing/src/processor/mod.rs @@ -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; diff --git a/backend/processing/src/processor/playlist/mod.rs b/backend/processing/src/processor/playlist/mod.rs index 7f0891f5d..05ad0b53d 100644 --- a/backend/processing/src/processor/playlist/mod.rs +++ b/backend/processing/src/processor/playlist/mod.rs @@ -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; diff --git a/backend/processing/src/processor/playlist/target.rs b/backend/processing/src/processor/playlist/target.rs index 4e584f949..b07f404f0 100644 --- a/backend/processing/src/processor/playlist/target.rs +++ b/backend/processing/src/processor/playlist/target.rs @@ -414,13 +414,14 @@ fn finalization_step_label(view: &str, stage: &str) -> String { pub(crate) fn prepare_eligible_catalog( target: &ConfigTarget, playlist: Vec, + group_uuids: &HashSet, step: &mut StepMeasure, ) -> Vec { 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,29 @@ pub(crate) async fn finalize_prepared_target, + step: &mut StepMeasure, + errors: &mut Vec, +) -> Vec { + let Some(target_cfg) = target.execution_plan.channel_groups.as_ref() else { + return Vec::new(); + }; + let cg_cfg = config.config.load().channel_groups.for_target(target_cfg); + 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( ctx: Arc>, prepared: PreparedTarget, @@ -517,6 +541,10 @@ pub(super) async fn finalize_prepared_target_with_tmdb = 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 +608,7 @@ pub(super) async fn finalize_prepared_target_with_tmdb, pub(crate) xtream: Option>, pub(crate) publication_plan: PlaylistPublicationPlan, + pub(crate) channel_groups: Vec, } pub(crate) fn build_curated_playlist_views( @@ -926,6 +959,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 +974,7 @@ pub(crate) async fn prepare_target_playlist_views( base: playlist, xtream: None, publication_plan: PlaylistPublicationPlan::Ordinary, + channel_groups: Vec::new(), }; }; @@ -954,13 +989,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 +1213,24 @@ pub(crate) async fn process_watch( true } + +#[cfg(test)] +pub(crate) async fn run_finalize_until_persist( + target: &ConfigTarget, + mut playlist: Vec, +) -> (Vec, Vec) { + let mut step = StepMeasure::new("test", |_, _| {}); + let pending = if let Some(target_cfg) = target.execution_plan.channel_groups.as_ref() { + let cg_cfg = shared::model::ChannelGroupsConfig::default().for_target(target_cfg); + 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 = 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) +} diff --git a/backend/processing/src/processor/playlist/tests.rs b/backend/processing/src/processor/playlist/tests.rs index 5e97c9828..3c2452e3d 100644 --- a/backend/processing/src/processor/playlist/tests.rs +++ b/backend/processing/src/processor/playlist/tests.rs @@ -24,6 +24,8 @@ mod curation_effect_gate; mod watch_contexts; +mod channel_groups; + #[cfg(test)] mod quality_rejection_fallback; diff --git a/backend/processing/src/processor/playlist/tests/channel_groups.rs b/backend/processing/src/processor/playlist/tests/channel_groups.rs new file mode 100644 index 000000000..b15f2cb27 --- /dev/null +++ b/backend/processing/src/processor/playlist/tests/channel_groups.rs @@ -0,0 +1,122 @@ +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) -> 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); +} + +#[tokio::test] +async fn grouping_uses_global_config_with_target_overrides() { + let dir = tempfile::tempdir().expect("tempdir"); + let app_config = super::curation_effect_gate::app_config(dir.path()); + let mut global: shared::model::ChannelGroupsConfig = + serde_json::from_str(r#"{"match_normalize":{"ignore_tokens":["backup"]},"name_strip_quality":true}"#) + .expect("global config"); + global.prepare().expect("prepared"); + app_config.config.store(std::sync::Arc::new(tuliprox_core::model::Config { + storage_dir: dir.path().to_string_lossy().into_owned(), + channel_groups: global, + ..tuliprox_core::model::Config::default() + })); + let target = target_with_options(r#"{"channel_groups":{"enabled":true,"name_strip_quality":false}}"#); + let mut playlist = vec![group("News", vec![live("a", "1", "ZDF HD"), live("b", "2", "ZDF HD backup")])]; + let mut step = tuliprox_core::utils::StepMeasure::new("test", |_, _| {}); + let mut errors = Vec::new(); + + let pending = crate::processor::playlist::target::process_target_channel_groups( + &app_config, + &target, + &mut playlist, + &mut step, + &mut errors, + ) + .await; + + assert!(errors.is_empty()); + assert_eq!(pending.len(), 1, "the global ignore token joins both channels"); + let names: Vec<&str> = playlist[0].channels.iter().map(|item| item.header.name.as_ref()).collect(); + assert_eq!(names, ["ZDF HD"], "the target keeps quality words"); +} diff --git a/backend/processing/src/processor/playlist/tests/curation_effect_gate.rs b/backend/processing/src/processor/playlist/tests/curation_effect_gate.rs index 8668e1a77..d86727c1b 100644 --- a/backend/processing/src/processor/playlist/tests/curation_effect_gate.rs +++ b/backend/processing/src/processor/playlist/tests/curation_effect_gate.rs @@ -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 { Arc::new(AppConfig { diff --git a/backend/processing/src/processor/playlist/tests/curation_selection.rs b/backend/processing/src/processor/playlist/tests/curation_selection.rs index fb4c5cedc..5b5c26c92 100644 --- a/backend/processing/src/processor/playlist/tests/curation_selection.rs +++ b/backend/processing/src/processor/playlist/tests/curation_selection.rs @@ -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); diff --git a/backend/processing/src/processor/playlist/tests/identity_serialization.rs b/backend/processing/src/processor/playlist/tests/identity_serialization.rs index 6d8374a1c..e92e3fe2c 100644 --- a/backend/processing/src/processor/playlist/tests/identity_serialization.rs +++ b/backend/processing/src/processor/playlist/tests/identity_serialization.rs @@ -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"); diff --git a/backend/processing/src/processor/stream_probe.rs b/backend/processing/src/processor/stream_probe.rs index ab401b97c..f75fca79c 100644 --- a/backend/processing/src/processor/stream_probe.rs +++ b/backend/processing/src/processor/stream_probe.rs @@ -289,11 +289,21 @@ async fn prepare_generic_stream_metadata( } let (raw_video, raw_audio, stats) = match probe_data { - ProbeUrlOutcome::Success(_quality, raw_video, raw_audio, stats) => ( - raw_video.map(|value| Arc::::from(value.to_string())), - raw_audio.map(|value| Arc::::from(value.to_string())), - stats, - ), + ProbeUrlOutcome::Success(_quality, raw_video, raw_audio, stats) => { + let rv = raw_video.map(|value| Arc::::from(value.to_string())); + let ra = raw_audio.map(|value| Arc::::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 diff --git a/backend/repository/src/channel_group_repository.rs b/backend/repository/src/channel_group_repository.rs new file mode 100644 index 000000000..56ef17b6a --- /dev/null +++ b/backend/repository/src/channel_group_repository.rs @@ -0,0 +1,645 @@ +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, + pub origin: ChannelGroupOrigin, + pub policy: ChannelGroupPolicy, + pub members: Vec, + pub orphaned: Vec, + 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, + pub provider_id: u32, + pub input_stream_id: Arc, + pub url: Arc, + pub item_type: PlaylistItemType, + pub cluster: XtreamCluster, + pub title: Arc, + pub archive: Option, + pub epg_channel_id: Option>, + pub additional_properties: Option, +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ChannelGroupRecord { + pub group_vid: VirtualId, + pub key: Arc, + pub origin: ChannelGroupOrigin, + pub policy: ChannelGroupPolicy, + pub members: Vec, + pub orphaned: Vec, +} + +pub struct ChannelGroupStagedPublication { + target_path: PathBuf, + staged_groups: Option, + staged_members: Option, + staged_aliases: Option, +} + +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 { + 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::::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::::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::::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> { + let db_path = target_path.join(FILE_CHANNEL_GROUPS_ALIAS); + if !db_path.exists() { + return Ok(None); + } + let mut query = match BPlusTreeQuery::::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> { + let db_path = target_path.join(FILE_CHANNEL_GROUPS); + if !db_path.exists() { + return Ok(None); + } + let mut query = match BPlusTreeQuery::::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> { + let db_path = target_path.join(FILE_CHANNEL_GROUPS_MEMBER); + if !db_path.exists() { + return Ok(None); + } + let mut member_query = match BPlusTreeQuery::::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> { + let db_path = target_path.join(FILE_CHANNEL_GROUPS); + if !db_path.exists() { + return Ok(Vec::new()); + } + let mut query = match BPlusTreeQuery::::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> { + let db_path = target_path.join(FILE_CHANNEL_GROUPS_ALIAS); + if !db_path.exists() { + return Ok(HashMap::new()); + } + let mut query = match BPlusTreeQuery::::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 { + 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>, + pub members: HashMap, + pub aliases: HashMap, +} + +impl ChannelGroupTargetData { + /// `(input_name, input_stream_id)` of every member of every group. + pub fn member_keys(&self) -> impl Iterator, Arc)> + '_ { + self.groups.values().flat_map(|group| { + group.members.iter().map(|member| (Arc::clone(&member.input_name), Arc::clone(&member.input_stream_id))) + }) + } +} + +pub struct ChannelGroupCache { + targets: tokio::sync::RwLock>>, + /// Per target: serializes store reads so concurrent requests after a generation bump load + /// once, without making other targets wait. + load_locks: std::sync::Mutex>>>, + #[cfg(any(test, feature = "test-support"))] + loads: std::sync::atomic::AtomicUsize, +} + +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()), + load_locks: std::sync::Mutex::new(HashMap::new()), + #[cfg(any(test, feature = "test-support"))] + loads: std::sync::atomic::AtomicUsize::new(0), + } + } + + fn load_lock(&self, target_name: &str) -> Arc> { + let mut locks = self.load_locks.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + Arc::clone(locks.entry(target_name.to_string()).or_default()) + } + + #[cfg(any(test, feature = "test-support"))] + pub async fn hold_load_lock_for_test(&self, target_name: &str) -> tokio::sync::OwnedMutexGuard<()> { + self.load_lock(target_name).lock_owned().await + } + + async fn cached(&self, target_name: &str, generation: u64) -> Option> { + let guard = self.targets.read().await; + guard.get(target_name).filter(|cached| cached.generation == generation).map(Arc::clone) + } + + pub async fn get_or_load( + &self, + app_config: &Arc, + playlists: &crate::PlaylistStorageState, + target: &ConfigTarget, + ) -> Arc { + let current_generation = playlists.channel_group_generation(&target.name); + if let Some(cached) = self.cached(&target.name, current_generation).await { + return cached; + } + + let _load_guard = self.load_lock(&target.name).lock_owned().await; + if let Some(cached) = self.cached(&target.name, current_generation).await { + return cached; + } + + let empty = || ChannelGroupTargetData { generation: current_generation, ..ChannelGroupTargetData::default() }; + let config = app_config.config.load(); + let Some(target_path) = crate::get_target_storage_path(&config, &target.name) else { + return Arc::new(empty()); + }; + + #[cfg(any(test, feature = "test-support"))] + self.loads.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + 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 + .ok() + .and_then(Result::ok) + .unwrap_or_else(empty); + + let arc_data = Arc::new(loaded); + let mut guard = self.targets.write().await; + guard.insert(target.name.clone(), Arc::clone(&arc_data)); + arc_data + } + + #[cfg(any(test, feature = "test-support"))] + pub async fn loaded_target_count(&self) -> usize { self.targets.read().await.len() } + + #[cfg(any(test, feature = "test-support"))] + pub fn load_count(&self) -> usize { self.loads.load(std::sync::atomic::Ordering::Relaxed) } +} + +#[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 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, 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 { + 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()); + } +} diff --git a/backend/repository/src/lib.rs b/backend/repository/src/lib.rs index 42d161eae..d409af671 100644 --- a/backend/repository/src/lib.rs +++ b/backend/repository/src/lib.rs @@ -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; @@ -43,6 +44,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::*; @@ -76,6 +78,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; diff --git a/backend/repository/src/playlist_mem_cache.rs b/backend/repository/src/playlist_mem_cache.rs index d0d64644e..0b9a50951 100644 --- a/backend/repository/src/playlist_mem_cache.rs +++ b/backend/repository/src/playlist_mem_cache.rs @@ -1,6 +1,6 @@ use super::{BPlusTree, VirtualIdRecord}; use shared::model::{M3uPlaylistItem, PlaylistItem, VirtualId, XtreamCluster, XtreamPlaylistItem}; -use std::collections::HashMap; +use std::{collections::HashMap, sync::Mutex}; use tokio::sync::RwLock; use tuliprox_core::model::ConfigTarget; @@ -27,6 +27,9 @@ pub type TargetPlaylistStorageMap = HashMap; pub struct PlaylistStorageState { pub data: RwLock, + /// Generation of the persisted channel groups per target name; a missing entry is generation 0. + channel_group_generations: Mutex>, + pub channel_groups: crate::ChannelGroupCache, } impl Default for PlaylistStorageState { @@ -34,7 +37,26 @@ 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_generations: Mutex::new(HashMap::new()), + channel_groups: crate::ChannelGroupCache::new(), + } + } + + pub fn bump_channel_group_generation(&self, target_name: &str) -> u64 { + let mut generations = self.channel_group_generations.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let generation = generations.entry(target_name.to_string()).or_insert(0); + *generation += 1; + *generation + } + + #[must_use] + pub fn channel_group_generation(&self, target_name: &str) -> u64 { + let generations = self.channel_group_generations.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + generations.get(target_name).copied().unwrap_or(0) + } /// Replaces one target only after its complete persisted state has been loaded. pub(crate) async fn replace_target(&self, target_name: &str, storage: TargetPlaylistStorage) { diff --git a/backend/repository/src/playlist_repository.rs b/backend/repository/src/playlist_repository.rs index f79cc62e4..f19b1b4d4 100644 --- a/backend/repository/src/playlist_repository.rs +++ b/backend/repository/src/playlist_repository.rs @@ -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; diff --git a/backend/repository/src/playlist_repository/episode.rs b/backend/repository/src/playlist_repository/episode.rs index 3fd5338d0..a5e7bf8bb 100644 --- a/backend/repository/src/playlist_repository/episode.rs +++ b/backend/repository/src/playlist_repository/episode.rs @@ -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); } } diff --git a/backend/repository/src/playlist_repository/persist.rs b/backend/repository/src/playlist_repository/persist.rs index 9aa0cd0f6..aa1b0504f 100644 --- a/backend/repository/src/playlist_repository/persist.rs +++ b/backend/repository/src/playlist_repository/persist.rs @@ -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, @@ -12,12 +12,13 @@ use crate::{ ensure_target_storage_path, epg_write_for_target, lineup_guide_index_carry_over, lineup_guide_index_remove, lineup_guide_index_view, lineup_guide_index_write, load_input_local_library_playlist, m3u_lineup_guide_index_path, m3u_write_playlist, persist_input_library_playlist, write_strm_playlist, xtream_get_file_path, - xtream_get_storage_path, xtream_lineup_guide_index_path, LineupGuideCarryOver, LineupGuideView, + xtream_get_storage_path, xtream_lineup_guide_index_path, ChannelGroupRecord, ChannelGroupStore, GroupMember, + LineupGuideCarryOver, LineupGuideView, MemberArchive, PendingChannelGroup, TargetIdMapping, }; use log::warn; use shared::{ error::TuliproxError, - model::{ClusterFlags, PlaylistGroup, XtreamCluster}, + model::{ClusterFlags, PlaylistGroup, StreamProperties, VirtualId, XtreamCluster}, }; use std::{ path::{Path, PathBuf}, @@ -53,6 +54,30 @@ pub async fn persist_playlist_views( target: &ConfigTarget, playlist_state: Option<&Arc>, options: TargetPlaylistPersistOptions, +) -> Result<(), Vec> { + 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, + base_playlist: &mut [PlaylistGroup], + xtream_playlist: Option<&mut [PlaylistGroup]>, + epg: Option<&Epg>, + target: &ConfigTarget, + playlist_state: Option<&Arc>, + options: TargetPlaylistPersistOptions, + channel_groups: &mut [PendingChannelGroup], ) -> Result<(), Vec> { persist_playlist_views_with_mode( app_config, @@ -63,6 +88,7 @@ pub async fn persist_playlist_views( playlist_state, options, TargetPersistenceMode::Persist, + channel_groups, ) .await } @@ -76,8 +102,83 @@ pub(super) async fn persist_playlist_with_mode( options: TargetPlaylistPersistOptions, persistence_mode: TargetPersistenceMode, ) -> Result<(), Vec> { - 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 { + 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)] @@ -90,6 +191,7 @@ async fn persist_playlist_views_with_mode( playlist_state: Option<&Arc>, options: TargetPlaylistPersistOptions, persistence_mode: TargetPersistenceMode, + channel_groups: &mut [PendingChannelGroup], ) -> Result<(), Vec> { let playlist_is_empty = !playlist_has_items(base_playlist) && xtream_playlist.as_deref().is_none_or(|playlist| !playlist_has_items(playlist)); @@ -109,9 +211,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()); @@ -122,6 +231,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 + ))]); + } + }; + // Only the view `lineup.json` is published from keeps a guide index. Any other index would // describe a playlist the reader no longer asks, and a later re-configuration could mistake it // for a current one. @@ -251,6 +380,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(&target.name); + } + } else { + staged_groups.discard(); + } + if errors.is_empty() && target.use_memory_cache { match playlist_state { Some(playlist_storage) => { diff --git a/backend/repository/src/playlist_repository/tests.rs b/backend/repository/src/playlist_repository/tests.rs index 15c30194b..232039e4f 100644 --- a/backend/repository/src/playlist_repository/tests.rs +++ b/backend/repository/src/playlist_repository/tests.rs @@ -388,6 +388,7 @@ fn make_media_server_episode( } } +mod channel_groups; mod lifecycle; mod playlist; mod policy; diff --git a/backend/repository/src/playlist_repository/tests/channel_groups.rs b/backend/repository/src/playlist_repository/tests/channel_groups.rs new file mode 100644 index 000000000..4f87bd667 --- /dev/null +++ b/backend/repository/src/playlist_repository/tests/channel_groups.rs @@ -0,0 +1,422 @@ +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, 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, 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, Vec) { + 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, Vec) { + 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, Vec) { + 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()); +} + +fn enable_groups(target: &mut ConfigTarget) { + target.options = Some(shared::model::ConfigTargetOptions { + channel_groups: Some(shared::model::ChannelGroupsTargetConfig { enabled: true, ..Default::default() }), + ..Default::default() + }); +} + +async fn persist_grouped(app_config: &Arc, target: &ConfigTarget, state: &Arc) { + let (mut playlist, mut pending) = grouped_fixture(); + persist_playlist_views_with_groups( + app_config, + &mut playlist, + None, + None, + target, + Some(state), + TargetPlaylistPersistOptions::default(), + &mut pending, + ) + .await + .expect("persists"); +} + +#[tokio::test] +async fn classify_without_channel_groups_returns_item_without_loading() { + let (app_config, target, _dir) = persist_test_env(""); + let state = PlaylistStorageState::new(); + let resolved = + crate::target_stream_item::classify_virtual_id(&app_config, &state, &target, VirtualId::new(1)).await; + assert_eq!(resolved, crate::target_stream_item::ResolvedStreamTarget::Item); + assert_eq!(state.channel_groups.loaded_target_count().await, 0); +} + +#[tokio::test] +async fn bumping_one_target_keeps_other_target_cache() { + let (app_config, mut target_a, _dir) = persist_test_env(""); + enable_groups(&mut target_a); + let mut target_b = target_a.clone(); + target_b.name = "target-groups-other".to_string(); + let state = Arc::new(PlaylistStorageState::new()); + let a_before = state.channel_groups.get_or_load(&app_config, &state, &target_a).await; + let b_before = state.channel_groups.get_or_load(&app_config, &state, &target_b).await; + state.bump_channel_group_generation(&target_a.name); + let a_after = state.channel_groups.get_or_load(&app_config, &state, &target_a).await; + let b_after = state.channel_groups.get_or_load(&app_config, &state, &target_b).await; + assert!(!Arc::ptr_eq(&a_before, &a_after)); + assert!(Arc::ptr_eq(&b_before, &b_after)); +} + +#[tokio::test] +async fn concurrent_loads_read_store_once() { + let (app_config, mut target, _dir) = persist_test_env(""); + enable_groups(&mut target); + let state = Arc::new(PlaylistStorageState::new()); + persist_grouped(&app_config, &target, &state).await; + let loads_before = state.channel_groups.load_count(); + let (first, second) = tokio::join!( + state.channel_groups.get_or_load(&app_config, &state, &target), + state.channel_groups.get_or_load(&app_config, &state, &target) + ); + assert!(Arc::ptr_eq(&first, &second)); + assert_eq!(state.channel_groups.load_count() - loads_before, 1); + assert_eq!(first.groups.len(), 1); +} + +#[tokio::test] +async fn loads_of_different_targets_do_not_wait_on_each_other() { + let (app_config, mut target_a, _dir) = persist_test_env(""); + enable_groups(&mut target_a); + let mut target_b = target_a.clone(); + target_b.name = "target-groups-other".to_string(); + let state = Arc::new(PlaylistStorageState::new()); + let _loading_a = state.channel_groups.hold_load_lock_for_test(&target_a.name).await; + let loaded_b = tokio::time::timeout( + std::time::Duration::from_secs(1), + state.channel_groups.get_or_load(&app_config, &state, &target_b), + ) + .await; + assert!(loaded_b.is_ok(), "target B loads while target A is loading"); +} diff --git a/backend/repository/src/recording_repository.rs b/backend/repository/src/recording_repository.rs index c88e1acf3..0bb0e5fd1 100644 --- a/backend/repository/src/recording_repository.rs +++ b/backend/repository/src/recording_repository.rs @@ -1360,7 +1360,7 @@ mod tests { #[test] fn losing_the_strongest_entry_relaxes_the_file() -> io::Result<()> { - // The plan's case: remove the former maximum and the file must fall + // Removing the former maximum makes the file fall // back to what is still attached, not keep a priority nobody holds. let dir = tempfile::TempDir::new().expect("tempdir"); let (mut repository, _opened) = RecordingRepository::open(dir.path(), dir.path())?; diff --git a/backend/repository/src/storage_const.rs b/backend/repository/src/storage_const.rs index 53363a951..153b72604 100644 --- a/backend/repository/src/storage_const.rs +++ b/backend/repository/src/storage_const.rs @@ -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"; diff --git a/backend/repository/src/stream_history/mod.rs b/backend/repository/src/stream_history/mod.rs index b31c4548f..eccf97181 100644 --- a/backend/repository/src/stream_history/mod.rs +++ b/backend/repository/src/stream_history/mod.rs @@ -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, diff --git a/backend/repository/src/target_stream_item.rs b/backend/repository/src/target_stream_item.rs new file mode 100644 index 000000000..1465cb343 --- /dev/null +++ b/backend/repository/src/target_stream_item.rs @@ -0,0 +1,440 @@ +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), + Member { group: Arc, index: usize }, +} + +/// Whether `target` resolves channel groups at all. Targets without them skip every group lookup. +#[must_use] +pub fn channel_groups_enabled(target: &ConfigTarget) -> bool { + target.options.as_ref().and_then(|options| options.channel_groups.as_ref()).is_some_and(|groups| groups.enabled) +} + +pub async fn classify_virtual_id( + app_config: &Arc, + playlists: &PlaylistStorageState, + target: &ConfigTarget, + vid: VirtualId, +) -> ResolvedStreamTarget { + if !channel_groups_enabled(target) { + return ResolvedStreamTarget::Item; + } + 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, + playlists: Arc, + target: ConfigTarget, + _dir: tempfile::TempDir, + group_vid: VirtualId, + plain_vid: VirtualId, + member_vids: Vec, + 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 { + 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, Vec) { + 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, Vec) { + let (mut playlist, _) = grouped_fixture(); + playlist.remove(0); + (playlist, Vec::new()) + } + + fn grouped_fixture_with_favourite_copy() -> (Vec, Vec) { + 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 mut 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() + }); + target.options = Some(shared::model::ConfigTargetOptions { + channel_groups: Some(shared::model::ChannelGroupsTargetConfig { enabled: true, ..Default::default() }), + ..Default::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 mut 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() + }); + target.options = Some(shared::model::ConfigTargetOptions { + channel_groups: Some(shared::model::ChannelGroupsTargetConfig { enabled: true, ..Default::default() }), + ..Default::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 + )); + } +} diff --git a/backend/session/Cargo.toml b/backend/session/Cargo.toml index 3ae7df636..20d8a9968 100644 --- a/backend/session/Cargo.toml +++ b/backend/session/Cargo.toml @@ -8,6 +8,10 @@ description = "Provider allocation and streaming-session runtime: admission, con [lints] workspace = true +[features] +# Exposes test-only helpers to dependent crates' tests. +test-support = [] + [dependencies] shared = { workspace = true } tuliprox-core = { workspace = true } @@ -24,6 +28,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"] } diff --git a/backend/session/src/active_provider_manager.rs b/backend/session/src/active_provider_manager.rs index dbc16d9b9..5ea1e5e25 100644 --- a/backend/session/src/active_provider_manager.rs +++ b/backend/session/src/active_provider_manager.rs @@ -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, - shared_subscribers: Vec, + pub(crate) single_allocations: Vec, + pub(crate) shared_subscribers: Vec, } #[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::*; diff --git a/backend/session/src/active_provider_manager/allocation.rs b/backend/session/src/active_provider_manager/allocation.rs index 91bc3db2d..0b50630dc 100644 --- a/backend/session/src/active_provider_manager/allocation.rs +++ b/backend/session/src/active_provider_manager/allocation.rs @@ -32,6 +32,11 @@ use tuliprox_core::{ impl ProviderReleaseSnapshot { pub fn is_empty(&self) -> bool { self.single_allocations.is_empty() && self.shared_subscribers.is_empty() } + + #[cfg(any(test, feature = "test-support"))] + pub(crate) fn from_single_allocations(addr: SocketAddr, single_allocations: Vec) -> Self { + Self { addr, single_allocations, shared_subscribers: Vec::new() } + } } /// Resolves a public live HLS session token to its stable provider playback owner. @@ -205,7 +210,7 @@ impl std::ops::Deref for ActiveProviderManager { } impl ActiveProviderManager { - fn index_owner(connections: &mut Connections, allocation_id: AllocationId, owner: Option<&Arc>) { + pub(super) fn index_owner(connections: &mut Connections, allocation_id: AllocationId, owner: Option<&Arc>) { if let Some(owner) = owner { connections.by_owner.entry(Arc::clone(owner)).or_default().insert(allocation_id); } diff --git a/backend/session/src/active_provider_manager/capacity_probe.rs b/backend/session/src/active_provider_manager/capacity_probe.rs new file mode 100644 index 000000000..f5b5c571f --- /dev/null +++ b/backend/session/src/active_provider_manager/capacity_probe.rs @@ -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, + 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, + 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, + allow_grace: bool, + addr: &SocketAddr, + priority: i8, + kind: ConnectionKind, + lease: Option>, + ) -> Option { + 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) + } +} diff --git a/backend/session/src/active_provider_manager/shared.rs b/backend/session/src/active_provider_manager/shared.rs index 6a5a34948..ffeb4d1e0 100644 --- a/backend/session/src/active_provider_manager/shared.rs +++ b/backend/session/src/active_provider_manager/shared.rs @@ -1,4 +1,6 @@ -use super::{ActiveProviderManager, ConnectionKind, Connections, PriorityKey, PriorityOwner}; +use super::{ + allocation::ActiveConnectionInfo, ActiveProviderManager, ConnectionKind, Connections, PriorityKey, PriorityOwner, +}; use crate::SharedStreamManager; use ::shared::utils::sanitize_sensitive_info; use log::error; @@ -332,26 +334,11 @@ impl ActiveProviderManager { if connections.shared.by_key.contains_key(key) { return false; } - let Some(info) = connections.single.remove(&handle.allocation_id) else { + let Some(info) = Self::take_single_locked(&mut connections, handle) else { return false; }; - if let Some(set) = connections.single_by_addr.get_mut(&info.client_addr) { - set.remove(&handle.allocation_id); - if set.is_empty() { - connections.single_by_addr.remove(&info.client_addr); - } - } - let provider_name = info.allocation.get_provider_name().unwrap_or_default(); if !info.allocation.is_unlimited_provider() { - if let Some(list) = connections.by_provider.get_mut(&provider_name) { - list.remove(&handle.allocation_id); - } - Self::remove_priority_entry( - &mut connections, - &provider_name, - &(info.priority, Reverse(info.created_at), handle.allocation_id), - info.kind, - ); + let provider_name = info.allocation.get_provider_name().unwrap_or_default(); Self::upsert_priority_entry( &mut connections, &provider_name, @@ -383,6 +370,115 @@ impl ActiveProviderManager { true } + /// Removes the single allocation of `handle` from the single-connection indexes so a shared + /// stream can own it. + fn take_single_locked(connections: &mut Connections, handle: &ProviderHandle) -> Option { + let info = connections.single.remove(&handle.allocation_id)?; + if let Some(set) = connections.single_by_addr.get_mut(&info.client_addr) { + set.remove(&handle.allocation_id); + if set.is_empty() { + connections.single_by_addr.remove(&info.client_addr); + } + } + if !info.allocation.is_unlimited_provider() { + let provider_name = info.allocation.get_provider_name().unwrap_or_default(); + if let Some(list) = connections.by_provider.get_mut(&provider_name) { + list.remove(&handle.allocation_id); + } + Self::remove_priority_entry( + connections, + &provider_name, + &(info.priority, Reverse(info.created_at), handle.allocation_id), + info.kind, + ); + } + Some(info) + } + + /// Releases the provider allocation `expected` behind the shared stream `key` while its + /// subscribers stay, so a group stream can move to another member. Refused when the stream + /// runs on another allocation, e.g. a successor stream under the same key. + pub fn detach_shared_allocation(&self, key: &str, expected: AllocationId) -> bool { + let _transition = self.lock_capacity_transition(); + let allocation = { + let mut connections = self.write_connections(); + let Some(shared) = connections.shared.by_key.get_mut(key) else { + return false; + }; + if shared.allocation_id != expected || matches!(shared.allocation, ProviderAllocation::Exhausted) { + return false; + } + let allocation_id = shared.allocation_id; + let allocation = std::mem::replace(&mut shared.allocation, ProviderAllocation::Exhausted); + shared.cancel_token = None; + let priority_key = (shared.priority, Reverse(shared.created_at), allocation_id); + let kind = shared.kind; + let owner = shared.session_owner.clone(); + connections.shared.shared_by_allocation_id.remove(&allocation_id); + Self::unindex_owner(&mut connections, allocation_id, owner.as_ref()); + if !allocation.is_unlimited_provider() { + if let Some(name) = allocation.get_provider_name() { + if let Some(list) = connections.by_provider.get_mut(&name) { + list.remove(&allocation_id); + } + Self::remove_priority_entry(&mut connections, &name, &priority_key, kind); + } + } + allocation + }; + allocation.release(); + if let Some(name) = allocation.get_provider_name() { + self.capacity.notify(&name); + } + true + } + + /// Moves the single allocation of `handle` under the shared stream `key`, whose subscribers + /// keep their priority and kind. Only the stream detached from `detached` takes it. + pub fn attach_shared_allocation(&self, key: &str, detached: AllocationId, handle: &ProviderHandle) -> bool { + if self.is_shutting_down.load(Ordering::Acquire) { + return false; + } + let _transition = self.lock_capacity_transition(); + let mut connections = self.write_connections(); + let Some((shared_key, shared)) = connections.shared.by_key.get_key_value(key) else { + return false; + }; + if shared.allocation_id != detached || !matches!(shared.allocation, ProviderAllocation::Exhausted) { + return false; + } + let shared_key = Arc::clone(shared_key); + let Some(info) = Self::take_single_locked(&mut connections, handle) else { + return false; + }; + let Some(shared) = connections.shared.by_key.get_mut(key) else { + return false; + }; + shared.allocation_id = handle.allocation_id; + shared.allocation = info.allocation; + shared.cancel_token = Some(info.cancel_token); + shared.created_at = info.created_at; + let (priority, kind, created_at) = (shared.priority, shared.kind, shared.created_at); + // The new allocation serves the shared stream's session owner, not the one it was + // acquired for. + let shared_owner = shared.session_owner.clone(); + let provider_name = + (!shared.allocation.is_unlimited_provider()).then(|| shared.allocation.get_provider_name()).flatten(); + Self::unindex_owner(&mut connections, handle.allocation_id, info.session_owner.as_ref()); + Self::index_owner(&mut connections, handle.allocation_id, shared_owner.as_ref()); + if let Some(provider_name) = provider_name { + Self::upsert_priority_entry( + &mut connections, + &provider_name, + (priority, Reverse(created_at), handle.allocation_id), + PriorityOwner::Shared(handle.allocation_id), + kind, + ); + } + connections.shared.shared_by_allocation_id.insert(handle.allocation_id, shared_key); + true + } + pub fn add_shared_connection( &self, addr: &SocketAddr, @@ -408,11 +504,13 @@ impl ActiveProviderManager { s.priority, s.kind, s.created_at, - s.allocation.is_unlimited_provider(), + // A detached stream (member switch) has no provider and no index entry; attaching + // the next allocation indexes it with the subscribers' priority. + s.allocation.is_unlimited_provider() || matches!(s.allocation, ProviderAllocation::Exhausted), ) }); - let Some((alloc_id, provider_name, old_priority, old_kind, created_at, shared_is_unlimited)) = metadata else { + let Some((alloc_id, provider_name, old_priority, old_kind, created_at, not_indexed)) = metadata else { let err = format!("Failed to add shared connection for {addr}: url {} not found", sanitize_sensitive_info(key)); error!("{err}"); @@ -447,7 +545,7 @@ impl ActiveProviderManager { let _ = shared_allocation; // Skip priority-index rebuild for unlimited providers: they are not // subject to preemption and therefore must not be in the index. - if !shared_is_unlimited && needs_reindex { + if !not_indexed && needs_reindex { Self::remove_priority_entry( &mut connections, &provider_name, diff --git a/backend/session/src/active_provider_manager/tests.rs b/backend/session/src/active_provider_manager/tests.rs index 2b030caa3..03b87a4b5 100644 --- a/backend/session/src/active_provider_manager/tests.rs +++ b/backend/session/src/active_provider_manager/tests.rs @@ -2,8 +2,10 @@ use super::{ActiveProviderManager, ConnectionKind, PlaybackLeaseRef}; mod admission; mod behavior; +mod capacity_probe; mod lifecycle; mod retry; +mod shared_rebind; mod support; mod terminal; diff --git a/backend/session/src/active_provider_manager/tests/capacity_probe.rs b/backend/session/src/active_provider_manager/tests/capacity_probe.rs new file mode 100644 index 000000000..07f1327d5 --- /dev/null +++ b/backend/session/src/active_provider_manager/tests/capacity_probe.rs @@ -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, +}; + +pub(super) 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()), + } +} + +pub(super) 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 = "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 = "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 = "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); +} diff --git a/backend/session/src/active_provider_manager/tests/shared_rebind.rs b/backend/session/src/active_provider_manager/tests/shared_rebind.rs new file mode 100644 index 000000000..f020b2a48 --- /dev/null +++ b/backend/session/src/active_provider_manager/tests/shared_rebind.rs @@ -0,0 +1,163 @@ +use super::{ + super::{ActiveProviderManager, ConnectionKind}, + capacity_probe::{addr, manager_with_inputs}, +}; +use std::sync::Arc; +use tuliprox_core::model::SharedSubscriberId; + +fn slots(manager: &ActiveProviderManager, input: &str) -> usize { + manager.active_connections().unwrap_or_default().get(input).copied().unwrap_or_default() +} + +const KEY: &str = "group:1:7"; + +#[test] +fn detach_then_attach_moves_shared_allocation_to_new_provider() { + let manager = manager_with_inputs(&[("solis", 2), ("korath", 2)]); + let solis: Arc = "solis".into(); + let korath: Arc = "korath".into(); + let first = manager.acquire_connection(&solis, &addr(1), 0, ConnectionKind::Normal).expect("solis slot"); + assert!(manager.make_shared_connection(&first, KEY, SharedSubscriberId::from_stream_uid(1))); + manager + .add_shared_connection(&addr(2), SharedSubscriberId::from_stream_uid(2), KEY, 0, ConnectionKind::Normal) + .expect("join"); + + assert!(manager.detach_shared_allocation(KEY, first.allocation_id)); + assert_eq!(slots(&manager, "solis"), 0); + assert!(!manager.detach_shared_allocation(KEY, first.allocation_id), "a detached stream has nothing to release"); + + let next = manager.acquire_connection(&korath, &addr(1), 0, ConnectionKind::Normal).expect("korath slot"); + assert!(manager.attach_shared_allocation(KEY, first.allocation_id, &next)); + assert_eq!((slots(&manager, "solis"), slots(&manager, "korath")), (0, 1)); + + manager.release_shared_connection(SharedSubscriberId::from_stream_uid(2)); + assert_eq!(slots(&manager, "korath"), 1, "one subscriber still watches"); + manager.release_shared_connection(SharedSubscriberId::from_stream_uid(1)); + assert_eq!(slots(&manager, "korath"), 0); +} + +#[test] +fn release_handle_after_attach_releases_new_allocation() { + let manager = manager_with_inputs(&[("solis", 1), ("korath", 1)]); + let solis: Arc = "solis".into(); + let korath: Arc = "korath".into(); + let first = manager.acquire_connection(&solis, &addr(1), 0, ConnectionKind::Normal).expect("solis slot"); + assert!(manager.make_shared_connection(&first, KEY, SharedSubscriberId::from_stream_uid(1))); + manager.detach_shared_allocation(KEY, first.allocation_id); + let next = manager.acquire_connection(&korath, &addr(1), 0, ConnectionKind::Normal).expect("korath slot"); + assert!(manager.attach_shared_allocation(KEY, first.allocation_id, &next)); + manager.release_handle(&first); + assert_eq!(slots(&manager, "korath"), 1, "the old handle no longer owns the shared stream"); + manager.release_handle(&next); + assert_eq!(slots(&manager, "korath"), 0); +} + +#[test] +fn attach_to_unknown_key_keeps_single_allocation() { + let manager = manager_with_inputs(&[("korath", 1)]); + let korath: Arc = "korath".into(); + let handle = manager.acquire_connection(&korath, &addr(1), 0, ConnectionKind::Normal).expect("korath slot"); + assert!(!manager.attach_shared_allocation(KEY, 0, &handle)); + assert_eq!(slots(&manager, "korath"), 1); + manager.release_handle(&handle); + assert_eq!(slots(&manager, "korath"), 0); +} + +#[test] +fn attach_keeps_session_owner_of_shared_stream() { + let manager = manager_with_inputs(&[("solis", 1), ("korath", 1)]); + let solis: Arc = "solis".into(); + let korath: Arc = "korath".into(); + let first = manager + .try_acquire_connection_no_preempt( + &solis, + false, + &addr(1), + 0, + ConnectionKind::Normal, + Some(super::super::PlaybackLeaseRef::for_session_owner("viewer-session")), + ) + .expect("solis slot"); + assert!(manager.make_shared_connection(&first, KEY, SharedSubscriberId::from_stream_uid(1))); + assert!(manager.has_active_owner_for_provider(&solis, "viewer-session")); + manager.detach_shared_allocation(KEY, first.allocation_id); + let next = manager.acquire_connection(&korath, &addr(1), 0, ConnectionKind::Normal).expect("korath slot"); + assert!(manager.attach_shared_allocation(KEY, first.allocation_id, &next)); + assert!(manager.has_active_owner_for_provider(&korath, "viewer-session")); +} + +#[test] +fn detach_with_stale_allocation_id_is_refused() { + let manager = manager_with_inputs(&[("solis", 1)]); + let solis: Arc = "solis".into(); + let first = manager.acquire_connection(&solis, &addr(1), 0, ConnectionKind::Normal).expect("solis slot"); + assert!(manager.make_shared_connection(&first, KEY, SharedSubscriberId::from_stream_uid(1))); + assert!(!manager.detach_shared_allocation(KEY, first.allocation_id + 1)); + assert_eq!(slots(&manager, "solis"), 1, "the running allocation stays"); +} + +#[test] +fn attach_after_foreign_detach_is_refused() { + let manager = manager_with_inputs(&[("solis", 1), ("korath", 1)]); + let solis: Arc = "solis".into(); + let korath: Arc = "korath".into(); + let first = manager.acquire_connection(&solis, &addr(1), 0, ConnectionKind::Normal).expect("solis slot"); + assert!(manager.make_shared_connection(&first, KEY, SharedSubscriberId::from_stream_uid(1))); + assert!(manager.detach_shared_allocation(KEY, first.allocation_id)); + let next = manager.acquire_connection(&korath, &addr(1), 0, ConnectionKind::Normal).expect("korath slot"); + assert!(!manager.attach_shared_allocation(KEY, first.allocation_id + 1, &next)); + assert_eq!(slots(&manager, "korath"), 1, "the refused handle stays a single allocation"); + manager.release_handle(&next); + assert_eq!(slots(&manager, "korath"), 0); +} + +#[test] +fn join_during_detach_leaves_no_priority_entry() { + let manager = manager_with_inputs(&[("solis", 1)]); + let solis: Arc = "solis".into(); + let first = manager.acquire_connection(&solis, &addr(1), 0, ConnectionKind::Normal).expect("solis slot"); + assert!(manager.make_shared_connection(&first, KEY, SharedSubscriberId::from_stream_uid(1))); + assert!(manager.detach_shared_allocation(KEY, first.allocation_id)); + manager + .add_shared_connection(&addr(2), SharedSubscriberId::from_stream_uid(2), KEY, -5, ConnectionKind::Normal) + .expect("join while detached"); + let connections = manager.read_connections(); + assert!(connections.priority_index.values().all(std::collections::BTreeMap::is_empty)); +} + +#[test] +fn teardown_in_detach_window_leaves_no_entry() { + let manager = manager_with_inputs(&[("solis", 1)]); + let solis: Arc = "solis".into(); + let first = manager.acquire_connection(&solis, &addr(1), 0, ConnectionKind::Normal).expect("solis slot"); + assert!(manager.make_shared_connection(&first, KEY, SharedSubscriberId::from_stream_uid(1))); + assert!(manager.detach_shared_allocation(KEY, first.allocation_id)); + manager.release_shared_connection(SharedSubscriberId::from_stream_uid(1)); + manager.release_handle(&first); + let connections = manager.read_connections(); + assert!(connections.shared.by_key.is_empty() && connections.shared.shared_by_allocation_id.is_empty()); + drop(connections); + assert_eq!(slots(&manager, "solis"), 0); +} + +#[test] +fn preempt_after_attach_targets_new_allocation() { + let manager = manager_with_inputs(&[("solis", 1), ("korath", 1)]); + let solis: Arc = "solis".into(); + let korath: Arc = "korath".into(); + let first = manager.acquire_connection(&solis, &addr(1), 10, ConnectionKind::Normal).expect("solis slot"); + assert!(manager.make_shared_connection(&first, KEY, SharedSubscriberId::from_stream_uid(1))); + assert!(manager.detach_shared_allocation(KEY, first.allocation_id)); + let next = manager.acquire_connection(&korath, &addr(1), 10, ConnectionKind::Normal).expect("korath slot"); + assert!(manager.attach_shared_allocation(KEY, first.allocation_id, &next)); + let connections = manager.read_connections(); + let entries: Vec<_> = + connections.priority_index.get(&korath).map(|tree| tree.values().copied().collect()).unwrap_or_default(); + assert!( + entries + .iter() + .any(|owner| matches!(owner, super::super::PriorityOwner::Shared(id) if *id == next.allocation_id)), + "preemption finds the shared stream under its new allocation" + ); + assert!(connections.priority_index.get(&solis).is_none_or(std::collections::BTreeMap::is_empty)); +} diff --git a/backend/session/src/active_user_manager/activity.rs b/backend/session/src/active_user_manager/activity.rs index 75f739a85..d05c26d18 100644 --- a/backend/session/src/active_user_manager/activity.rs +++ b/backend/session/src/active_user_manager/activity.rs @@ -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); + } + } } diff --git a/backend/session/src/active_user_manager/admission.rs b/backend/session/src/active_user_manager/admission.rs index 138ca4cf5..c99c9eb72 100644 --- a/backend/session/src/active_user_manager/admission.rs +++ b/backend/session/src/active_user_manager/admission.rs @@ -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,22 @@ impl UserConnectionData { } impl ActiveUserManager { + pub async fn pending_release_allocations(&self, username: &str) -> Vec { + self.pending_provider_release(username).await.map_or_else(Vec::new, |snapshot| snapshot.single_allocations) + } + + #[cfg(any(test, feature = "test-support"))] + 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 { self.pending_provider_releases.lock().await.get(username).cloned() } diff --git a/backend/session/src/active_user_manager/mod.rs b/backend/session/src/active_user_manager/mod.rs index 97149690a..d91d17960 100644 --- a/backend/session/src/active_user_manager/mod.rs +++ b/backend/session/src/active_user_manager/mod.rs @@ -111,6 +111,14 @@ pub enum SessionProviderHeaders { Headers(HashMap), } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MemberBinding { + pub group_vid: u32, + pub member_vid: u32, + pub member_index: usize, + pub input_name: Arc, +} + #[derive(Clone, Debug)] pub struct UserSession { pub token: String, @@ -140,6 +148,7 @@ pub struct UserSession { pub permission: UserConnectionPermission, pub connection_kind: Option, pub lifecycle: PlaybackLifecycle, + pub member_binding: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/backend/session/src/active_user_manager/observability.rs b/backend/session/src/active_user_manager/observability.rs index a3326734c..6c420cb59 100644 --- a/backend/session/src/active_user_manager/observability.rs +++ b/backend/session/src/active_user_manager/observability.rs @@ -79,6 +79,42 @@ impl ActiveUserManager { .collect() } + /// Collect active session virtual ids for a user. + pub async fn active_session_vids(&self, username: &str) -> Vec { + 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> { + 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))) + } + + /// Providers of the user's active streams. + #[cfg(any(test, feature = "test-support"))] + pub async fn stream_providers(&self, username: &str) -> Vec> { + let connections = self.connections.read().await; + connections.by_key.get(username).map_or_else(Vec::new, |data| { + data.streams.iter().filter(|s| !s.preserved).map(|s| Arc::clone(&s.provider)).collect() + }) + } + + /// Provider of the session bound to a channel group member. + pub async fn bound_member_session_provider(&self, username: &str) -> Option> { + let connections = self.connections.read().await; + let data = connections.by_key.get(username)?; + data.sessions.iter().find(|s| s.member_binding.is_some()).map(|s| Arc::clone(&s.provider)) + } + 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 }; diff --git a/backend/session/src/active_user_manager/session_lifecycle.rs b/backend/session/src/active_user_manager/session_lifecycle.rs index e5bfc03fd..144b0cb44 100644 --- a/backend/session/src/active_user_manager/session_lifecycle.rs +++ b/backend/session/src/active_user_manager/session_lifecycle.rs @@ -84,6 +84,7 @@ impl Default for UserSession { permission: UserConnectionPermission::Allowed, connection_kind: None, lifecycle: PlaybackLifecycle::default(), + member_binding: None, } } } @@ -403,6 +404,29 @@ impl ActiveUserManager { } } + /// After a shared channel group stream switched member: every viewer's stream and session + /// of the group on `target_id` shows `provider`. + pub async fn update_shared_group_provider(&self, target_id: u16, group_vid: u32, provider: Arc) { + let now = current_time_secs(); + let mut user_connections = self.write_connections().await; + for connection_data in user_connections.by_key.values_mut() { + let mut tokens = Vec::new(); + for stream in &mut connection_data.streams { + let channel = &stream.channel; + if channel.shared && channel.target_id == target_id && channel.client_virtual_id() == group_vid { + stream.provider = Arc::clone(&provider); + stream.ts = now; + tokens.extend(stream.session_token.clone()); + } + } + for session in connection_data.sessions.iter_mut().filter(|session| tokens.contains(&session.token)) { + if session.provider != provider { + session.switch_provider(Arc::clone(&provider)); + } + } + } + } + pub async fn clear_unbound_session_addr(&self, username: &str, token: &str, addr: &SocketAddr) { let now = current_time_secs(); let mut user_connections = self.write_connections().await; diff --git a/backend/session/src/active_user_manager/tests/observability.rs b/backend/session/src/active_user_manager/tests/observability.rs index 04bd223c4..b3e7ad0b7 100644 --- a/backend/session/src/active_user_manager/tests/observability.rs +++ b/backend/session/src/active_user_manager/tests/observability.rs @@ -162,7 +162,7 @@ async fn check_divergence_detects_stream_without_counted_session() { let mut connections = manager.connections.write().await; let data = connections.by_key.entry(user.username.clone()).or_insert_with(|| UserConnectionData::new(0, 1, 0)); - // Add a session with GraceHold pending — exempt from Invariant 1 + // A session with a pending GraceHold is not expected to have a counted connection data.add_session(UserSession { token: "tok-div-3".to_string(), transition_version: 1, @@ -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, diff --git a/backend/session/src/active_user_manager/tests/support.rs b/backend/session/src/active_user_manager/tests/support.rs index ac32cba02..9c6521f97 100644 --- a/backend/session/src/active_user_manager/tests/support.rs +++ b/backend/session/src/active_user_manager/tests/support.rs @@ -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, } } diff --git a/backend/session/src/channel_group/circuit.rs b/backend/session/src/channel_group/circuit.rs new file mode 100644 index 000000000..33fb34435 --- /dev/null +++ b/backend/session/src/channel_group/circuit.rs @@ -0,0 +1,299 @@ +use axum::http::StatusCode; +use dashmap::DashMap; +use shared::model::ChannelGroupCircuitConfig; +use std::{ + collections::{HashSet, VecDeque}, + sync::Arc, + time::{Duration, Instant}, +}; + +pub type MemberKey = (Arc, Arc); + +#[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::from(&ChannelGroupCircuitConfig::default()) } +} + +impl From<&ChannelGroupCircuitConfig> for CircuitPolicy { + fn from(cfg: &ChannelGroupCircuitConfig) -> Self { + Self { + failures: cfg.source_failures, + cooldown: Duration::from_secs(cfg.cooldown_secs), + cooldown_max: Duration::from_secs(cfg.cooldown_max_secs), + input_failures: cfg.input_failures, + input_window: Duration::from_secs(cfg.input_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, + pub input_circuits: DashMap, InputCircuitEntry>, +} + +pub struct HalfOpenGuard { + pub(crate) member_key: MemberKey, + pub(crate) input_name: Option>, + pub(crate) admit_time: Instant, + pub(crate) shared: Arc, + 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); + } +} diff --git a/backend/session/src/channel_group/health.rs b/backend/session/src/channel_group/health.rs new file mode 100644 index 000000000..455a8182c --- /dev/null +++ b/backend/session/src/channel_group/health.rs @@ -0,0 +1,570 @@ +use super::circuit::{ + compute_cooldown, record_input_failure_in_shared, CircuitAdmission, CircuitPolicy, CircuitSharedState, + CircuitState, CircuitView, FailureClass, HalfOpenGuard, MemberKey, +}; +use arc_swap::{ArcSwap, ArcSwapOption}; +use dashmap::DashMap; +use shared::model::{AudioChannels, AudioCodec, MediaQuality, VideoCodec, VideoResolution}; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, + time::{Duration, Instant}, +}; +use tuliprox_repository::GroupMember; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum QosKey { + Provider { input: Arc, provider_id: u32 }, + TargetMember { target: Arc, 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, 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>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MemberHealth { + pub qos: Option, + pub probe: Option, + pub circuit: CircuitView, +} + +pub struct MemberHealthIndex { + qos: ArcSwap>, + /// Patched per probe result; a map clone per patch would cost O(n) for every probed stream. + probe: DashMap, + /// Keys of all channel group members. Probe results of other streams are not kept; + /// `None` until the members are known. + member_keys: ArcSwapOption>, + shared: Arc, + policy: ArcSwap, + event_sink: ArcSwap>>, +} + +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: DashMap::new(), + member_keys: ArcSwapOption::empty(), + shared: Arc::new(CircuitSharedState::default()), + policy: ArcSwap::from_pointee(policy), + event_sink: ArcSwap::from_pointee(None), + } + } + + pub fn set_event_sink(&self, sink: Arc) { + self.event_sink.store(Arc::new(Some(sink))); + } + + fn emit_circuit_changed(&self, input_name: Arc, input_stream_id: Arc, 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, 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.get(&m_key).map(|entry| entry.value().clone()); + let circuit = self.circuit_view(&m_key, now); + MemberHealth { qos, probe, circuit } + } + + pub fn replace_qos(&self, entries: impl IntoIterator) { + 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) { + 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)); + } + + /// Restricts probe data to `keys` and drops what is stored for other streams. + pub fn set_member_keys(&self, keys: HashSet) { + self.probe.retain(|key, _| keys.contains(key)); + self.member_keys.store(Some(Arc::new(keys))); + } + + fn tracks_probe(&self, key: &MemberKey) -> bool { + self.member_keys.load().as_ref().is_none_or(|keys| keys.contains(key)) + } + + pub fn patch_probe(&self, key: &MemberKey, video_json: Option<&str>, bitrate_kbps: u32) { + if !self.tracks_probe(key) { + return; + } + 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 + .entry(key.clone()) + .and_modify(|part| { + part.resolution.clone_from(&resolution); + part.codec.clone_from(&codec); + part.bitrate_kbps = bitrate_kbps; + }) + .or_insert_with(|| ProbePart { + resolution: resolution.clone(), + codec: codec.clone(), + bitrate_kbps, + audio_codec: AudioCodec::default(), + audio_channels: AudioChannels::default(), + audio_language: None, + }); + } + + pub fn patch_probe_audio(&self, key: &MemberKey, audio_json: Option<&str>) { + if !self.tracks_probe(key) { + return; + } + 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::(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::::from(lower)) + } + }); + + self.probe + .entry(key.clone()) + .and_modify(|part| { + part.audio_codec.clone_from(&audio_codec); + part.audio_channels.clone_from(&audio_channels); + part.audio_language.clone_from(&audio_language); + }) + .or_insert_with(|| ProbePart { + resolution: VideoResolution::default(), + codec: VideoCodec::default(), + bitrate_kbps: 0, + audio_codec: audio_codec.clone(), + audio_channels: audio_channels.clone(), + audio_language: audio_language.clone(), + }); + } + + 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)); } + + pub fn policy(&self) -> Arc { self.policy.load_full() } +} + +#[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 = "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 = "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 = "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")); + } + + #[test] + fn patch_probe_ignores_non_member_keys() { + let index = MemberHealthIndex::new(CircuitPolicy::default()); + let target: Arc = "Main".into(); + let member = test_member("solis", "1", 1, 101); + let stranger = test_member("solis", "2", 2, 102); + index.set_member_keys(std::iter::once(member_key(&member)).collect()); + index.patch_probe(&member_key(&stranger), Some(r#"{"codec_name":"h264","width":1920,"height":1080}"#), 5_000); + index.patch_probe_audio(&member_key(&stranger), Some(r#"[{"codec_name":"aac","channels":2}]"#)); + assert!(index.health(&target, &stranger, Instant::now()).probe.is_none()); + index.patch_probe(&member_key(&member), Some(r#"{"codec_name":"h264","width":1920,"height":1080}"#), 5_000); + assert!(index.health(&target, &member, Instant::now()).probe.is_some()); + } + + #[test] + fn set_member_keys_prunes_probe_entries() { + let index = MemberHealthIndex::new(CircuitPolicy::default()); + let target: Arc = "Main".into(); + let kept = test_member("solis", "1", 1, 101); + let dropped = test_member("korath", "1", 1, 102); + for member in [&kept, &dropped] { + index.patch_probe(&member_key(member), Some(r#"{"codec_name":"h264","width":1280,"height":720}"#), 3_000); + } + index.set_member_keys(std::iter::once(member_key(&kept)).collect()); + let now = Instant::now(); + assert!(index.health(&target, &kept, now).probe.is_some()); + assert!(index.health(&target, &dropped, now).probe.is_none()); + } +} diff --git a/backend/session/src/channel_group/mod.rs b/backend/session/src/channel_group/mod.rs new file mode 100644 index 000000000..805666428 --- /dev/null +++ b/backend/session/src/channel_group/mod.rs @@ -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::*; diff --git a/backend/session/src/channel_group/ranking.rs b/backend/session/src/channel_group/ranking.rs new file mode 100644 index 000000000..a86ba15a1 --- /dev/null +++ b/backend/session/src/channel_group/ranking.rs @@ -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>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SelectionMode { + Live, + Runtime { + current_codec: VideoCodec, + current_resolution: VideoResolution, + current_audio: Option, + 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, + 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, + members: &[GroupMember], + policy: ChannelGroupPolicy, + health: &MemberHealthIndex, + mode: &SelectionMode, + now: Instant, +) -> Vec { + 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, &str)]) -> (Vec, 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, MemberHealthIndex) { + let (mut members, index) = fixture( + &titles + .iter() + .enumerate() + .map(|(i, _)| ("solis", u32::try_from(i + 1).unwrap(), None, "")) + .collect::>(), + ); + 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 { + 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![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![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![1] + ); + } +} diff --git a/backend/session/src/channel_group/test_support.rs b/backend/session/src/channel_group/test_support.rs new file mode 100644 index 000000000..6ebe1ad1d --- /dev/null +++ b/backend/session/src/channel_group/test_support.rs @@ -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, + } +} diff --git a/backend/session/src/connection_manager/tests.rs b/backend/session/src/connection_manager/tests.rs index dc8e97da3..937a01a27 100644 --- a/backend/session/src/connection_manager/tests.rs +++ b/backend/session/src/connection_manager/tests.rs @@ -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, diff --git a/backend/session/src/event_manager.rs b/backend/session/src/event_manager.rs index d07b45da9..399add313 100644 --- a/backend/session/src/event_manager.rs +++ b/backend/session/src/event_manager.rs @@ -157,6 +157,7 @@ struct LatchedEvents { pub struct EventManager { pub(crate) account_provider: std::sync::OnceLock>, + pub(crate) member_health: std::sync::OnceLock>, channel_tx: tokio::sync::broadcast::Sender, meters: StreamMeterRegistry, stats: Arc, @@ -199,6 +200,7 @@ impl EventManager { Self { channel_tx, account_provider: std::sync::OnceLock::new(), + member_health: std::sync::OnceLock::new(), meters: StreamMeterRegistry::new(), stats: Arc::new(EventBusStats::default()), last_nudge: Mutex::new(HashMap::new()), @@ -391,6 +393,10 @@ impl FilteredEventReceiver { } impl EventManager { + pub fn bind_member_health(&self, health: &Arc) { + let _ = self.member_health.set(Arc::downgrade(health)); + } + /// Stop background work, flushing what is pending. /// /// The event channel needs nothing: closing the sender is what tells @@ -417,6 +423,24 @@ impl EventSink for EventManager { }); } } + fn observe_stream_probe( + &self, + input_name: Arc, + input_stream_id: Arc, + video: Option<&str>, + audio: Option<&str>, + bitrate_kbps: u32, + ) { + if let Some(health) = self.member_health.get().and_then(std::sync::Weak::upgrade) { + let key = (input_name, input_stream_id); + if video.is_some() || bitrate_kbps > 0 { + health.patch_probe(&key, video, bitrate_kbps); + } + if audio.is_some() { + health.patch_probe_audio(&key, audio); + } + } + } } impl Default for EventManager { diff --git a/backend/session/src/lib.rs b/backend/session/src/lib.rs index 35031296e..2b8c262ae 100644 --- a/backend/session/src/lib.rs +++ b/backend/session/src/lib.rs @@ -21,6 +21,7 @@ pub mod active_provider_manager; pub mod active_user_manager; pub mod admission; pub mod admission_strategy; +pub mod channel_group; pub mod connection_manager; pub mod event_manager; pub mod meter; @@ -37,7 +38,7 @@ pub mod stream_options; pub mod streams; pub use self::{ - active_provider_manager::*, active_user_manager::*, admission::*, admission_strategy::*, connection_manager::*, - event_manager::*, meter::*, meter_registry::*, provider_leases::*, provider_lineup_manager::*, - provider_session_headers::*, stream::*, streams::*, + active_provider_manager::*, active_user_manager::*, admission::*, admission_strategy::*, channel_group::*, + connection_manager::*, event_manager::*, meter::*, meter_registry::*, provider_leases::*, + provider_lineup_manager::*, provider_session_headers::*, stream::*, streams::*, }; diff --git a/backend/session/src/provider_lineup_manager.rs b/backend/session/src/provider_lineup_manager.rs index 8112adb59..38c72f51c 100644 --- a/backend/session/src/provider_lineup_manager.rs +++ b/backend/session/src/provider_lineup_manager.rs @@ -156,6 +156,26 @@ impl ProviderLineup { } } } + + fn probe_excluding( + &self, + with_grace: bool, + grace_period_timeout_secs: u64, + excluded_providers: &std::collections::HashSet>, + ) -> ProviderAllocation { + match self { + ProviderLineup::Single(lineup) => { + if excluded_providers.contains(&lineup.provider.name) { + ProviderAllocation::Exhausted + } else { + lineup.probe(with_grace, grace_period_timeout_secs) + } + } + ProviderLineup::Multi(lineup) => { + lineup.probe_excluding(with_grace, grace_period_timeout_secs, excluded_providers) + } + } + } } /// Handles a single provider and ensures safe allocation/release of connections. @@ -183,6 +203,10 @@ impl SingleProviderLineup { self.provider.try_allocate(with_grace, grace_period_timeout_secs) } + fn probe(&self, with_grace: bool, grace_period_timeout_secs: u64) -> ProviderAllocation { + self.provider.probe_capacity(with_grace, grace_period_timeout_secs) + } + #[cfg(test)] fn release(&self, provider_name: &Arc) { if &self.provider.name == provider_name { @@ -337,6 +361,63 @@ impl MultiProviderLineup { ProviderAllocation::Exhausted } + fn probe_next_provider_from_group( + priority_group: &ProviderPriorityGroup, + grace: bool, + grace_period_timeout_secs: u64, + excluded_providers: &std::collections::HashSet>, + ) -> ProviderAllocation { + match priority_group { + ProviderPriorityGroup::SingleProviderGroup(p) => { + if excluded_providers.contains(&p.name) { + return ProviderAllocation::Exhausted; + } + let result = p.probe_capacity(grace, grace_period_timeout_secs); + if !matches!(result, ProviderAllocation::Exhausted) { + return result; + } + } + ProviderPriorityGroup::MultiProviderGroup(index, pg) => { + let provider_count = pg.len(); + if provider_count == 0 { + return ProviderAllocation::Exhausted; + } + if grace { + for p in pg { + if excluded_providers.contains(&p.name) { + continue; + } + let result = p.probe_capacity(true, grace_period_timeout_secs); + if !matches!(result, ProviderAllocation::Exhausted) { + return result; + } + } + return ProviderAllocation::Exhausted; + } + + let start = index.load(Ordering::Acquire) % provider_count; + let mut idx = start; + + loop { + let p = &pg[idx]; + if !excluded_providers.contains(&p.name) { + let result = p.probe_capacity(grace, grace_period_timeout_secs); + if !matches!(result, ProviderAllocation::Exhausted) { + return result; + } + } + + idx = (idx + 1) % provider_count; + + if idx == start { + break; + } + } + } + } + ProviderAllocation::Exhausted + } + // Used for redirect to cyclce through provider fn get_next_provider_from_group( priority_group: &ProviderPriorityGroup, @@ -425,6 +506,24 @@ impl MultiProviderLineup { .unwrap_or(ProviderAllocation::Exhausted) } + fn probe_excluding( + &self, + with_grace: bool, + grace_period_timeout_secs: u64, + excluded_providers: &std::collections::HashSet>, + ) -> ProviderAllocation { + self.scan_priority_groups(with_grace, |priority_group, grace| { + let allocation = Self::probe_next_provider_from_group( + priority_group, + grace, + grace_period_timeout_secs, + excluded_providers, + ); + (!matches!(allocation, ProviderAllocation::Exhausted)).then_some(allocation) + }) + .unwrap_or(ProviderAllocation::Exhausted) + } + // It is intended to use with redirects to cycle through providers. fn get_next(&self, grace_period_timeout_secs: u64) -> Option> { self.scan_priority_groups(true, |priority_group, grace| { @@ -930,6 +1029,40 @@ impl ProviderLineupManager { allocation } + pub fn probe_exact_connection_with_grace_override( + &self, + provider_name: &Arc, + allow_grace: bool, + ) -> ProviderAllocation { + let snapshot = self.snapshot.load_full(); + let with_grace = allow_grace && self.grace_period_millis.load(Ordering::Acquire) > 0; + match Self::get_provider_config_by_name(provider_name, &snapshot.providers) { + None => ProviderAllocation::Exhausted, + Some((_lineup, config)) => { + config.probe_capacity(with_grace, self.grace_period_timeout_secs.load(Ordering::Acquire)) + } + } + } + + pub fn probe_connection_with_grace_override_excluding( + &self, + input_name: &Arc, + allow_grace: bool, + excluded_providers: &std::collections::HashSet>, + ) -> ProviderAllocation { + let snapshot = self.snapshot.load_full(); + let lineup_opt = Self::get_provider_config_by_name(input_name, &snapshot.providers); + let with_grace = allow_grace && self.grace_period_millis.load(Ordering::Acquire) > 0; + match lineup_opt { + None => ProviderAllocation::Exhausted, + Some((lineup, _config)) => lineup.probe_excluding( + with_grace, + self.grace_period_timeout_secs.load(Ordering::Acquire), + excluded_providers, + ), + } + } + /// Report that every provider behind an input is at capacity. /// /// The snapshot used to be built only when debug logging was on and diff --git a/backend/session/src/qos_aggregation_manager.rs b/backend/session/src/qos_aggregation_manager.rs index 0a7d19034..5ed6f2bbf 100644 --- a/backend/session/src/qos_aggregation_manager.rs +++ b/backend/session/src/qos_aggregation_manager.rs @@ -47,7 +47,19 @@ pub fn qos_aggregation_is_enabled(config: &Config) -> bool { }) } +pub trait QosAggregationListener: Send + Sync { + fn on_snapshots(&self, snapshots: &[QosSnapshotRecord]); +} + pub fn exec_qos_aggregation(app_config: &Arc, cancel_token: &CancellationToken) { + exec_qos_aggregation_with_listener(app_config, cancel_token, None); +} + +pub fn exec_qos_aggregation_with_listener( + app_config: &Arc, + cancel_token: &CancellationToken, + listener: Option>, +) { let config = app_config.config.load(); let Some(reverse_proxy) = config.reverse_proxy.as_ref() else { return; @@ -81,8 +93,15 @@ pub fn exec_qos_aggregation(app_config: &Arc, cancel_token: &Cancella let today = current_utc_day(); let repo_clone = repo.clone(); let history_dir_clone = history_dir.clone(); + let listener_clone = listener.clone(); if let Err(err) = tokio::task::spawn_blocking(move || { - run_aggregation_once(&repo_clone, &history_dir_clone, &today, compaction_interval_secs) + run_aggregation_once(&repo_clone, &history_dir_clone, &today, compaction_interval_secs)?; + if let Some(listener) = listener_clone { + let mut snapshots = Vec::new(); + let _ = repo_clone.for_each_snapshot(|s| snapshots.push(s.clone())); + listener.on_snapshots(&snapshots); + } + Ok::<(), std::io::Error>(()) }) .await .unwrap_or_else(|e| Err(std::io::Error::other(e.to_string()))) diff --git a/backend/session/src/streams/shared_stream_manager.rs b/backend/session/src/streams/shared_stream_manager.rs index 970aed3a8..46928d041 100644 --- a/backend/session/src/streams/shared_stream_manager.rs +++ b/backend/session/src/streams/shared_stream_manager.rs @@ -112,7 +112,8 @@ struct BudgetedChunk { pub struct SharedStreamState { headers: Vec<(String, String)>, buf_size: usize, - provider_guard: Option, + /// The provider allocation feeding this stream; a group stream replaces it on a member switch. + provider_guard: std::sync::Mutex>, low_priority_preempted: Option, preempted_token: CancellationToken, subscribers: RwLock>, diff --git a/backend/session/src/streams/shared_stream_manager/manager.rs b/backend/session/src/streams/shared_stream_manager/manager.rs index 54fac5e64..ff891405b 100644 --- a/backend/session/src/streams/shared_stream_manager/manager.rs +++ b/backend/session/src/streams/shared_stream_manager/manager.rs @@ -141,11 +141,12 @@ impl SharedStreamManager { } pub(super) async fn finish_unregister(&self, stream_url: &str, state: &SharedStreamState) { - self.remove_meter_uid_if_owned_by(stream_url, state.provider_guard.as_ref().map(|handle| handle.allocation_id)); + self.remove_meter_uid_if_owned_by(stream_url, state.provider_allocation_id()); state.cancel_subscribers().await; state.stop_token.cancel(); - if let Some(handle) = &state.provider_guard { - self.provider_manager.release_handle(handle); + let handle = state.lock_provider_guard().clone(); + if let Some(handle) = handle { + self.provider_manager.release_handle(&handle); } } @@ -166,9 +167,7 @@ impl SharedStreamManager { pub async fn teardown_preempted_stream(&self, stream_url: &str, allocation_id: u64) { let mut register = self.shared_streams.write().await; - if !register.by_key.get(stream_url).is_some_and(|state| { - state.provider_guard.as_ref().is_some_and(|handle| handle.allocation_id == allocation_id) - }) { + if register.by_key.get(stream_url).is_none_or(|state| state.provider_allocation_id() != Some(allocation_id)) { return; } let state = { @@ -399,6 +398,37 @@ impl SharedStreamManager { Some(subscribed_stream) } + /// Releases the provider allocation `expected` of the shared stream `key` while its + /// subscribers keep receiving; a group stream calls this before it opens another member. + pub async fn detach_provider(&self, key: &str, expected: AllocationId) -> bool { + let register = self.shared_streams.read().await; + register.by_key.contains_key(key) && self.provider_manager.detach_shared_allocation(key, expected) + } + + /// Hands the allocation of the member a group stream switched to over to the shared stream + /// `key`. Without the stream the handle is dropped, which releases its slot. + pub async fn attach_provider(&self, key: &str, detached: AllocationId, mut handle: ManagedProviderHandle) -> bool { + // The registry lock keeps the stream from being torn down between attaching the + // allocation and storing its handle, which teardown releases. + let register = self.shared_streams.read().await; + let Some(state) = register.by_key.get(key) else { + return false; + }; + let Some(provider_handle) = handle.handle() else { + return false; + }; + if !self.provider_manager.attach_shared_allocation(key, detached, provider_handle) { + return false; + } + let allocation_id = provider_handle.allocation_id; + let Some(provider_handle) = handle.disarm() else { + return false; + }; + *state.lock_provider_guard() = Some(provider_handle); + self.adopt_meter_uid(key, allocation_id); + true + } + pub async fn subscribe_shared_stream( ctx: SharedStreamCtx<'_>, stream_url: &str, diff --git a/backend/session/src/streams/shared_stream_manager/subscription.rs b/backend/session/src/streams/shared_stream_manager/subscription.rs index b4863c1a5..ed8aef0f0 100644 --- a/backend/session/src/streams/shared_stream_manager/subscription.rs +++ b/backend/session/src/streams/shared_stream_manager/subscription.rs @@ -44,7 +44,7 @@ impl SharedStreamState { Self { headers, buf_size: base_channel_capacity, - provider_guard, + provider_guard: std::sync::Mutex::new(provider_guard), low_priority_preempted, preempted_token: CancellationToken::new(), subscribers: RwLock::new(HashMap::new()), @@ -71,6 +71,14 @@ impl SharedStreamState { pub(super) fn has_pending_joins(&self) -> bool { self.pending_joins.load(Ordering::Acquire) > 0 } + pub(super) fn lock_provider_guard(&self) -> std::sync::MutexGuard<'_, Option> { + self.provider_guard.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + } + + pub(super) fn provider_allocation_id(&self) -> Option { + self.lock_provider_guard().as_ref().map(|handle| handle.allocation_id) + } + pub(super) fn lock_task_handles(&self) -> std::sync::MutexGuard<'_, Vec>> { self.task_handles.lock().unwrap_or_else(std::sync::PoisonError::into_inner) } @@ -341,7 +349,7 @@ impl SharedStreamState { self.lock_task_handles().push(handle); - let provider = self.provider_guard.as_ref().and_then(|h| h.allocation.get_provider_name()); + let provider = self.lock_provider_guard().as_ref().and_then(|h| h.allocation.get_provider_name()); let (permit, request_id, owner) = pending_cleanup.take_cleanup(); ( ReceiverStreamWrapper { diff --git a/backend/session/src/streams/shared_stream_manager/tests.rs b/backend/session/src/streams/shared_stream_manager/tests.rs index 97e5e49e1..8aa799cb1 100644 --- a/backend/session/src/streams/shared_stream_manager/tests.rs +++ b/backend/session/src/streams/shared_stream_manager/tests.rs @@ -91,6 +91,7 @@ fn create_test_stream_channel(url: &str) -> StreamChannel { epg_channel_id: None, epg_reference_ts: None, upstream_user_agent: None, + group_virtual_id: None, } } diff --git a/bin/check-workspace-deps.sh b/bin/check-workspace-deps.sh index f413716f5..2ebcc7f96 100755 --- a/bin/check-workspace-deps.sh +++ b/bin/check-workspace-deps.sh @@ -139,6 +139,7 @@ dev tuliprox -> tuliprox-hls dev tuliprox -> tuliprox-mpegts dev tuliprox -> tuliprox-processing dev tuliprox -> tuliprox-repository +dev tuliprox -> tuliprox-session dev tuliprox-hls -> tuliprox-mpegts dev tuliprox-repository -> tuliprox-btree ALLOWLIST diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 14d85befa..917beb79e 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -23,6 +23,8 @@ - [Metadata Updater & FFprobe](./configuration/metadata-update.md) - [Local Media Library](./configuration/local-library.md) - [source.yml (Inputs, Panel API & Targets)](./configuration/source.md) + - [Channel Groups & Stream Failover](./configuration/channel-groups.md) + - [Channel Groups: Settings Reference](./configuration/channel-groups-settings.md) - [api-proxy.yml (Server, Users & RBAC)](./configuration/api-proxy.md) - [template.yml (Macros & Regex)](./configuration/template.md) - [mapping.yml (Mapper DSL & Logic)](./configuration/mapping-dsl.md) diff --git a/docs/src/configuration/channel-groups-settings.md b/docs/src/configuration/channel-groups-settings.md new file mode 100644 index 000000000..0e3e186ad --- /dev/null +++ b/docs/src/configuration/channel-groups-settings.md @@ -0,0 +1,486 @@ +# Channel Groups: Settings Reference + +This page explains every channel group setting: what it does, which values it takes, its default, when to change it, +and an example. For how channel groups work as a whole, see [Channel Groups & Stream Failover](./channel-groups.md). + +## Where the settings live + +| Where | What | +|:--------------------------------------------------------------|:--------------------------------------------------------------------| +| `config.yml` → `channel_groups` | All settings below. They apply to every target with channel groups. | +| Web UI: **Settings → Channel groups** | Edits the same block. | +| `source.yml` → `targets[].options.channel_groups` | `enabled` and a few [overrides](#target-overrides) for one target. | +| Web UI: **Source editor → Targets → target → Channel groups** | Edits the same target block. | + +Nothing happens until a target sets `enabled: true`. The global block is optional; without it every setting has +its default. + +A complete global block with all defaults: + +```yaml +channel_groups: + match_by: caption + match_as_ascii: false + match_normalize: + strip_regex: null + ignore_tokens: [] + aliases: {} + epg_conflict: split + name_strip_quality: false + policy: auto + allow_provider_grace: true + start: + timeout_secs: 15 + max_attempts: 4 + failover: + enabled: true + no_data_timeout_secs: 8 + slow_source_pct: 90 + slow_source_secs: 15 + max_outage_secs: 20 + same_source_retries: 1 + stable_after_secs: 30 + max_switches: 6 + max_switches_window_secs: 600 + audio_language: prefer + circuit: + source_failures: 2 + cooldown_secs: 60 + cooldown_max_secs: 900 + input_failures: 3 + input_window_secs: 120 +``` + +--- + +## Grouping + +Grouping decides which channels become one channel. It runs during the playlist update, after the target's filters, +renames and mappings, so it sees the channel names as the target outputs them. Changes take effect with the next +playlist update. + +### How two channels are found equal + +For every live channel Tuliprox builds a **match key**. Channels with the same key form a group (a key with a single +channel leaves that channel alone). The key is built in this order: + +1. Take the field chosen by [`match_by`](#match_by). +2. Remove everything [`strip_regex`](#match_normalizestrip_regex) matches. +3. With [`match_as_ascii`](#match_as_ascii), turn accented and special letters into plain letters. +4. Split into words at every character that is not a letter or digit (spaces, `:`, `|`, `-`, brackets, ...). +5. Drop quality words (`SD`, `HD`, `FHD`, `UHD`, `QHD`, `4K`, `480p`, `576p`, `720p`, `1080p`, `1440p`, `2160p`) and + the words of [`ignore_tokens`](#match_normalizeignore_tokens). +6. Join the rest in lower case with single spaces. +7. Replace the key if [`aliases`](#match_normalizealiases) maps it to another key. + +Example with `strip_regex: "^[A-Z]{2}:\\s*"`, `ignore_tokens: [backup]` and `aliases: {"das erste": "ard"}`: + +| Channel name | After step 2 | Words kept (step 5) | Key after step 6 | Final key | +|:--------------------|:----------------|:--------------------|:-----------------|:----------| +| `DE: Das Erste HD` | `Das Erste HD` | `Das`, `Erste` | `das erste` | `ard` | +| `ARD [FHD]` | `ARD [FHD]` | `ARD` | `ard` | `ard` | +| `DE: ARD HD backup` | `ARD HD backup` | `ARD` | `ard` | `ard` | +| `ZDF-HD` | `ZDF-HD` | `ZDF` | `zdf` | `zdf` | + +The first three channels become one group `ard`. `ZDF-HD` stays a normal channel unless another input has a ZDF +channel too. + +### `match_by` + +Which channel field the match key is built from. + +| Value | Uses | +|:-----------------|:-----------------------------------------------------------------------------------------------------------------------| +| `caption` | The title, or the name when the title is empty. Right for most setups. | +| `name` | The `name` field only. | +| `title` | The `title` field only. | +| `epg_channel_id` | The EPG channel id, compared without case. Steps 2 to 7 above do not apply. Channels without EPG id are never grouped. | + +**Default:** `caption`. **Can be overridden per target.** + +Use `epg_channel_id` when your providers name channels differently but carry clean, identical EPG ids – for example +`ZDF HD` at one provider and `ZDF Deutschland` at the other, both with `ZDF.de`. + +```yaml +match_by: epg_channel_id +``` + +### `match_as_ascii` + +Compares names after turning accented and special letters into plain letters: `Télé 5` and `Tele 5` match, as do +`Ö3` and `O3`. + +**Default:** `false`. **Can be overridden per target.** + +Turn it on when one provider writes names with accents and another without. + +### `match_normalize.strip_regex` + +A [regular expression](https://docs.rs/regex/latest/regex/#syntax); every match is removed from the name before the +key is built. Use it for prefixes and suffixes that differ between providers. + +**Default:** none. + +| Goal | `strip_regex` | Name | Becomes | +|:------------------------------------|:--------------------------|:-------------------|:----------| +| Drop a country prefix like `DE:` | `"^[A-Z]{2}:\\s*"` | `DE: ZDF HD` | `ZDF HD` | +| Drop a prefix like `DE -` or `DE /` | `"^[A-Z]{2}\\s*[-/]\\s*"` | `DE - ZDF HD` | `ZDF HD` | +| Drop anything in round brackets | `"\\(.*?\\)"` | `ZDF (Germany) HD` | `ZDF HD` | + +For a prefix with a pipe (`DE | ZDF`) use `"^[A-Z]{2}\\s*\\|\\s*"`. Without stripping, the country code stays a word +of the key: `DE: ZDF` becomes `de zdf` and does not match `ZDF` of a provider without prefix. + +In YAML, write the expression in double quotes and double every backslash (`\\s`). In the Web UI, enter it once +(`^[A-Z]{2}:\s*`). + +### `match_normalize.ignore_tokens` + +Words that are dropped when names are compared, in addition to the quality words. Case does not matter. A token is a +whole word: `backup` drops `ZDF backup` → `zdf`, but not `ZDFbackup`. + +**Default:** empty. + +```yaml +match_normalize: + ignore_tokens: [backup, hevc, h265, raw, "+1"] +``` + +Careful with words that tell channels apart: ignoring `plus` would join `Sky Sport` and `Sky Sport Plus`. + +### `match_normalize.aliases` + +Maps one match key to another, for channels that have different names at different providers. Both sides are +normalized like a channel name first, so write them as you would read them: `"Das Erste"` and `"das erste"` are the +same. + +**Default:** empty. + +```yaml +match_normalize: + aliases: + "das erste": "ard" + "zdf deutschland": "zdf" + "rtl television": "rtl" +``` + +In the Web UI, enter the name on the left and the name it should be compared as on the right. + +### `epg_conflict` + +What happens when channels with the same key carry **different** EPG ids – often a sign that they are different +channels with the same name, for example two regional `WDR` channels. + +| Value | Effect | +|:---------|:---------------------------------------------------------------------------------------------------------------------------------------------| +| `split` | One group per EPG id. Channels without EPG id in such a key stay normal channels. A split part with a single channel stays a normal channel. | +| `ignore` | All channels with the key form one group, whatever their EPG ids. | + +**Default:** `split`. **Can be overridden per target.** + +Example: `WDR HD` (EPG `wdr.koeln`), `WDR FHD` (EPG `wdr.koeln`) and `WDR` (EPG `wdr.dortmund`). With `split`, +the two Köln channels form a group and the Dortmund channel stays alone. With `ignore`, all three are one group. + +Use `ignore` only when your providers use different EPG ids for the same channel and you are sure the names are +unique. + +### `name_strip_quality` + +Removes quality words from the name of the joined channel, so the group is shown as `ZDF` instead of `ZDF HD`. Only +whole, space-separated words are removed: `ZDF [FHD]` becomes `ZDF`, `ZDF-HD` stays `ZDF-HD`. + +**Default:** `false`. **Can be overridden per target.** + +The group takes name, logo and EPG id of its first channel; this setting changes only the name. + +### `policy` + +The order in which the channels of a group ("members") are tried. + +| Value | Order | +|:-----------|:--------------------------------------------------------------------------------------------------------------| +| `auto` | By score: 60 % reliability history, 30 % picture quality, 10 % position in the playlist. Paused members last. | +| `priority` | By position in the playlist (or the order in the overrides file). Paused members last. | + +**Default:** `auto`. **Can be overridden per target.** + +- **Reliability history** comes from the stream history: how often a member started and played without problems in + the last 24 hours and 7 days. It needs `reverse_proxy.stream_history` and `reverse_proxy.qos_aggregation` in + `config.yml`; without them every member counts as average. +- **Picture quality** comes from the metadata prober (resolution and bitrate); without a probe the quality word in + the name is used (`FHD` beats `HD`). +- **Position:** the channel that appears first in the target playlist ranks highest. + +Use `priority` when you want a fixed order, for example "always the paid provider first, the second provider only +as backup". Sort your playlist or the overrides file accordingly. + +--- + +## Start + +These settings control what happens when a client starts a group. They apply to the next stream start. + +### `allow_provider_grace` + +Providers can have a grace connection: one connection above `max_connections` that is allowed for a short time while +an old stream is closing. With this setting a start may use it when no regular connection is free. + +**Default:** `true`. **Can be overridden per target.** + +Switches while watching never use the grace connection. Turn it off for targets with many users on few provider +connections, so a group start does not hold the slot other users need while they zap. + +### `start.timeout_secs` + +The longest time a start may try members – selecting, opening and checking that the provider sends a video stream. +When it runs out, the client gets the "channel unavailable" video. + +**Default:** `15`. `0` means no limit. + +Example: member 1 does not answer (given up after 4 seconds), member 2 answers with HTTP 403, member 3 plays after +2 seconds. The start takes about 6 seconds and stays well inside 15. + +Lower it (for example to `8`) when your players give up early; raise it when your providers are slow to answer. + +### `start.max_attempts` + +The most members a start tries. + +**Default:** `4`. Must be greater than 0. + +With 6 members and `max_attempts: 4`, at most the 4 best-ranked members are tried before the start gives up. + +--- + +## Failover while watching + +When the playing member fails, Tuliprox opens the next member and continues the **same** response, so the player +does not notice more than a short freeze. This works for MPEG-TS streams (direct and shared); HLS streams change the +member only when a new stream starts. Changes apply to the next stream start. + +### `failover.enabled` + +Turns the switch while watching on or off. With `false` a group still starts on the best member, but the stream ends +when its member fails, as for a normal channel. + +**Default:** `true`. **Can be overridden per target** (as `failover: false`). + +Turn it off for a target whose players show artefacts or stop when the stream changes – some built-in TV players +do. VLC, Kodi, ffmpeg-based players and most IPTV apps handle the switch. + +### `failover.no_data_timeout_secs` + +A member that sends no picture and no sound for this long is replaced. + +**Default:** `8`. `0` turns the check off. + +Keep it above the keyframe interval of your channels: after a switch, Tuliprox waits up to 4 seconds for a keyframe +of the new member, and some sports channels send a keyframe only every 5 to 10 seconds. Too short a value replaces +members that are merely slow to start. Raise it to `12`–`15` for providers that pause for a few seconds now and then +without really failing. + +### `failover.slow_source_pct` and `failover.slow_source_secs` + +A member must deliver video at least as fast as it is played. A member that is slower than this percentage of +real-time speed for `slow_source_secs` seconds is replaced. + +**Defaults:** `slow_source_pct: 90`, `slow_source_secs: 15`. `slow_source_pct: 0` turns the check off; at most `100`. + +What the numbers mean: with `90`, the member must deliver at least 9 seconds of video every 10 seconds. A member +that delivers only 7 seconds every 10 seconds drains the player's buffer – after a while the picture stutters or +stops to buffer. If that lasts for 15 seconds, Tuliprox switches before the viewer sees it. + +The speed is measured with the clock inside the stream (PCR), not by counting bytes, so channels with a low bitrate +are not mistaken for slow ones. + +| Situation | Suggestion | +|:-----------------------------------------------------|:---------------------------------------------------------------------| +| Switches happen although the picture is fine | Lower `slow_source_pct` to `80` or raise `slow_source_secs` to `30`. | +| The picture stutters for a long time before a switch | Lower `slow_source_secs` to `8`. | +| You do not want this check | `slow_source_pct: 0`. | + +### `failover.max_outage_secs` + +The longest time without picture while Tuliprox looks for a replacement. When no member plays within this time, the +stream ends (with the "channel unavailable" video, if configured). + +**Default:** `20`. Must be greater than 0. + +During the search, the player receives empty packets every half second so it keeps the connection. A member that +played for 5 seconds (or half of `max_outage_secs`, if that is shorter) counts as working; a later failure starts a +new outage with the full time. + +### `failover.same_source_retries` + +A member that played well (for at least [`stable_after_secs`](#failoverstable_after_secs)) and then fails is first +tried again this many times before another member is used. Short hiccups of a good provider are common; reopening it +is often the best choice. + +**Default:** `1`. `0` switches to another member at once. + +### `failover.stable_after_secs` + +After this much smooth playback a member counts as stable: the current incident is over, earlier failed members may +be tried again, and a new failure first retries the same member (see above). + +**Default:** `30`. + +### `failover.max_switches` and `failover.max_switches_window_secs` + +The switch limit. When more than `max_switches` switches happen within `max_switches_window_secs`, the stream ends +instead of jumping between members that all fail. + +**Defaults:** `max_switches: 6`, `max_switches_window_secs: 600` (10 minutes). `max_switches: 0` turns the limit off. + +Keep the limit on: a provider that answers every request with a short error video would otherwise keep a stream +switching forever. + +### `failover.audio_language` + +Whether a switch must keep the audio language of the member that played (as found by the metadata prober). + +| Value | Effect | +|:----------|:-------------------------------------------------------------------------------------| +| `prefer` | Members with the same language rank higher; others are used when nothing else works. | +| `require` | Only members with the same language (or without a known language) are used. | + +**Default:** `prefer`. + +Use `require` when a group mixes language versions, for example an English and a German feed of the same sports +channel. + +--- + +## Pausing failing members (`circuit`) + +A member that keeps failing is paused, so starts and switches try the others first. Pauses belong to the member +(input and stream), so they are shared by all targets. Answers like "too many connections" (HTTP 429/509) never +count as a failure – the member is fine, the account is just full. + +### `circuit.source_failures` + +How many failures in a row pause a member. A successful start resets the count. + +**Default:** `2`. Must be greater than 0. + +### `circuit.cooldown_secs` and `circuit.cooldown_max_secs` + +How long a member stays paused. After the pause it is tried once; if that fails, the next pause is twice as long, up +to `cooldown_max_secs`. A success ends the pausing. + +**Defaults:** `cooldown_secs: 60`, `cooldown_max_secs: 900`. A `cooldown_max_secs` below `cooldown_secs` is raised to +it. + +Example with the defaults: pauses of 1, 2, 4, 8 minutes, then 15 minutes for every further failure. + +A paused member is not removed: when every member of a group is paused, the best of them is still tried. + +### `circuit.input_failures` and `circuit.input_window_secs` + +When **several different** channels of one input fail within a short time, the provider itself is probably down. Then +the whole input is paused and its members are tried last. + +**Defaults:** `input_failures: 3`, `input_window_secs: 120`. `input_failures` must be at least 2; the failures must +come from at least two different members. + +Example: within 2 minutes, `ZDF` of provider A fails twice and `ARD` of provider A once – 3 failures from 2 members. +All channels of provider A now rank behind provider B until the pause ends. + +--- + +## Target overrides + +A target turns channel groups on with `enabled` and can override these settings. Every override is optional; a +missing one uses the global value. In the Web UI each override offers "Global (*value*)". + +| Key | Overrides | +|:-----------------------|:------------------------------------------------| +| `enabled` | – (target only, default `false`) | +| `match_by` | [`match_by`](#match_by) | +| `match_as_ascii` | [`match_as_ascii`](#match_as_ascii) | +| `epg_conflict` | [`epg_conflict`](#epg_conflict) | +| `name_strip_quality` | [`name_strip_quality`](#name_strip_quality) | +| `policy` | [`policy`](#policy) | +| `allow_provider_grace` | [`allow_provider_grace`](#allow_provider_grace) | +| `failover` | [`failover.enabled`](#failoverenabled) | + +`match_normalize`, `start`, the failover timing and `circuit` exist only globally: the pause state belongs to the +member and is shared by all targets, and the timing describes your providers, not a target. + +--- + +## Recipes + +### Two providers with the same channels + +```yaml +# config.yml – nothing needed, the defaults fit +# source.yml +targets: + - name: tv + options: + channel_groups: + enabled: true +``` + +### Providers with country prefixes and different spellings + +```yaml +# config.yml +channel_groups: + match_as_ascii: true + name_strip_quality: true + match_normalize: + strip_regex: "^[A-Z]{2}\\s*[:|-]\\s*" + ignore_tokens: [backup, hevc] + aliases: + "das erste": "ard" +``` + +### A paid provider first, a cheap one only as backup + +```yaml +# config.yml +channel_groups: + policy: priority +``` + +Put the paid provider's input first in the source, so its channels come first in the playlist. + +### One target for a TV box that cannot handle a switch + +```yaml +# source.yml +targets: + - name: living-room + options: + channel_groups: + enabled: true + failover: false +``` + +### Many users on few provider connections + +```yaml +# source.yml +targets: + - name: family + options: + channel_groups: + enabled: true + allow_provider_grace: false + share_live_streams: + mpeg_ts: true # viewers of the same group share one provider connection +``` + +--- + +## Troubleshooting + +| Problem | Check | +|:---------------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Channels that should be one are not grouped | Compare their names after the target's renames; add `strip_regex`, `ignore_tokens` or `aliases`; check that the EPG ids are equal or set `epg_conflict: ignore`; run a playlist update. | +| Different channels were joined | Keep `epg_conflict: split`; remove too broad `ignore_tokens`; use the mapping field `channel_group: ""` to keep a channel out. | +| A switch shows artefacts for a moment | Normal when the members differ in codec or resolution; members with the same codec and resolution are preferred. Turn `failover` off for players that cannot handle it. | +| Streams switch although the picture was fine | Raise `no_data_timeout_secs`; lower `slow_source_pct` or raise `slow_source_secs`. | +| Streams end after many switches | All members fail; check the providers. `max_switches` ended the stream as intended. | +| A good provider is always tried last | It is paused after failures; wait for the pause to end or check the events `channel_group.*`. | diff --git a/docs/src/configuration/channel-groups.md b/docs/src/configuration/channel-groups.md new file mode 100644 index 000000000..be373a255 --- /dev/null +++ b/docs/src/configuration/channel-groups.md @@ -0,0 +1,226 @@ +# Channel Groups and Stream Failover + +Many setups carry the same live channel from several inputs: two providers, two accounts of one provider, or an +HD and an FHD variant. **Channel groups** collapse these equal channels into **one channel** in the target. When a +client plays it, Tuliprox picks the best reachable source ("member") and, if that source fails while the client is +watching, switches to another member **underneath the open response** – the player keeps its connection and does +not have to reconnect. + +Channel groups are **turned on per target** and are **off by default**. How channels are matched and how sources are +selected and switched is set **once in `config.yml`** under `channel_groups`; a target can override a few of these +settings where it needs to. + +## Quick start + +```yaml +# source.yml +sources: + - inputs: [provider_a, provider_b] + targets: + - name: tv + output: + - type: xtream + options: + channel_groups: + enabled: true +``` + +With this, live channels of `provider_a` and `provider_b` whose caption matches (quality words such as `HD`, `FHD`, +`4K` are ignored) appear once in the `tv` target. All other settings use the defaults of `config.yml`. + +In the Web UI: + +- **Settings → Channel groups** edits the global block of `config.yml`. +- **Source editor → Targets → *target* → Channel groups** turns the feature on for a target and holds its overrides. + Every override offers "Global (*value*)" to keep the global setting. + +## How channels are grouped + +Grouping runs at the end of the target processing, after filters, renames and mappings. + +1. Only live channels take part (`XtreamCluster::Live`, no local library items). +2. Each channel gets a **match key** from the field chosen by `match_by` (`caption`, `name`, `title` or + `epg_channel_id`), normalized with `match_as_ascii` and `match_normalize`. +3. Channels with the same key form a group; a key with a single channel leaves that channel as it is. +4. With `epg_conflict: split`, a group whose members carry **different EPG ids** is split into one group per EPG id, + so channels with different programmes are never mixed. `ignore` keeps such groups together. +5. The group appears as one playlist entry. Its name, logo and EPG id come from its first member; with + `name_strip_quality: true` the quality words are removed from the name. The members disappear from the + playlist but keep their stable virtual ids, so old favourites and direct member requests still play (a request + for a member id plays the group). + +The mapping DSL can steer grouping per channel with the header field `channel_group`: a value forces the channel +into the group with that key, an empty string (`""`) keeps the channel out of any group. + +### Manual groups and corrections + +Per target, an optional overrides file under `/channel_groups/--.yml` holds manual groups +(`groups`), corrections of automatic groups (`auto_overrides`: order, added/removed members, name, logo, EPG id, +policy) and excluded channels (`excluded`). Members are referenced by input name and provider stream id. A manual +group with fewer than two resolvable members is dropped; members that vanished from the provider are +skipped with a warning. There is no Web UI editor for this file yet. + +## How a source is selected + +When a client starts a group, Tuliprox ranks the members and opens the best one that has provider capacity. + +- **Ranking (`policy: auto`).** Members are scored from their QoS history (24 h / 7 d success, needs + `stream_history` and `qos_aggregation` in `config.yml`), their probed quality (resolution, codec, bitrate from the + metadata prober) and their position in the group. Members with an open circuit or a long failure streak go last. + `priority` keeps the configured member order and only moves failing members back. +- **Capacity.** A member with a free provider connection wins over a better member that would need the grace slot + (`allow_provider_grace`) or the preemption of a lower-priority stream. Inputs in maintenance are skipped. +- **Start attempts.** If a member cannot be opened (HTTP error, provider refuses the account, no MPEG-TS/playlist in + the first bytes, no answer), the next member is tried, up to `start.max_attempts` and bounded by + `start.timeout_secs`. A provider that does not answer at all is given up after 4 seconds per member. +- **Pausing failing sources.** `circuit.source_failures` consecutive failures of one member pause it for + `circuit.cooldown_secs`, doubling up to `circuit.cooldown_max_secs` while it keeps failing. Failures of at least two + different members of one input within `circuit.input_window_secs` (`circuit.input_failures` in total) put the whole + input last. This state is shared by all targets, because it belongs to the source, not to the target. + "Too many connections" answers (`429`/`509`) never open a circuit. + +HLS entries use the same start selection; with the shared HLS cache enabled, viewers of the same member share one +origin session. + +## Failover while watching (MPEG-TS) + +With `failover.enabled` (default on), MPEG-TS playback – direct or [shared](./source.md#326-options) – replaces a +running member when + +- its provider body ends or fails (`upstream_eof`, `upstream_error`), +- it sends no media for `failover.no_data_timeout_secs` (`stall`), +- it is too slow (`underrun`): it delivers less than `failover.slow_source_pct` percent of real-time speed for + `failover.slow_source_secs`. With 90 % a source must deliver at least 9 seconds of video every 10 seconds; + a slower source drains the player's buffer and the picture stutters. The speed is measured from the stream's PCR. + +With `failover.enabled: false` a group still starts on the best source, but the stream ends with its member. + +The switch keeps the client response open. While the next member opens, null packets are sent every 500 ms so players +and proxies do not time out. The new member is spliced in at a clean point: the output continues with its PAT and +PMT (version numbers continue the previous member's, CRC recomputed) followed by a keyframe, and the first packet of +each stream carries the MPEG-TS `discontinuity_indicator`. Players such as VLC, ffmpeg-based players and Kodi handle +this well; some built-in TV players may stutter when codec or resolution change between members, so the ranking +prefers members with the same codec, resolution and audio as the one that played. + +Rules of a switch: + +- A member that played for at least `failover.stable_after_secs` is retried `failover.same_source_retries` times before + others. +- Runtime switches never use the provider grace slot and never preempt other streams. +- A member whose slot was preempted by a higher-priority stream is not failed over; the stream ends as before. +- Members that refuse the account or have no capacity, or that deliver no MPEG-TS, are not asked again within the + same switch. Others are retried in rounds with a growing pause (0.5 s up to 4 s). +- **Outage limit.** The time without playback while looking for a replacement is bounded by `failover.max_outage_secs`. + A member counts as playing once it has sent media for 5 seconds (or half of `max_outage_secs`, if that is shorter); + then a later failure starts a fresh outage limit. A provider that answers every request with an error clip longer + than that therefore keeps the stream alive between switches – `failover.max_switches` is the outer bound for that + case. +- **Switch limit.** More than `failover.max_switches` switches within `failover.max_switches_window_secs` end the stream. +- When the outage limit or the switch limit ends the stream, the `channel_unavailable` custom video is shown if + configured. + +Each switch emits the event `channel_group.member_switched` (with the reason); start selections emit +`channel_group.member_selected`, exhausted groups `channel_group.exhausted`. + +HLS playback switches members only when a new entry is started; HLS runtime failover is not implemented. + +## Global settings (`config.yml` → `channel_groups`) + +All keys are optional; the block itself can be left out. Every setting is explained with examples in the +[Settings Reference](./channel-groups-settings.md). + +| Parameter | Type | Default | Description | +|:-----------------------|:-----|:----------|:-----------------------------------------------------------------------------------------------------------------| +| `match_by` | Enum | `caption` | Field that decides which channels are equal: `caption`, `name`, `title`, `epg_channel_id`. | +| `match_as_ascii` | Bool | `false` | Compares match keys after transliteration to ASCII ("Télé" matches "Tele"). | +| `match_normalize` | Map | empty | Extra normalization: `strip_regex` (removed from the key), `ignore_tokens` (words ignored), `aliases` (key map). | +| `epg_conflict` | Enum | `split` | `split` separates members with different EPG ids into own groups, `ignore` keeps them together. | +| `name_strip_quality` | Bool | `false` | Removes quality words (`HD`, `FHD`, `4K`, ...) from the group's name. | +| `policy` | Enum | `auto` | Member order: `auto` (ranked by QoS, quality, order) or `priority` (configured order, failing members last). | +| `allow_provider_grace` | Bool | `true` | A start may use the provider grace slot when no free connection is left. Switches while watching never do. | +| `start` | Map | see below | How a start picks a member. | +| `failover` | Map | see below | Switching members while watching. | +| `circuit` | Map | see below | Pausing failing members and inputs. | + +### `start` + +| Parameter | Type | Default | Description | +|:---------------|:-----|:--------|:-------------------------------------------------------------------------------------| +| `timeout_secs` | Int | `15` | Longest time a start may try members before the client gets an error. `0`: no limit. | +| `max_attempts` | Int | `4` | Most members tried at start. Must be greater than 0. | + +### `failover` + +| Parameter | Type | Default | Description | +|:---------------------------|:-----|:---------|:--------------------------------------------------------------------------------------------------| +| `enabled` | Bool | `true` | Switches to another member while a client watches an MPEG-TS stream. | +| `no_data_timeout_secs` | Int | `8` | Seconds without media before a member is replaced. Keep it above the keyframe interval. `0`: off. | +| `slow_source_pct` | Int | `90` | Minimum share of real-time speed a member must deliver. `0`: off; at most 100. | +| `slow_source_secs` | Int | `15` | How long a member may stay below `slow_source_pct` before it is replaced. | +| `max_outage_secs` | Int | `20` | Longest time without playback while looking for a replacement. Must be greater than 0. | +| `same_source_retries` | Int | `1` | Retries of a member that played stably before other members are tried. | +| `stable_after_secs` | Int | `30` | Playback after which a member counts as stable and an incident is over. | +| `max_switches` | Int | `6` | Most switches within `max_switches_window_secs`; then the stream ends. `0`: no limit. | +| `max_switches_window_secs` | Int | `600` | Window of `max_switches`. | +| `audio_language` | Enum | `prefer` | Switches `prefer` members with the same audio language, or `require` it. | + +### `circuit` + +| Parameter | Type | Default | Description | +|:--------------------|:-----|:--------|:--------------------------------------------------------------------------------------------------| +| `source_failures` | Int | `2` | Consecutive failures that pause a member. Must be greater than 0. | +| `cooldown_secs` | Int | `60` | First pause of a failing member; doubles while it keeps failing. | +| `cooldown_max_secs` | Int | `900` | Longest pause. Raised to `cooldown_secs` when lower. | +| `input_failures` | Int | `3` | Failures of one input (from at least two members) within the window that put the input last. ≥ 2. | +| `input_window_secs` | Int | `120` | Window of `input_failures`. | + +Changes to grouping settings take effect with the next playlist update; selection and failover settings apply to the +next stream start. + +## Target settings (`source.yml` → `targets[].options.channel_groups`) + +| Parameter | Type | Default | Description | +|:-----------------------|:-----|:---------|:---------------------------------------------------------------------------------| +| `enabled` | Bool | `false` | Turns channel groups on for this target. | +| `match_by` | Enum | (global) | Overrides `match_by`, e.g. `epg_channel_id` for a target with clean EPG ids. | +| `match_as_ascii` | Bool | (global) | Overrides `match_as_ascii`. | +| `epg_conflict` | Enum | (global) | Overrides `epg_conflict`. | +| `name_strip_quality` | Bool | (global) | Overrides `name_strip_quality`. | +| `policy` | Enum | (global) | Overrides `policy`. | +| `allow_provider_grace` | Bool | (global) | Overrides `allow_provider_grace`, e.g. `false` for a target with many users. | +| `failover` | Bool | (global) | Overrides `failover.enabled`, e.g. `false` for players that stutter on a switch. | + +Timing, `start`, `circuit` and `match_normalize` exist only globally: the pause state of a source is shared by all +targets, and the timing describes the providers, not the target. + +## Example + +```yaml +# config.yml +channel_groups: + name_strip_quality: true + match_normalize: + strip_regex: "^[A-Z]{2}:\\s*" # drop country prefixes like "DE: " + ignore_tokens: [hevc, backup] + aliases: + "das erste": "ard" + failover: + no_data_timeout_secs: 10 + max_outage_secs: 30 +``` + +```yaml +# source.yml +targets: + - name: family + options: + channel_groups: + enabled: true + share_live_streams: + mpeg_ts: true # optional: one provider connection per group for all viewers + - name: tv-box + options: + channel_groups: + enabled: true + failover: false # this player stutters on a switch; keep the start selection only +``` diff --git a/docs/src/configuration/config.md b/docs/src/configuration/config.md index 0a040a683..e2ebaa6b9 100644 --- a/docs/src/configuration/config.md +++ b/docs/src/configuration/config.md @@ -40,6 +40,7 @@ hdhomerun: library: reverse_proxy: metadata_update: +channel_groups: ``` ### Global System & Storage Settings (Flat Keys) @@ -87,20 +88,21 @@ instead of your mount point. ### Subsections (Object Keys) -| Block | Description | Link | -|:------------------|:----------------------------------------------------------------------------------|:------------------------------------------------| -| `api` | Internal web server binding settings. | [See section](#1-api-server-api) | -| `web_ui` | Web Dashboard, RBAC, and Authentication. | [See section](#2-web-ui--administration-web_ui) | -| `log` | Console output verbosity and sanitization. | [See section](#3-logging-log) | -| `schedules` | Automated background tasks (Cronjobs). | [See section](#4-schedules-schedules) | -| `messaging` | Webhooks & Push-Notifications (Telegram, Discord, etc.). | [See section](#5-messaging-messaging) | -| `video` | Extension mapping and Web UI download behavior. | [See section](#6-video--web-search-video) | -| `proxy` | SOCKS5/HTTP proxy settings for outgoing requests. | [See section](#7-outgoing-proxy-proxy) | -| `ipcheck` | IP detection to verify in the Web UI which public IP Tuliprox is currently using. | [See section](#8-ip-check-ipcheck) | -| `hdhomerun` | Virtual DVB-C/T network tuner emulation. | [See section](#9-hdhomerun-emulation-hdhomerun) | -| `library` | Local Media Library integration. | [See Local Library](./local-library.md) | -| `reverse_proxy` | Streaming buffers, rate limits, caching. | [See Reverse Proxy](./reverse-proxy.md) | -| `metadata_update` | TMDB matching, FFprobe processing, Job Queues. | [See Metadata Update](./metadata-update.md) | +| Block | Description | Link | +|:------------------|:----------------------------------------------------------------------------------|:---------------------------------------------------| +| `api` | Internal web server binding settings. | [See section](#1-api-server-api) | +| `web_ui` | Web Dashboard, RBAC, and Authentication. | [See section](#2-web-ui--administration-web_ui) | +| `log` | Console output verbosity and sanitization. | [See section](#3-logging-log) | +| `schedules` | Automated background tasks (Cronjobs). | [See section](#4-schedules-schedules) | +| `messaging` | Webhooks & Push-Notifications (Telegram, Discord, etc.). | [See section](#5-messaging-messaging) | +| `video` | Extension mapping and Web UI download behavior. | [See section](#6-video--web-search-video) | +| `proxy` | SOCKS5/HTTP proxy settings for outgoing requests. | [See section](#7-outgoing-proxy-proxy) | +| `ipcheck` | IP detection to verify in the Web UI which public IP Tuliprox is currently using. | [See section](#8-ip-check-ipcheck) | +| `hdhomerun` | Virtual DVB-C/T network tuner emulation. | [See section](#9-hdhomerun-emulation-hdhomerun) | +| `library` | Local Media Library integration. | [See Local Library](./local-library.md) | +| `reverse_proxy` | Streaming buffers, rate limits, caching. | [See Reverse Proxy](./reverse-proxy.md) | +| `metadata_update` | TMDB matching, FFprobe processing, Job Queues. | [See Metadata Update](./metadata-update.md) | +| `channel_groups` | Matching, source selection and failover of channel groups. | [See Channel Groups](./channel-groups-settings.md) | *(Note: The advanced topics **Local Library**, **Reverse Proxy** and **Metadata Update** are extremely extensive and have their own diff --git a/docs/src/configuration/source.md b/docs/src/configuration/source.md index 466405de2..3978e6b84 100644 --- a/docs/src/configuration/source.md +++ b/docs/src/configuration/source.md @@ -1620,6 +1620,8 @@ targets: match_by: caption keep: best_quality match_as_ascii: false + channel_groups: + enabled: true ``` #### Target Option Parameters @@ -1635,6 +1637,7 @@ targets: | `epg_output.adopt_guide_id_case` | Bool | No | `true` | Rewrites a live channel EPG id that matches a guide channel only case-insensitively (for example `usa4k.us` against ``) to the guide's exact spelling, so the M3U `tvg-id`, Xtream `epg_channel_id`, XMLTV `` and EPG API lookups use the same id. Set `false` to keep the playlist's original spelling. Changing this option requires a full target refresh. | | `epg_output.lowercase_ids` | Bool | No | `false` | Canonicalizes visible technical EPG IDs with ASCII lowercase across M3U `tvg-id`, Xtream `epg_channel_id`, XMLTV channel/programme references, and EPG API responses. Changing this option requires a full target refresh. | | `epg_output.lowercase_xmltv_display_names` | Bool | No | `false` | Applies Unicode lowercase exclusively to XMLTV `` values during serialization. Playlist names, Xtream names, programme titles, and programme descriptions remain unchanged; persisted target data does not require rebuilding. | +| `channel_groups` | Map | No | - | Turns channel groups on for this target (`enabled`) and overrides single global settings of `config.yml` `channel_groups`. See [Channel Groups & Stream Failover](./channel-groups.md). | | `force_redirect` | Bool | No | `false` | Optional redirect-related behavior switch. This influences how Tuliprox serves final stream delivery where redirect-style output handling is required by the deployment model. | > **Shared HLS:** `share_live_streams.hls` requires `reverse_proxy.hls_cache` in `config.yml`. diff --git a/docs/src/features.md b/docs/src/features.md index b58bc8722..f72011251 100644 --- a/docs/src/features.md +++ b/docs/src/features.md @@ -40,6 +40,8 @@ That makes it usable both for IPTV players and for media-server-oriented workflo - put inputs into maintenance mode: new streams get a maintenance video, the provider is not contacted - persist optional stream history for per-variant connect/disconnect, startup-failure, and reconnect telemetry - aggregate optional QoS snapshots from stream history to rank equivalent channel variants by reliability +- group equal live channels from several inputs into one channel with source selection and runtime failover + ([Channel Groups](./configuration/channel-groups.md)) ## Metadata and library diff --git a/frontend/public/assets/i18n/ar.json b/frontend/public/assets/i18n/ar.json index 153ec6b4a..1c0d07c23 100644 --- a/frontend/public/assets/i18n/ar.json +++ b/frontend/public/assets/i18n/ar.json @@ -873,6 +873,45 @@ "MAINTENANCE": { "INPUT": "المدخل الذي يتم إيقافه عن الخدمة. أثناء الصيانة تحصل البثوث الجديدة على فيديو الصيانة ولا يتصل Tuliprox بمزوده؛ تستمر البثوث والتسجيلات الجارية.", "REASON": "ملاحظة اختيارية تظهر في قائمة الصيانة، مثل سبب تعطل المزود. 500 حرف كحد أقصى." + }, + "CHANNEL_GROUPS_TARGET_CONFIG": { + "ENABLED": "يجمع القنوات المباشرة المتطابقة في هذا الهدف في قناة واحدة. يبدأ التشغيل بأفضل مصدر متاح وينتقل إلى مصدر آخر عند تعطله." + }, + "CHANNEL_GROUPS_CONFIG": { + "MATCH_BY": "الحقل الذي يحدد أن قناتين متطابقتان: caption أو name أو title أو معرّف قناة EPG. يتم تجاهل كلمات الجودة مثل HD أو FHD.", + "MATCH_AS_ASCII": "يقارن الأسماء بعد تحويل الحروف المشكّلة والخاصة إلى حروف بسيطة، فتتطابق \"Télé\" و\"Tele\".", + "EPG_CONFLICT": "split: القنوات ذات الاسم نفسه ومعرّفات EPG مختلفة تبقى في مجموعات منفصلة (برامج مختلفة). ignore: تُجمع رغم ذلك.", + "NAME_STRIP_QUALITY": "يزيل كلمات الجودة مثل HD أو FHD أو 4K من اسم القناة المجمّعة.", + "POLICY": "auto: يُشغَّل أولًا المصدر ذو السجل وجودة الصورة الأفضل. priority: تُستخدم المصادر بترتيب القائمة.", + "ALLOW_PROVIDER_GRACE": "إذا لم يكن لدى المزود اتصال حر، يمكن للبدء استخدام اتصال السماح. التبديل أثناء المشاهدة لا يفعل ذلك أبدًا." + }, + "CHANNEL_MATCH_NORMALIZE": { + "STRIP_REGEX": "تعبير نمطي يُزال من الأسماء قبل المقارنة، مثل \"^[A-Z]{2}:\\s*\" لبادئات الدول مثل \"DE: \".", + "IGNORE_TOKENS": "كلمات يتم تجاهلها عند مقارنة الأسماء، مثل \"backup\" أو \"hevc\".", + "ALIASES": "أسماء للقناة نفسها: يُقارن الاسم الأيسر كالاسم الأيمن، مثل \"das erste\" كـ \"ard\"." + }, + "CHANNEL_GROUP_START_CONFIG": { + "TIMEOUT_SECS": "أطول مدة يجرب فيها البدء المصادر قبل أن يتلقى العميل فيديو عدم التوفر. 0 تعني بلا حد.", + "MAX_ATTEMPTS": "أقصى عدد من المصادر يجربها البدء." + }, + "CHANNEL_GROUP_FAILOVER_CONFIG": { + "ENABLED": "عند تعطل المصدر، يفتح Tuliprox مصدرًا آخر من المجموعة ويكمل البث نفسه، ويحتفظ المشغل باتصاله. لـ MPEG-TS فقط؛ HLS يغيّر المصدر فقط عند بدء بث جديد.", + "NO_DATA_TIMEOUT_SECS": "يُستبدل المصدر الذي لا يرسل صورة ولا صوتًا لهذه المدة. اجعلها أكبر من فاصل الإطارات الرئيسية (بضع ثوانٍ). 0 يعطّل الفحص.", + "SLOW_SOURCE_PCT": "يجب أن يقدّم المصدر هذه النسبة على الأقل من السرعة الفعلية. 90 تعني: خلال 10 ثوانٍ يجب أن يقدّم 9 ثوانٍ من الفيديو، وإلا يفرغ مخزن المشغل وتتقطع الصورة. 0 يعطّل الفحص.", + "SLOW_SOURCE_SECS": "المدة التي يمكن أن يبقى فيها المصدر دون الحد الأدنى للسرعة قبل استبداله.", + "MAX_OUTAGE_SECS": "أطول مدة دون صورة أثناء البحث عن بديل. بعدها ينتهي البث.", + "SAME_SOURCE_RETRIES": "يُعاد تجربة المصدر الذي كان يعمل جيدًا هذا العدد من المرات قبل استخدام مصدر آخر.", + "STABLE_AFTER_SECS": "بعد هذا القدر من التشغيل السلس يُعد المصدر مستقرًا ويبدأ العطل محاولة جديدة.", + "MAX_SWITCHES": "أقصى عدد من تبديلات المصدر ضمن النافذة. بعدها ينتهي البث بدل التنقل بين مصادر معطلة. 0 يعطّل الحد.", + "MAX_SWITCHES_WINDOW_SECS": "النافذة الزمنية لأقصى عدد تبديلات.", + "AUDIO_LANGUAGE": "prefer: يفضّل التبديل مصدرًا بلغة الصوت نفسها. require: تُستخدم فقط المصادر بلغة الصوت نفسها." + }, + "CHANNEL_GROUP_CIRCUIT_CONFIG": { + "SOURCE_FAILURES": "أعطال متتالية يُوقف بعدها المصدر مؤقتًا وتُجرّب مصادر أخرى أولًا.", + "COOLDOWN_SECS": "أول إيقاف للمصدر المتعطل. يتضاعف ما دام المصدر يتعطل.", + "COOLDOWN_MAX_SECS": "أطول إيقاف للمصدر المتعطل.", + "INPUT_FAILURES": "أعطال قنوات مختلفة لمدخل واحد ضمن النافذة يُجرَّب بعدها المدخل كله أخيرًا (المزود متوقف).", + "INPUT_WINDOW_SECS": "النافذة الزمنية لأعطال المدخل." } }, "HINT": { @@ -907,7 +946,9 @@ "PROCESS_PARALLEL": "المعالجة المتوازية: إذا كان لديك عدة مزودين، يمكنك تفعيلها. الوصول إلى مزودك بالتوازي يمكن أن يسبب حظرًا.", "RESTART_TO_APPLY_CHANGES": "تحتاج إلى إعادة التشغيل لتطبيق التغييرات.", "SCHEDULE_EXAMPLE": "مثال على الجدول", - "WEB_ROOT": "Web-Root: يحتوي على ملفات واجهة الويب. عادة ما يتم وضعها في نفس الدليل تحت ./web" + "WEB_ROOT": "Web-Root: يحتوي على ملفات واجهة الويب. عادة ما يتم وضعها في نفس الدليل تحت ./web", + "CHANNEL_GROUPS_CONFIG": "تجمع مجموعات القنوات القناة المباشرة نفسها من عدة مدخلات في قناة واحدة. تنطبق هذه الإعدادات على كل هدف يفعّل مجموعات القنوات، ويمكن للهدف تجاوز بعضها في صفحة مجموعات القنوات الخاصة به.", + "CHANNEL_GROUPS_TARGET": "يفعّل مجموعات القنوات لهذا الهدف. الإعدادات المتروكة على \"عام\" تستخدم الإعدادات > مجموعات القنوات." }, "LABEL": { "HLS_STARTUP_LIMITS": "حدود البدء السريع", @@ -1934,7 +1975,42 @@ "RECORDING_RULE_DELETE_RETAIN": "حذف القاعدة والإبقاء على التسجيلات القادمة", "RECORDING_RULE_DELETE_CANCEL": "حذف القاعدة وإلغاء التسجيلات القادمة", "RECORDING_RULE_ACTION_ENABLE": "تمكين", - "RECORDING_RULE_ACTION_DISABLE": "تعطيل" + "RECORDING_RULE_ACTION_DISABLE": "تعطيل", + "CHANNEL_GROUPS": "مجموعات القنوات", + "CHANNEL_GROUPS_MATCH_BY": "المطابقة حسب", + "CHANNEL_GROUPS_ALLOW_PROVIDER_GRACE": "السماح باتصال السماح لدى المزود", + "CHANNEL_GROUPS_CONFIG": "مجموعات القنوات", + "CHANNEL_GROUPS_GLOBAL": "عام", + "CHANNEL_GROUPS_MATCH_AS_ASCII": "المقارنة كـ ASCII", + "CHANNEL_GROUPS_STRIP_REGEX": "إزالة من الاسم (regex)", + "CHANNEL_GROUPS_IGNORE_TOKENS": "كلمات متجاهلة", + "CHANNEL_GROUPS_ALIASES": "أسماء بديلة", + "CHANNEL_GROUPS_EPG_CONFLICT": "معرّفات EPG مختلفة", + "CHANNEL_GROUPS_NAME_STRIP_QUALITY": "إزالة كلمات الجودة من الاسم", + "CHANNEL_GROUPS_POLICY": "ترتيب المصادر", + "CHANNEL_GROUPS_FAILOVER": "تبديل المصدر أثناء المشاهدة", + "CHANNEL_GROUPS_GROUPING": "التجميع", + "CHANNEL_GROUPS_START": "البدء", + "CHANNEL_GROUPS_CIRCUIT": "إيقاف المصادر المتعطلة مؤقتًا", + "CHANNEL_GROUPS_START_TIMEOUT_SECS": "مهلة البدء (ث)", + "CHANNEL_GROUPS_START_MAX_ATTEMPTS": "المصادر المجرّبة عند البدء", + "CHANNEL_GROUPS_NO_DATA_TIMEOUT_SECS": "مهلة انعدام البيانات (ث)", + "CHANNEL_GROUPS_SLOW_SOURCE_PCT": "الحد الأدنى للسرعة (%)", + "CHANNEL_GROUPS_SLOW_SOURCE_SECS": "بطيء جدًا لمدة (ث)", + "CHANNEL_GROUPS_MAX_OUTAGE_SECS": "أقصى انقطاع (ث)", + "CHANNEL_GROUPS_SAME_SOURCE_RETRIES": "إعادة محاولات المصدر نفسه", + "CHANNEL_GROUPS_STABLE_AFTER_SECS": "مستقر بعد (ث)", + "CHANNEL_GROUPS_MAX_SWITCHES": "أقصى عدد تبديلات", + "CHANNEL_GROUPS_MAX_SWITCHES_WINDOW_SECS": "نافذة التبديلات (ث)", + "CHANNEL_GROUPS_AUDIO_LANGUAGE": "لغة الصوت", + "CHANNEL_GROUPS_SOURCE_FAILURES": "الأعطال حتى الإيقاف", + "CHANNEL_GROUPS_COOLDOWN_SECS": "الإيقاف (ث)", + "CHANNEL_GROUPS_COOLDOWN_MAX_SECS": "أقصى إيقاف (ث)", + "CHANNEL_GROUPS_INPUT_FAILURES": "أعطال المدخل حتى الإيقاف", + "CHANNEL_GROUPS_INPUT_WINDOW_SECS": "نافذة أعطال المدخل (ث)", + "CHANNEL_GROUPS_IGNORE_TOKEN": "مثال: backup", + "CHANNEL_GROUPS_ALIAS_NAME": "الاسم", + "CHANNEL_GROUPS_ALIAS_TARGET": "يُقارن كـ" }, "MESSAGES": { "CLIPBOARD_NOT_SUPPORTED": "الحافظة غير مدعومة.\nمتصفحك أو السياق الحالي لا يسمح بالوصول إلى الحافظة.\nيرجى استخدام HTTPS أو localhost.", diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 9210a59a3..83f453dbd 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -873,6 +873,45 @@ "MAINTENANCE": { "INPUT": "Input to take out of service. While it is in maintenance, new streams get the maintenance video and Tuliprox does not contact its provider; running streams and recordings continue.", "REASON": "Optional remark shown in the maintenance list, for example why the provider is down. At most 500 characters." + }, + "CHANNEL_GROUPS_TARGET_CONFIG": { + "ENABLED": "Joins equal live channels of this target into one channel. Playback starts on the best reachable source and switches to another one when it fails." + }, + "CHANNEL_GROUPS_CONFIG": { + "MATCH_BY": "Which channel field decides that two channels are the same: caption, name, title or EPG channel id. Quality words like HD or FHD are ignored.", + "MATCH_AS_ASCII": "Compares names after turning accents and special letters into plain letters, so \"Télé\" and \"Tele\" match.", + "EPG_CONFLICT": "split: channels with the same name but different EPG ids stay separate groups (different programmes). ignore: they are joined anyway.", + "NAME_STRIP_QUALITY": "Removes quality words such as HD, FHD or 4K from the name of the joined channel.", + "POLICY": "auto: the source with the best history and picture quality plays first. priority: sources play in the order of the list.", + "ALLOW_PROVIDER_GRACE": "When a provider has no free connection, a start may use its grace connection. A switch while watching never does." + }, + "CHANNEL_MATCH_NORMALIZE": { + "STRIP_REGEX": "Regular expression removed from names before comparing, for example \"^[A-Z]{2}:\\s*\" for country prefixes like \"DE: \".", + "IGNORE_TOKENS": "Words ignored when comparing names, for example \"backup\" or \"hevc\".", + "ALIASES": "Names that mean the same channel: the left name is compared as the right one, for example \"das erste\" as \"ard\"." + }, + "CHANNEL_GROUP_START_CONFIG": { + "TIMEOUT_SECS": "Longest time a start may try sources before the client gets the unavailable video. 0 means no limit.", + "MAX_ATTEMPTS": "How many sources a start tries at most." + }, + "CHANNEL_GROUP_FAILOVER_CONFIG": { + "ENABLED": "When the playing source fails, Tuliprox opens another source of the group and continues the same stream, the player keeps its connection. Only for MPEG-TS; HLS changes the source only when a new stream starts.", + "NO_DATA_TIMEOUT_SECS": "A source that sends no picture or sound for this long is replaced. Keep it above the keyframe interval of your channels (a few seconds). 0 turns the check off.", + "SLOW_SOURCE_PCT": "A source must deliver at least this share of real-time speed. 90 means: in 10 seconds it must deliver 9 seconds of video, otherwise the player buffer runs empty and the picture stutters. 0 turns the check off.", + "SLOW_SOURCE_SECS": "How long a source may stay below the minimum speed before it is replaced.", + "MAX_OUTAGE_SECS": "Longest time without picture while looking for a replacement. Then the stream ends.", + "SAME_SOURCE_RETRIES": "A source that played well before is tried again this many times before another source is used.", + "STABLE_AFTER_SECS": "After this much smooth playback a source counts as stable and a failure starts a new attempt.", + "MAX_SWITCHES": "Most source switches within the switch window. Then the stream ends instead of jumping between broken sources. 0 turns the limit off.", + "MAX_SWITCHES_WINDOW_SECS": "Time window for the maximum number of switches.", + "AUDIO_LANGUAGE": "prefer: a switch prefers a source with the same audio language. require: only sources with the same audio language are used." + }, + "CHANNEL_GROUP_CIRCUIT_CONFIG": { + "SOURCE_FAILURES": "Failures in a row after which a source is paused and other sources are tried first.", + "COOLDOWN_SECS": "First pause of a failing source. It doubles while the source keeps failing.", + "COOLDOWN_MAX_SECS": "Longest pause of a failing source.", + "INPUT_FAILURES": "Failures of different channels of one input within the window after which the whole input is tried last (provider down).", + "INPUT_WINDOW_SECS": "Time window for the input failures." } }, "HINT": { @@ -907,7 +946,9 @@ "PROCESS_PARALLEL": "Process Parallel: If you have multiple providers, you can enable it. Accessing your provider in parallel can cause a ban.", "RESTART_TO_APPLY_CHANGES": "You need to restart to apply changes.", "SCHEDULE_EXAMPLE": "Schedule example", - "WEB_ROOT": "Web-Root: Contains the web-ui files. They are normally placed in the same directory under ./web" + "WEB_ROOT": "Web-Root: Contains the web-ui files. They are normally placed in the same directory under ./web", + "CHANNEL_GROUPS_CONFIG": "Channel groups join the same live channel from several inputs into one channel. These settings apply to every target that turns channel groups on; a target can override some of them on its own Channel groups page.", + "CHANNEL_GROUPS_TARGET": "Turns channel groups on for this target. Settings left on \"Global\" use Settings > Channel groups." }, "LABEL": { "HLS_STARTUP_LIMITS": "Fast start limits", @@ -1934,7 +1975,42 @@ "RECORDING_RULE_DELETE_RETAIN": "Delete rule, keep upcoming recordings", "RECORDING_RULE_DELETE_CANCEL": "Delete rule and cancel upcoming recordings", "RECORDING_RULE_ACTION_ENABLE": "Enable", - "RECORDING_RULE_ACTION_DISABLE": "Disable" + "RECORDING_RULE_ACTION_DISABLE": "Disable", + "CHANNEL_GROUPS": "Channel groups", + "CHANNEL_GROUPS_MATCH_BY": "Match by", + "CHANNEL_GROUPS_ALLOW_PROVIDER_GRACE": "Allow provider grace", + "CHANNEL_GROUPS_CONFIG": "Channel groups", + "CHANNEL_GROUPS_GLOBAL": "Global", + "CHANNEL_GROUPS_MATCH_AS_ASCII": "Compare as ASCII", + "CHANNEL_GROUPS_STRIP_REGEX": "Remove from name (regex)", + "CHANNEL_GROUPS_IGNORE_TOKENS": "Ignored words", + "CHANNEL_GROUPS_ALIASES": "Aliases", + "CHANNEL_GROUPS_EPG_CONFLICT": "Different EPG ids", + "CHANNEL_GROUPS_NAME_STRIP_QUALITY": "Remove quality words from name", + "CHANNEL_GROUPS_POLICY": "Source order", + "CHANNEL_GROUPS_FAILOVER": "Switch source while watching", + "CHANNEL_GROUPS_GROUPING": "Grouping", + "CHANNEL_GROUPS_START": "Start", + "CHANNEL_GROUPS_CIRCUIT": "Pause failing sources", + "CHANNEL_GROUPS_START_TIMEOUT_SECS": "Start timeout (s)", + "CHANNEL_GROUPS_START_MAX_ATTEMPTS": "Sources tried at start", + "CHANNEL_GROUPS_NO_DATA_TIMEOUT_SECS": "No data timeout (s)", + "CHANNEL_GROUPS_SLOW_SOURCE_PCT": "Minimum speed (%)", + "CHANNEL_GROUPS_SLOW_SOURCE_SECS": "Too slow for (s)", + "CHANNEL_GROUPS_MAX_OUTAGE_SECS": "Max. outage (s)", + "CHANNEL_GROUPS_SAME_SOURCE_RETRIES": "Retries of the same source", + "CHANNEL_GROUPS_STABLE_AFTER_SECS": "Stable after (s)", + "CHANNEL_GROUPS_MAX_SWITCHES": "Max. switches", + "CHANNEL_GROUPS_MAX_SWITCHES_WINDOW_SECS": "Switch window (s)", + "CHANNEL_GROUPS_AUDIO_LANGUAGE": "Audio language", + "CHANNEL_GROUPS_SOURCE_FAILURES": "Failures until pause", + "CHANNEL_GROUPS_COOLDOWN_SECS": "Pause (s)", + "CHANNEL_GROUPS_COOLDOWN_MAX_SECS": "Max. pause (s)", + "CHANNEL_GROUPS_INPUT_FAILURES": "Input failures until pause", + "CHANNEL_GROUPS_INPUT_WINDOW_SECS": "Input failure window (s)", + "CHANNEL_GROUPS_IGNORE_TOKEN": "Example: backup", + "CHANNEL_GROUPS_ALIAS_NAME": "Name", + "CHANNEL_GROUPS_ALIAS_TARGET": "Compared as" }, "MESSAGES": { "CLIPBOARD_NOT_SUPPORTED": "Clipboard not supported.\nYour browser or current context does not allow clipboard access.\nPlease use HTTPS or localhost.", diff --git a/frontend/public/assets/i18n/es.json b/frontend/public/assets/i18n/es.json index b54560abe..5270d854c 100644 --- a/frontend/public/assets/i18n/es.json +++ b/frontend/public/assets/i18n/es.json @@ -873,6 +873,45 @@ "MAINTENANCE": { "INPUT": "Entrada que se retira del servicio. Mientras está en mantenimiento, las nuevas reproducciones reciben el vídeo de mantenimiento y Tuliprox no contacta con su proveedor; las reproducciones y grabaciones en curso continúan.", "REASON": "Observación opcional que se muestra en la lista de mantenimiento, por ejemplo por qué el proveedor no está disponible. Máximo 500 caracteres." + }, + "CHANNEL_GROUPS_TARGET_CONFIG": { + "ENABLED": "Une los canales en vivo iguales de este target en un solo canal. La reproducción empieza con la mejor fuente disponible y cambia a otra cuando falla." + }, + "CHANNEL_GROUPS_CONFIG": { + "MATCH_BY": "Qué campo decide que dos canales son el mismo: caption, name, title o ID de canal EPG. Palabras de calidad como HD o FHD se ignoran.", + "MATCH_AS_ASCII": "Compara los nombres tras convertir acentos y letras especiales en letras simples, así \"Télé\" y \"Tele\" coinciden.", + "EPG_CONFLICT": "split: canales con el mismo nombre pero IDs de EPG distintos quedan en grupos separados (programas distintos). ignore: se unen igualmente.", + "NAME_STRIP_QUALITY": "Quita palabras de calidad como HD, FHD o 4K del nombre del canal unido.", + "POLICY": "auto: primero la fuente con mejor historial y calidad de imagen. priority: las fuentes se usan en el orden de la lista.", + "ALLOW_PROVIDER_GRACE": "Si un proveedor no tiene conexión libre, un inicio puede usar su conexión de gracia. Un cambio durante la reproducción nunca lo hace." + }, + "CHANNEL_MATCH_NORMALIZE": { + "STRIP_REGEX": "Expresión regular que se quita de los nombres antes de comparar, por ejemplo \"^[A-Z]{2}:\\s*\" para prefijos de país como \"DE: \".", + "IGNORE_TOKENS": "Palabras que se ignoran al comparar nombres, por ejemplo \"backup\" o \"hevc\".", + "ALIASES": "Nombres del mismo canal: el nombre de la izquierda se compara como el de la derecha, por ejemplo \"das erste\" como \"ard\"." + }, + "CHANNEL_GROUP_START_CONFIG": { + "TIMEOUT_SECS": "Tiempo máximo que un inicio prueba fuentes antes de mostrar el vídeo de no disponible. 0 significa sin límite.", + "MAX_ATTEMPTS": "Cuántas fuentes prueba un inicio como máximo." + }, + "CHANNEL_GROUP_FAILOVER_CONFIG": { + "ENABLED": "Si la fuente falla, Tuliprox abre otra fuente del grupo y sigue el mismo stream; el reproductor mantiene su conexión. Solo para MPEG-TS; HLS cambia la fuente solo al iniciar un nuevo stream.", + "NO_DATA_TIMEOUT_SECS": "Una fuente que no envía imagen ni sonido durante este tiempo se reemplaza. Mantenlo por encima del intervalo de keyframes (unos segundos). 0 desactiva la comprobación.", + "SLOW_SOURCE_PCT": "Una fuente debe entregar al menos esta parte de la velocidad real. 90 significa: en 10 segundos debe entregar 9 segundos de vídeo; si no, el búfer del reproductor se vacía y la imagen se corta. 0 desactiva la comprobación.", + "SLOW_SOURCE_SECS": "Cuánto tiempo puede estar una fuente por debajo de la velocidad mínima antes de reemplazarla.", + "MAX_OUTAGE_SECS": "Tiempo máximo sin imagen mientras se busca un reemplazo. Después el stream termina.", + "SAME_SOURCE_RETRIES": "Una fuente que funcionaba bien se reintenta estas veces antes de usar otra.", + "STABLE_AFTER_SECS": "Tras esta reproducción sin problemas una fuente cuenta como estable y un fallo empieza un nuevo intento.", + "MAX_SWITCHES": "Máximo de cambios de fuente dentro de la ventana. Después el stream termina en vez de saltar entre fuentes rotas. 0 desactiva el límite.", + "MAX_SWITCHES_WINDOW_SECS": "Ventana de tiempo para el máximo de cambios.", + "AUDIO_LANGUAGE": "prefer: un cambio prefiere una fuente con el mismo idioma de audio. require: solo se usan fuentes con el mismo idioma de audio." + }, + "CHANNEL_GROUP_CIRCUIT_CONFIG": { + "SOURCE_FAILURES": "Fallos seguidos tras los que una fuente se pausa y se prueban antes otras fuentes.", + "COOLDOWN_SECS": "Primera pausa de una fuente con fallos. Se duplica mientras siga fallando.", + "COOLDOWN_MAX_SECS": "Pausa más larga de una fuente con fallos.", + "INPUT_FAILURES": "Fallos de distintos canales de una entrada dentro de la ventana tras los que toda la entrada se prueba al final (proveedor caído).", + "INPUT_WINDOW_SECS": "Ventana de tiempo para los fallos de entrada." } }, "HINT": { @@ -907,7 +946,9 @@ "PROCESS_PARALLEL": "Proceso Paralelo: si tiene varios proveedores, puede habilitarlo. Acceder a su proveedor en paralelo puede causar un baneo.", "RESTART_TO_APPLY_CHANGES": "Debe reiniciar para aplicar los cambios.", "SCHEDULE_EXAMPLE": "Ejemplo de programación", - "WEB_ROOT": "Web-Root: contiene los archivos de la interfaz web. Normalmente se colocan en el mismo directorio bajo ./web" + "WEB_ROOT": "Web-Root: contiene los archivos de la interfaz web. Normalmente se colocan en el mismo directorio bajo ./web", + "CHANNEL_GROUPS_CONFIG": "Los grupos de canales unen el mismo canal en vivo de varias entradas en un solo canal. Estos ajustes valen para todos los targets que activan los grupos de canales; un target puede sobrescribir algunos en su propia página de grupos de canales.", + "CHANNEL_GROUPS_TARGET": "Activa los grupos de canales para este target. Los ajustes en \"Global\" usan Ajustes > Grupos de canales." }, "LABEL": { "HLS_STARTUP_LIMITS": "Límites de inicio rápido", @@ -1934,7 +1975,42 @@ "RECORDING_RULE_DELETE_RETAIN": "Eliminar regla, conservar próximas grabaciones", "RECORDING_RULE_DELETE_CANCEL": "Eliminar regla y cancelar próximas grabaciones", "RECORDING_RULE_ACTION_ENABLE": "Habilitar", - "RECORDING_RULE_ACTION_DISABLE": "Deshabilitar" + "RECORDING_RULE_ACTION_DISABLE": "Deshabilitar", + "CHANNEL_GROUPS": "Grupos de canales", + "CHANNEL_GROUPS_MATCH_BY": "Comparar por", + "CHANNEL_GROUPS_ALLOW_PROVIDER_GRACE": "Permitir gracia del proveedor", + "CHANNEL_GROUPS_CONFIG": "Grupos de canales", + "CHANNEL_GROUPS_GLOBAL": "Global", + "CHANNEL_GROUPS_MATCH_AS_ASCII": "Comparar como ASCII", + "CHANNEL_GROUPS_STRIP_REGEX": "Quitar del nombre (regex)", + "CHANNEL_GROUPS_IGNORE_TOKENS": "Palabras ignoradas", + "CHANNEL_GROUPS_ALIASES": "Alias", + "CHANNEL_GROUPS_EPG_CONFLICT": "IDs de EPG distintos", + "CHANNEL_GROUPS_NAME_STRIP_QUALITY": "Quitar palabras de calidad del nombre", + "CHANNEL_GROUPS_POLICY": "Orden de fuentes", + "CHANNEL_GROUPS_FAILOVER": "Cambiar de fuente durante la reproducción", + "CHANNEL_GROUPS_GROUPING": "Agrupación", + "CHANNEL_GROUPS_START": "Inicio", + "CHANNEL_GROUPS_CIRCUIT": "Pausar fuentes con fallos", + "CHANNEL_GROUPS_START_TIMEOUT_SECS": "Tiempo de inicio (s)", + "CHANNEL_GROUPS_START_MAX_ATTEMPTS": "Fuentes probadas al inicio", + "CHANNEL_GROUPS_NO_DATA_TIMEOUT_SECS": "Tiempo sin datos (s)", + "CHANNEL_GROUPS_SLOW_SOURCE_PCT": "Velocidad mínima (%)", + "CHANNEL_GROUPS_SLOW_SOURCE_SECS": "Demasiado lenta durante (s)", + "CHANNEL_GROUPS_MAX_OUTAGE_SECS": "Corte máximo (s)", + "CHANNEL_GROUPS_SAME_SOURCE_RETRIES": "Reintentos de la misma fuente", + "CHANNEL_GROUPS_STABLE_AFTER_SECS": "Estable tras (s)", + "CHANNEL_GROUPS_MAX_SWITCHES": "Máx. cambios", + "CHANNEL_GROUPS_MAX_SWITCHES_WINDOW_SECS": "Ventana de cambios (s)", + "CHANNEL_GROUPS_AUDIO_LANGUAGE": "Idioma de audio", + "CHANNEL_GROUPS_SOURCE_FAILURES": "Fallos hasta la pausa", + "CHANNEL_GROUPS_COOLDOWN_SECS": "Pausa (s)", + "CHANNEL_GROUPS_COOLDOWN_MAX_SECS": "Pausa máx. (s)", + "CHANNEL_GROUPS_INPUT_FAILURES": "Fallos de entrada hasta la pausa", + "CHANNEL_GROUPS_INPUT_WINDOW_SECS": "Ventana de fallos de entrada (s)", + "CHANNEL_GROUPS_IGNORE_TOKEN": "Ejemplo: backup", + "CHANNEL_GROUPS_ALIAS_NAME": "Nombre", + "CHANNEL_GROUPS_ALIAS_TARGET": "Se compara como" }, "MESSAGES": { "CLIPBOARD_NOT_SUPPORTED": "Portapapeles no compatible.\nSu navegador o el contexto actual no permite el acceso al portapapeles.\nUtilice HTTPS o localhost.", diff --git a/frontend/public/assets/i18n/ru.json b/frontend/public/assets/i18n/ru.json index bb864e3f2..a5889f171 100644 --- a/frontend/public/assets/i18n/ru.json +++ b/frontend/public/assets/i18n/ru.json @@ -873,6 +873,45 @@ "MAINTENANCE": { "INPUT": "Вход, который выводится из эксплуатации. Пока он на обслуживании, новые потоки получают видео обслуживания, а Tuliprox не обращается к его провайдеру; текущие потоки и записи продолжаются.", "REASON": "Необязательное примечание в списке обслуживания, например почему провайдер недоступен. Не более 500 символов." + }, + "CHANNEL_GROUPS_TARGET_CONFIG": { + "ENABLED": "Объединяет одинаковые live-каналы этой цели в один канал. Воспроизведение начинается с лучшего доступного источника и переключается на другой при сбое." + }, + "CHANNEL_GROUPS_CONFIG": { + "MATCH_BY": "Какое поле решает, что два канала одинаковы: caption, name, title или EPG id канала. Слова качества вроде HD или FHD игнорируются.", + "MATCH_AS_ASCII": "Сравнивает имена после замены диакритики и особых букв на простые, так «Télé» и «Tele» совпадают.", + "EPG_CONFLICT": "split: каналы с одинаковым именем, но разными EPG id остаются в разных группах (разные программы). ignore: они всё равно объединяются.", + "NAME_STRIP_QUALITY": "Убирает слова качества, например HD, FHD или 4K, из имени объединённого канала.", + "POLICY": "auto: сначала источник с лучшей историей и качеством картинки. priority: источники используются в порядке списка.", + "ALLOW_PROVIDER_GRACE": "Если у провайдера нет свободного подключения, запуск может использовать льготное подключение. Переключение во время просмотра — никогда." + }, + "CHANNEL_MATCH_NORMALIZE": { + "STRIP_REGEX": "Регулярное выражение, удаляемое из имён перед сравнением, например \"^[A-Z]{2}:\\s*\" для префиксов страны вроде \"DE: \".", + "IGNORE_TOKENS": "Слова, которые игнорируются при сравнении имён, например \"backup\" или \"hevc\".", + "ALIASES": "Имена одного и того же канала: левое имя сравнивается как правое, например \"das erste\" как \"ard\"." + }, + "CHANNEL_GROUP_START_CONFIG": { + "TIMEOUT_SECS": "Максимальное время, которое запуск перебирает источники, прежде чем клиент получит видео «недоступно». 0 — без ограничения.", + "MAX_ATTEMPTS": "Сколько источников максимум пробует запуск." + }, + "CHANNEL_GROUP_FAILOVER_CONFIG": { + "ENABLED": "Если источник отказывает, Tuliprox открывает другой источник группы и продолжает тот же поток; плеер сохраняет соединение. Только для MPEG-TS; HLS меняет источник только при новом запуске.", + "NO_DATA_TIMEOUT_SECS": "Источник без картинки и звука дольше этого времени заменяется. Значение должно быть больше интервала ключевых кадров (несколько секунд). 0 отключает проверку.", + "SLOW_SOURCE_PCT": "Источник должен выдавать не меньше этой доли реальной скорости. 90 значит: за 10 секунд он должен выдать 9 секунд видео, иначе буфер плеера опустеет и картинка будет прерываться. 0 отключает проверку.", + "SLOW_SOURCE_SECS": "Как долго источник может быть медленнее минимальной скорости, прежде чем его заменят.", + "MAX_OUTAGE_SECS": "Максимальное время без картинки при поиске замены. Затем поток завершается.", + "SAME_SOURCE_RETRIES": "Источник, который до этого работал хорошо, пробуется снова столько раз, прежде чем используется другой.", + "STABLE_AFTER_SECS": "После стольких секунд плавного воспроизведения источник считается стабильным, и сбой начинает новую попытку.", + "MAX_SWITCHES": "Максимум переключений источника в окне. Затем поток завершается, а не прыгает между сломанными источниками. 0 отключает ограничение.", + "MAX_SWITCHES_WINDOW_SECS": "Окно времени для максимума переключений.", + "AUDIO_LANGUAGE": "prefer: при переключении предпочитается источник с тем же языком аудио. require: используются только источники с тем же языком аудио." + }, + "CHANNEL_GROUP_CIRCUIT_CONFIG": { + "SOURCE_FAILURES": "Сбоев подряд, после которых источник ставится на паузу и сначала пробуются другие.", + "COOLDOWN_SECS": "Первая пауза сбойного источника. Удваивается, пока источник продолжает отказывать.", + "COOLDOWN_MAX_SECS": "Самая длинная пауза сбойного источника.", + "INPUT_FAILURES": "Сбоев разных каналов одного входа в окне, после которых весь вход пробуется последним (провайдер недоступен).", + "INPUT_WINDOW_SECS": "Окно времени для сбоев входа." } }, "HINT": { @@ -907,7 +946,9 @@ "PROCESS_PARALLEL": "Process Parallel: Если у вас есть несколько провайдеров, вы можете включить это. Доступ к вашему провайдеру параллельно может привести к бану.", "RESTART_TO_APPLY_CHANGES": "Вам нужно перезагрузиться, чтобы применить изменения.", "SCHEDULE_EXAMPLE": "Пример расписания", - "WEB_ROOT": "Web-Root: Содержит файлы веб-интерфейса. Они обычно размещаются в той же директории под ./web" + "WEB_ROOT": "Web-Root: Содержит файлы веб-интерфейса. Они обычно размещаются в той же директории под ./web", + "CHANNEL_GROUPS_CONFIG": "Группы каналов объединяют один и тот же live-канал из нескольких входов в один канал. Эти настройки действуют для всех целей, где включены группы каналов; цель может переопределить часть из них на своей странице «Группы каналов».", + "CHANNEL_GROUPS_TARGET": "Включает группы каналов для этой цели. Значения «Глобально» берутся из Настройки > Группы каналов." }, "LABEL": { "HLS_STARTUP_LIMITS": "Ограничения быстрого старта", @@ -1934,7 +1975,42 @@ "RECORDING_RULE_DELETE_RETAIN": "Удалить правило, сохранить будущие записи", "RECORDING_RULE_DELETE_CANCEL": "Удалить правило и отменить будущие записи", "RECORDING_RULE_ACTION_ENABLE": "Включить", - "RECORDING_RULE_ACTION_DISABLE": "Отключить" + "RECORDING_RULE_ACTION_DISABLE": "Отключить", + "CHANNEL_GROUPS": "Группы каналов", + "CHANNEL_GROUPS_MATCH_BY": "Сравнивать по", + "CHANNEL_GROUPS_ALLOW_PROVIDER_GRACE": "Разрешить льготное подключение", + "CHANNEL_GROUPS_CONFIG": "Группы каналов", + "CHANNEL_GROUPS_GLOBAL": "Глобально", + "CHANNEL_GROUPS_MATCH_AS_ASCII": "Сравнивать как ASCII", + "CHANNEL_GROUPS_STRIP_REGEX": "Удалить из имени (regex)", + "CHANNEL_GROUPS_IGNORE_TOKENS": "Игнорируемые слова", + "CHANNEL_GROUPS_ALIASES": "Псевдонимы", + "CHANNEL_GROUPS_EPG_CONFLICT": "Разные EPG id", + "CHANNEL_GROUPS_NAME_STRIP_QUALITY": "Убрать слова качества из имени", + "CHANNEL_GROUPS_POLICY": "Порядок источников", + "CHANNEL_GROUPS_FAILOVER": "Переключать источник во время просмотра", + "CHANNEL_GROUPS_GROUPING": "Группировка", + "CHANNEL_GROUPS_START": "Запуск", + "CHANNEL_GROUPS_CIRCUIT": "Пауза для сбойных источников", + "CHANNEL_GROUPS_START_TIMEOUT_SECS": "Тайм-аут запуска (с)", + "CHANNEL_GROUPS_START_MAX_ATTEMPTS": "Источников при запуске", + "CHANNEL_GROUPS_NO_DATA_TIMEOUT_SECS": "Тайм-аут без данных (с)", + "CHANNEL_GROUPS_SLOW_SOURCE_PCT": "Минимальная скорость (%)", + "CHANNEL_GROUPS_SLOW_SOURCE_SECS": "Слишком медленно в течение (с)", + "CHANNEL_GROUPS_MAX_OUTAGE_SECS": "Макс. перерыв (с)", + "CHANNEL_GROUPS_SAME_SOURCE_RETRIES": "Повторы того же источника", + "CHANNEL_GROUPS_STABLE_AFTER_SECS": "Стабилен через (с)", + "CHANNEL_GROUPS_MAX_SWITCHES": "Макс. переключений", + "CHANNEL_GROUPS_MAX_SWITCHES_WINDOW_SECS": "Окно переключений (с)", + "CHANNEL_GROUPS_AUDIO_LANGUAGE": "Язык аудио", + "CHANNEL_GROUPS_SOURCE_FAILURES": "Сбоев до паузы", + "CHANNEL_GROUPS_COOLDOWN_SECS": "Пауза (с)", + "CHANNEL_GROUPS_COOLDOWN_MAX_SECS": "Макс. пауза (с)", + "CHANNEL_GROUPS_INPUT_FAILURES": "Сбоев входа до паузы", + "CHANNEL_GROUPS_INPUT_WINDOW_SECS": "Окно сбоев входа (с)", + "CHANNEL_GROUPS_IGNORE_TOKEN": "Например: backup", + "CHANNEL_GROUPS_ALIAS_NAME": "Имя", + "CHANNEL_GROUPS_ALIAS_TARGET": "Сравнивать как" }, "MESSAGES": { "CLIPBOARD_NOT_SUPPORTED": "Буфер обмена не поддерживается.\nВаш браузер или текущий контекст не позволяют доступ к буферу обмена.\nПожалуйста, используйте HTTPS или localhost.", diff --git a/frontend/scss/app/_component.scss b/frontend/scss/app/_component.scss index abff837d5..05a0f6762 100644 --- a/frontend/scss/app/_component.scss +++ b/frontend/scss/app/_component.scss @@ -80,6 +80,7 @@ @forward "components/config/config_view"; @forward "components/config/local_library_config"; @forward "components/config/log_config"; +@forward "components/config/channel_groups_config"; @forward "components/config/api_config"; @forward "components/config/messaging_config"; @forward "components/config/panel_api_config"; diff --git a/frontend/scss/app/components/config/_channel_groups_config.scss b/frontend/scss/app/components/config/_channel_groups_config.scss new file mode 100644 index 000000000..526749ba3 --- /dev/null +++ b/frontend/scss/app/components/config/_channel_groups_config.scss @@ -0,0 +1,7 @@ +.tp__channel-groups-config-view { + &__info { + max-width: 60rem; + color: var(--modest-text-color); + line-height: 1.35; + } +} diff --git a/frontend/scss/app/components/source_editor/_source_editor_form.scss b/frontend/scss/app/components/source_editor/_source_editor_form.scss index 28eac6138..4bedc3264 100644 --- a/frontend/scss/app/components/source_editor/_source_editor_form.scss +++ b/frontend/scss/app/components/source_editor/_source_editor_form.scss @@ -162,6 +162,11 @@ $form-width: 520px; display: flex; min-width: 0; } + + &__hint { + color: var(--modest-text-color); + line-height: 1.35; + } } &__sidebar { diff --git a/frontend/src/app/components/config/channel_groups_config_view.rs b/frontend/src/app/components/config/channel_groups_config_view.rs new file mode 100644 index 000000000..53e0b50df --- /dev/null +++ b/frontend/src/app/components/config/channel_groups_config_view.rs @@ -0,0 +1,323 @@ +use crate::{ + app::{ + components::{ + config::{ + config_page::{ConfigForm, LABEL_CHANNEL_GROUPS_CONFIG}, + config_view_context::ConfigViewContext, + use_emit_config_form, + }, + input::Input, + number_input::NumberInput, + select::Select, + selection_first, Card, DropDownOption, DropDownSelection, FieldLabel, KeyValueEditor, Tag, TagList, + ToggleSwitch, + }, + context::ConfigContext, + }, + generate_form_reducer, + i18n::use_translation, + use_default_form_reducer, +}; +use shared::model::{ + ChannelGroupPolicy, ChannelGroupsConfig, ChannelMatchBy, ChannelMatchNormalize, EpgConflictMode, + RuntimeAudioLanguage, +}; +use std::{collections::HashMap, rc::Rc, str::FromStr}; +use yew::prelude::*; + +const LABEL_GROUPING: &str = "LABEL.CHANNEL_GROUPS_GROUPING"; +const LABEL_START: &str = "LABEL.CHANNEL_GROUPS_START"; +const LABEL_FAILOVER: &str = "LABEL.CHANNEL_GROUPS_FAILOVER"; +const LABEL_CIRCUIT: &str = "LABEL.CHANNEL_GROUPS_CIRCUIT"; +const INFO_CONFIG: &str = "INFO.CHANNEL_GROUPS_CONFIG"; + +generate_form_reducer!( + state: ChannelGroupsConfigFormState { form: ChannelGroupsConfig }, + action_name: ChannelGroupsConfigFormAction, + fields { + MatchNormalize => match_normalize: ChannelMatchNormalize, + } +); + +/// One edit of the global channel group settings. +type Edit = fn(&mut ChannelGroupsConfig, T); + +/// Applies an edit to the form and marks it modified. +type Apply = dyn Fn(&dyn Fn(&mut ChannelGroupsConfig)); + +/// An empty value box, so a field without entries looks like the other fields in view mode. +fn empty_value() -> Html { + html! { } +} + +fn clamp_u8(value: u64) -> u8 { u8::try_from(value).unwrap_or(u8::MAX) } + +fn choice(values: &[T], value: T) -> Rc> +where + T: Copy + PartialEq + ToString, +{ + Rc::new( + values + .iter() + .map(|&option| DropDownOption { + id: option.to_string(), + label: html! { option.to_string() }, + selected: option == value, + }) + .collect(), + ) +} + +#[component] +pub fn ChannelGroupsConfigView() -> Html { + let translate = use_translation(); + let config_ctx = use_context::().expect("ConfigContext not found"); + let config_view_ctx = use_context::().expect("ConfigViewContext not found"); + let edit_mode = *config_view_ctx.edit_mode; + + let form_state: UseReducerHandle = + use_default_form_reducer!(ChannelGroupsConfigFormState { form: ChannelGroupsConfig::default() }); + + use_emit_config_form(&form_state, config_view_ctx.on_form_change.clone(), ConfigForm::ChannelGroups); + + { + let form_state = form_state.clone(); + let channel_groups = config_ctx.config.as_ref().and_then(|c| c.config.channel_groups.clone()); + use_effect_with((channel_groups, edit_mode), move |(channel_groups, _mode)| { + form_state.dispatch(ChannelGroupsConfigFormAction::SetAll(channel_groups.clone().unwrap_or_default())); + || () + }); + } + + let config = form_state.form.clone(); + let update: Rc = { + let form_state = form_state.clone(); + Rc::new(move |edit| { + let mut config = form_state.form.clone(); + edit(&mut config); + form_state.dispatch(ChannelGroupsConfigFormAction::SetAllEdited(config)); + }) + }; + + let field = |label: &str, field_id: &str, body: Html| { + html! { +
+ + { body } +
+ } + }; + + let toggle = |label: &str, field_id: &str, value: bool, edit: Edit| { + let apply = Rc::clone(&update); + html! { +
+ + +
+ } + }; + + let number = |label: &str, field_id: &str, value: u64, max: u64, edit: Edit| { + if !edit_mode { + return field(label, field_id, html! { { value } }); + } + let apply = Rc::clone(&update); + html! { +
+ | { + let value = value.and_then(|value| u64::try_from(value).ok()).unwrap_or_default().min(max); + apply(&|config| edit(config, value)); + })} + /> +
+ } + }; + + let select = + |label: &str, field_id: &str, options: Rc>, shown: String, apply: Callback| { + let body = if edit_mode { + html! { + + } + } else { + html! { { normalize.strip_regex.clone().unwrap_or_default() } } + }; + field("LABEL.CHANNEL_GROUPS_STRIP_REGEX", "CHANNEL_MATCH_NORMALIZE.STRIP_REGEX", body) + }; + let ignore_tokens = { + let form_state = form_state.clone(); + let tags: Vec> = + normalize.ignore_tokens.iter().map(|token| Rc::new(Tag { label: token.clone(), class: None })).collect(); + field( + "LABEL.CHANNEL_GROUPS_IGNORE_TOKENS", + "CHANNEL_MATCH_NORMALIZE.IGNORE_TOKENS", + if !edit_mode && tags.is_empty() { + empty_value() + } else { + html! { + >| { + let mut normalize = form_state.form.match_normalize.clone(); + normalize.ignore_tokens = tags.iter().map(|tag| tag.label.clone()).collect(); + form_state.dispatch(ChannelGroupsConfigFormAction::MatchNormalize(normalize)); + })} /> + } + }, + ) + }; + let aliases = { + let form_state = form_state.clone(); + let entries: HashMap = + normalize.aliases.iter().map(|(key, value)| (key.clone(), value.clone())).collect(); + field( + "LABEL.CHANNEL_GROUPS_ALIASES", + "CHANNEL_MATCH_NORMALIZE.ALIASES", + if !edit_mode && entries.is_empty() { + empty_value() + } else { + html! { + | { + let mut normalize = form_state.form.match_normalize.clone(); + normalize.aliases = entries.into_iter().collect(); + form_state.dispatch(ChannelGroupsConfigFormAction::MatchNormalize(normalize)); + })} /> + } + }, + ) + }; + + let start = config.start; + let failover = config.failover; + let circuit = config.circuit; + + html! { +
+
{ translate.t(LABEL_CHANNEL_GROUPS_CONFIG) }
+
{ translate.t(INFO_CONFIG) }
+
+ +

{ translate.t(LABEL_GROUPING) }

+ { enum_select!("LABEL.CHANNEL_GROUPS_MATCH_BY", "CHANNEL_GROUPS_CONFIG.MATCH_BY", ChannelMatchBy, + config.match_by, |config, value| config.match_by = value) } + { toggle("LABEL.CHANNEL_GROUPS_MATCH_AS_ASCII", "CHANNEL_GROUPS_CONFIG.MATCH_AS_ASCII", + config.match_as_ascii, |config, value| config.match_as_ascii = value) } + { strip_regex } + { ignore_tokens } + { aliases } + { enum_select!("LABEL.CHANNEL_GROUPS_EPG_CONFLICT", "CHANNEL_GROUPS_CONFIG.EPG_CONFLICT", EpgConflictMode, + config.epg_conflict, |config, value| config.epg_conflict = value) } + { toggle("LABEL.CHANNEL_GROUPS_NAME_STRIP_QUALITY", "CHANNEL_GROUPS_CONFIG.NAME_STRIP_QUALITY", + config.name_strip_quality, |config, value| config.name_strip_quality = value) } + { enum_select!("LABEL.CHANNEL_GROUPS_POLICY", "CHANNEL_GROUPS_CONFIG.POLICY", ChannelGroupPolicy, + config.policy, |config, value| config.policy = value) } +
+ +

{ translate.t(LABEL_START) }

+ { toggle("LABEL.CHANNEL_GROUPS_ALLOW_PROVIDER_GRACE", "CHANNEL_GROUPS_CONFIG.ALLOW_PROVIDER_GRACE", + config.allow_provider_grace, |config, value| config.allow_provider_grace = value) } + { number("LABEL.CHANNEL_GROUPS_START_TIMEOUT_SECS", "CHANNEL_GROUP_START_CONFIG.TIMEOUT_SECS", + start.timeout_secs, 3_600, |config, value| config.start.timeout_secs = value) } + { number("LABEL.CHANNEL_GROUPS_START_MAX_ATTEMPTS", "CHANNEL_GROUP_START_CONFIG.MAX_ATTEMPTS", + u64::from(start.max_attempts), u64::from(u8::MAX), + |config, value| config.start.max_attempts = clamp_u8(value.max(1))) } +
+ +

{ translate.t(LABEL_FAILOVER) }

+ { toggle("LABEL.ENABLED", "CHANNEL_GROUP_FAILOVER_CONFIG.ENABLED", + failover.enabled, |config, value| config.failover.enabled = value) } + { number("LABEL.CHANNEL_GROUPS_NO_DATA_TIMEOUT_SECS", "CHANNEL_GROUP_FAILOVER_CONFIG.NO_DATA_TIMEOUT_SECS", + failover.no_data_timeout_secs, 3_600, |config, value| config.failover.no_data_timeout_secs = value) } + { number("LABEL.CHANNEL_GROUPS_SLOW_SOURCE_PCT", "CHANNEL_GROUP_FAILOVER_CONFIG.SLOW_SOURCE_PCT", + u64::from(failover.slow_source_pct), 100, |config, value| config.failover.slow_source_pct = clamp_u8(value)) } + { number("LABEL.CHANNEL_GROUPS_SLOW_SOURCE_SECS", "CHANNEL_GROUP_FAILOVER_CONFIG.SLOW_SOURCE_SECS", + failover.slow_source_secs, 3_600, |config, value| config.failover.slow_source_secs = value) } + { number("LABEL.CHANNEL_GROUPS_MAX_OUTAGE_SECS", "CHANNEL_GROUP_FAILOVER_CONFIG.MAX_OUTAGE_SECS", + failover.max_outage_secs, 3_600, |config, value| config.failover.max_outage_secs = value.max(1)) } + { number("LABEL.CHANNEL_GROUPS_SAME_SOURCE_RETRIES", "CHANNEL_GROUP_FAILOVER_CONFIG.SAME_SOURCE_RETRIES", + u64::from(failover.same_source_retries), u64::from(u8::MAX), + |config, value| config.failover.same_source_retries = clamp_u8(value)) } + { number("LABEL.CHANNEL_GROUPS_STABLE_AFTER_SECS", "CHANNEL_GROUP_FAILOVER_CONFIG.STABLE_AFTER_SECS", + failover.stable_after_secs, 86_400, |config, value| config.failover.stable_after_secs = value) } + { number("LABEL.CHANNEL_GROUPS_MAX_SWITCHES", "CHANNEL_GROUP_FAILOVER_CONFIG.MAX_SWITCHES", + u64::from(failover.max_switches), u64::from(u8::MAX), + |config, value| config.failover.max_switches = clamp_u8(value)) } + { number("LABEL.CHANNEL_GROUPS_MAX_SWITCHES_WINDOW_SECS", "CHANNEL_GROUP_FAILOVER_CONFIG.MAX_SWITCHES_WINDOW_SECS", + failover.max_switches_window_secs, 86_400, |config, value| config.failover.max_switches_window_secs = value) } + { enum_select!("LABEL.CHANNEL_GROUPS_AUDIO_LANGUAGE", "CHANNEL_GROUP_FAILOVER_CONFIG.AUDIO_LANGUAGE", + RuntimeAudioLanguage, failover.audio_language, |config, value| config.failover.audio_language = value) } +
+ +

{ translate.t(LABEL_CIRCUIT) }

+ { number("LABEL.CHANNEL_GROUPS_SOURCE_FAILURES", "CHANNEL_GROUP_CIRCUIT_CONFIG.SOURCE_FAILURES", + u64::from(circuit.source_failures), u64::from(u8::MAX), + |config, value| config.circuit.source_failures = clamp_u8(value.max(1))) } + { number("LABEL.CHANNEL_GROUPS_COOLDOWN_SECS", "CHANNEL_GROUP_CIRCUIT_CONFIG.COOLDOWN_SECS", + circuit.cooldown_secs, 86_400, |config, value| config.circuit.cooldown_secs = value) } + { number("LABEL.CHANNEL_GROUPS_COOLDOWN_MAX_SECS", "CHANNEL_GROUP_CIRCUIT_CONFIG.COOLDOWN_MAX_SECS", + circuit.cooldown_max_secs, 86_400, |config, value| config.circuit.cooldown_max_secs = value) } + { number("LABEL.CHANNEL_GROUPS_INPUT_FAILURES", "CHANNEL_GROUP_CIRCUIT_CONFIG.INPUT_FAILURES", + u64::from(circuit.input_failures), u64::from(u8::MAX), + |config, value| config.circuit.input_failures = clamp_u8(value.max(2))) } + { number("LABEL.CHANNEL_GROUPS_INPUT_WINDOW_SECS", "CHANNEL_GROUP_CIRCUIT_CONFIG.INPUT_WINDOW_SECS", + circuit.input_window_secs, 86_400, |config, value| config.circuit.input_window_secs = value) } +
+
+
+ } +} diff --git a/frontend/src/app/components/config/config_page.rs b/frontend/src/app/components/config/config_page.rs index 681830285..e0ab29270 100644 --- a/frontend/src/app/components/config/config_page.rs +++ b/frontend/src/app/components/config/config_page.rs @@ -1,9 +1,9 @@ use shared::{ error::TuliproxError, model::{ - ApiProxyConfigDto, ConfigApiDto, HdHomeRunConfigDto, IpCheckConfigDto, LibraryConfigDto, LogConfigDto, - MainConfigDto, MessagingConfigDto, MetadataUpdateConfigDto, ProxyConfigDto, ReverseProxyConfigDto, - SchedulesConfigDto, SourcesConfigDto, VideoConfigDto, WebUiConfigDto, + ApiProxyConfigDto, ChannelGroupsConfig, ConfigApiDto, HdHomeRunConfigDto, IpCheckConfigDto, LibraryConfigDto, + LogConfigDto, MainConfigDto, MessagingConfigDto, MetadataUpdateConfigDto, ProxyConfigDto, + ReverseProxyConfigDto, SchedulesConfigDto, SourcesConfigDto, VideoConfigDto, WebUiConfigDto, }, }; use std::{fmt, str::FromStr}; @@ -22,6 +22,7 @@ pub const LABEL_RECORDING_CONFIG: &str = "LABEL.RECORDING_CONFIG"; pub const LABEL_PANEL_CONFIG: &str = "LABEL.PANEL_CONFIG"; pub const LABEL_LIBRARY_CONFIG: &str = "LABEL.LIBRARY_CONFIG"; pub const LABEL_METADATA_UPDATE_CONFIG: &str = "LABEL.METADATA_UPDATE_CONFIG"; +pub const LABEL_CHANNEL_GROUPS_CONFIG: &str = "LABEL.CHANNEL_GROUPS_CONFIG"; const MAIN_PAGE: &str = "main"; const API_PAGE: &str = "api"; @@ -37,6 +38,7 @@ const RECORDING_PAGE: &str = "recording"; const METADATA_UPDATE_PAGE: &str = "metadata_update"; const PANEL_PAGE: &str = "panel"; const LIBRARY_PAGE: &str = "library"; +const CHANNEL_GROUPS_PAGE: &str = "channel_groups"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)] pub enum ConfigPage { @@ -54,6 +56,7 @@ pub enum ConfigPage { IpCheck, Panel, Library, + ChannelGroups, } impl FromStr for ConfigPage { @@ -75,6 +78,7 @@ impl FromStr for ConfigPage { IPCHECK_PAGE => Ok(ConfigPage::IpCheck), PANEL_PAGE => Ok(ConfigPage::Panel), LIBRARY_PAGE => Ok(ConfigPage::Library), + CHANNEL_GROUPS_PAGE => Ok(ConfigPage::ChannelGroups), _ => Err(TuliproxError::Config(format!("Unknown config page: {s}"))), } } @@ -97,6 +101,7 @@ impl fmt::Display for ConfigPage { ConfigPage::IpCheck => IPCHECK_PAGE, ConfigPage::Panel => PANEL_PAGE, ConfigPage::Library => LIBRARY_PAGE, + ConfigPage::ChannelGroups => CHANNEL_GROUPS_PAGE, }; write!(f, "{s}") } @@ -124,6 +129,7 @@ pub enum ConfigForm { IpCheck(bool, IpCheckConfigDto), Panel(bool, SourcesConfigDto), Library(bool, LibraryConfigDto), + ChannelGroups(bool, ChannelGroupsConfig), } impl ConfigForm { @@ -145,6 +151,7 @@ impl ConfigForm { | ConfigForm::IpCheck(true, _) | ConfigForm::Panel(true, _) | ConfigForm::Library(true, _) + | ConfigForm::ChannelGroups(true, _) ) } } @@ -166,6 +173,7 @@ pub struct ConfigFormSlots { pub ipcheck: Option, pub panel: Option, pub library: Option, + pub channel_groups: Option, } impl ConfigFormSlots { @@ -188,10 +196,11 @@ impl ConfigFormSlots { ConfigForm::IpCheck(_, _) => Self::set_form_slot(&mut self.ipcheck, form), ConfigForm::Panel(_, _) => Self::set_form_slot(&mut self.panel, form), ConfigForm::Library(_, _) => Self::set_form_slot(&mut self.library, form), + ConfigForm::ChannelGroups(_, _) => Self::set_form_slot(&mut self.channel_groups, form), } } - pub fn all_slots(&self) -> [&Option; 15] { + pub fn all_slots(&self) -> [&Option; 16] { [ &self.main, &self.api, @@ -208,6 +217,7 @@ impl ConfigFormSlots { &self.ipcheck, &self.panel, &self.library, + &self.channel_groups, ] } diff --git a/frontend/src/app/components/config/config_update.rs b/frontend/src/app/components/config/config_update.rs index 025b0e66f..b28911169 100644 --- a/frontend/src/app/components/config/config_update.rs +++ b/frontend/src/app/components/config/config_update.rs @@ -146,6 +146,9 @@ pub fn update_config(config: &mut ConfigDto, forms: Vec) { ConfigForm::Proxy(_, mut proxy_cfg) => set_config_field!(config, proxy_cfg, proxy), ConfigForm::IpCheck(_, mut ipcheck_cfg) => set_config_field!(config, ipcheck_cfg, ipcheck), ConfigForm::Library(_, library_cfg) => update_library_field(config, library_cfg), + ConfigForm::ChannelGroups(_, channel_groups_cfg) => { + config.channel_groups = (!channel_groups_cfg.is_default()).then_some(channel_groups_cfg); + } ConfigForm::Panel(_, _) => {} ConfigForm::ApiProxy(_, _) => {} } diff --git a/frontend/src/app/components/config/config_view.rs b/frontend/src/app/components/config/config_view.rs index a0b4a5cd7..9fe0db9ff 100644 --- a/frontend/src/app/components/config/config_view.rs +++ b/frontend/src/app/components/config/config_view.rs @@ -3,16 +3,17 @@ use crate::{ components::{ config::{ config_page::{ - ConfigForm, ConfigFormSlots, ConfigPage, LABEL_API_CONFIG, LABEL_HDHOMERUN_CONFIG, - LABEL_IP_CHECK_CONFIG, LABEL_LIBRARY_CONFIG, LABEL_LOG_CONFIG, LABEL_MAIN_CONFIG, - LABEL_MESSAGING_CONFIG, LABEL_METADATA_UPDATE_CONFIG, LABEL_PANEL_CONFIG, LABEL_PROXY_CONFIG, - LABEL_RECORDING_CONFIG, LABEL_REVERSE_PROXY_CONFIG, LABEL_SCHEDULES_CONFIG, LABEL_WEB_UI_CONFIG, + ConfigForm, ConfigFormSlots, ConfigPage, LABEL_API_CONFIG, LABEL_CHANNEL_GROUPS_CONFIG, + LABEL_HDHOMERUN_CONFIG, LABEL_IP_CHECK_CONFIG, LABEL_LIBRARY_CONFIG, LABEL_LOG_CONFIG, + LABEL_MAIN_CONFIG, LABEL_MESSAGING_CONFIG, LABEL_METADATA_UPDATE_CONFIG, LABEL_PANEL_CONFIG, + LABEL_PROXY_CONFIG, LABEL_RECORDING_CONFIG, LABEL_REVERSE_PROXY_CONFIG, LABEL_SCHEDULES_CONFIG, + LABEL_WEB_UI_CONFIG, }, config_update::apply_and_prepare_config, config_view_context::ConfigViewContext, - ApiConfigView, HdHomerunConfigView, IpCheckConfigView, LibraryConfigView, LogConfigView, - MainConfigView, MessagingConfigView, MetadataUpdateConfigView, PanelConfigView, ProxyConfigView, - RecordingConfigView, ReverseProxyConfigView, SchedulesConfigView, WebUiConfigView, + ApiConfigView, ChannelGroupsConfigView, HdHomerunConfigView, IpCheckConfigView, LibraryConfigView, + LogConfigView, MainConfigView, MessagingConfigView, MetadataUpdateConfigView, PanelConfigView, + ProxyConfigView, RecordingConfigView, ReverseProxyConfigView, SchedulesConfigView, WebUiConfigView, }, input::Input, validate_credentials, Card, NoSubmitForm, TabItem, TabSet, TextButton, @@ -62,6 +63,7 @@ fn config_form_to_config_page(form: &ConfigForm) -> ConfigPage { ConfigForm::IpCheck(_, _) => ConfigPage::IpCheck, ConfigForm::Panel(_, _) => ConfigPage::Panel, ConfigForm::Library(_, _) => ConfigPage::Library, + ConfigForm::ChannelGroups(_, _) => ConfigPage::ChannelGroups, } } @@ -135,6 +137,7 @@ pub fn ConfigView() -> Html { "Metadata", ), (ConfigPage::Library, LABEL_LIBRARY_CONFIG, html! { }, "VideoLibrary"), + (ConfigPage::ChannelGroups, LABEL_CHANNEL_GROUPS_CONFIG, html! { }, "Nodes"), ]; let editing = *editing; diff --git a/frontend/src/app/components/config/mod.rs b/frontend/src/app/components/config/mod.rs index 7cda2afb0..10a0e5949 100644 --- a/frontend/src/app/components/config/mod.rs +++ b/frontend/src/app/components/config/mod.rs @@ -2,6 +2,7 @@ mod macros; mod admission_strategies; mod api_config_view; +mod channel_groups_config_view; mod config_page; mod config_update; mod config_view; @@ -26,6 +27,7 @@ mod webui_config_view; pub(crate) use admission_strategies::*; pub use api_config_view::*; +pub use channel_groups_config_view::*; pub use config_page::*; pub use config_view::*; pub use config_view_context::*; diff --git a/frontend/src/app/components/dashboard/stream_display/helpers.rs b/frontend/src/app/components/dashboard/stream_display/helpers.rs index feae9d12b..7439ed8f6 100644 --- a/frontend/src/app/components/dashboard/stream_display/helpers.rs +++ b/frontend/src/app/components/dashboard/stream_display/helpers.rs @@ -250,6 +250,7 @@ mod tests { channel: StreamChannel { target_id: 1, virtual_id: uid, + group_virtual_id: None, provider_id: 1, item_type, cluster: item_type.cluster(), diff --git a/frontend/src/app/components/dashboard/stream_display/mod.rs b/frontend/src/app/components/dashboard/stream_display/mod.rs index 02e232e07..7ae097543 100644 --- a/frontend/src/app/components/dashboard/stream_display/mod.rs +++ b/frontend/src/app/components/dashboard/stream_display/mod.rs @@ -352,6 +352,7 @@ mod tests { channel: StreamChannel { target_id: 1, virtual_id: 42, + group_virtual_id: None, provider_id: 1, item_type, cluster: XtreamCluster::Live, diff --git a/frontend/src/app/components/playlist/target/target_options.rs b/frontend/src/app/components/playlist/target/target_options.rs index d1f7017f0..222429cd6 100644 --- a/frontend/src/app/components/playlist/target/target_options.rs +++ b/frontend/src/app/components/playlist/target/target_options.rs @@ -20,6 +20,7 @@ pub fn TargetOptions(props: &TargetOptionsProps) -> Html { None => ( vec![false, false, false, false, false, false], vec![ + (false, "LABEL.CHANNEL_GROUPS"), (false, "LABEL.IGNORE_LOGO"), (false, "LABEL.SHARE_LIVE_STREAMS"), (false, "LABEL.HLS"), @@ -37,8 +38,10 @@ pub fn TargetOptions(props: &TargetOptionsProps) -> Html { (force_redirect.contains(ClusterFlags::Series), "LABEL.SERIES"), ], }; + let channel_groups = options.channel_groups.as_ref().is_some_and(|groups| groups.enabled); ( vec![ + channel_groups, options.ignore_logo, options.share_live_any_enabled(), options.share_live_hls_enabled(), @@ -49,6 +52,7 @@ pub fn TargetOptions(props: &TargetOptionsProps) -> Html { force_redirect[2].0, ], vec![ + (channel_groups, "LABEL.CHANNEL_GROUPS"), (options.ignore_logo, "LABEL.IGNORE_LOGO"), (options.share_live_any_enabled(), "LABEL.SHARE_LIVE_STREAMS"), (options.share_live_hls_enabled(), "LABEL.HLS"), diff --git a/frontend/src/app/components/setup/setup_helpers.rs b/frontend/src/app/components/setup/setup_helpers.rs index 0abe0e247..b77ef1d7a 100644 --- a/frontend/src/app/components/setup/setup_helpers.rs +++ b/frontend/src/app/components/setup/setup_helpers.rs @@ -195,6 +195,9 @@ fn apply_setup_config_forms(config: &mut shared::model::ConfigDto, forms: Vec config.proxy = Some(proxy_cfg), ConfigForm::IpCheck(_, ipcheck_cfg) => config.ipcheck = Some(ipcheck_cfg), ConfigForm::Library(_, library_cfg) => apply_setup_library_form(config, library_cfg), + ConfigForm::ChannelGroups(_, channel_groups_cfg) => { + config.channel_groups = (!channel_groups_cfg.is_default()).then_some(channel_groups_cfg); + } ConfigForm::Panel(_, _) => {} ConfigForm::ApiProxy(_, _) => {} } diff --git a/frontend/src/app/components/setup/step_config.rs b/frontend/src/app/components/setup/step_config.rs index bba0abe0b..caf20eb1c 100644 --- a/frontend/src/app/components/setup/step_config.rs +++ b/frontend/src/app/components/setup/step_config.rs @@ -76,6 +76,7 @@ pub fn ConfigStep(props: &ConfigStepProps) -> Html { ConfigForm::IpCheck(_, _) => setup_ctx.config_forms.slots.ipcheck.as_ref(), ConfigForm::Panel(_, _) => setup_ctx.config_forms.slots.panel.as_ref(), ConfigForm::Library(_, _) => setup_ctx.config_forms.slots.library.as_ref(), + ConfigForm::ChannelGroups(_, _) => setup_ctx.config_forms.slots.channel_groups.as_ref(), }; if current_slot == Some(&form_data) { return; diff --git a/frontend/src/app/components/source_editor/channel_groups_form.rs b/frontend/src/app/components/source_editor/channel_groups_form.rs new file mode 100644 index 000000000..a5156aec4 --- /dev/null +++ b/frontend/src/app/components/source_editor/channel_groups_form.rs @@ -0,0 +1,258 @@ +//! Channel group page of a target: the enable switch and the overrides of the global settings. + +use crate::{ + app::{ + components::{select::Select, selection_first, DropDownOption, DropDownSelection, FieldLabel, ToggleSwitch}, + ConfigContext, + }, + i18n::use_translation, +}; +use shared::model::{ + ChannelGroupPolicy, ChannelGroupsConfig, ChannelGroupsTargetConfig, ChannelMatchBy, EpgConflictMode, +}; +use std::{rc::Rc, str::FromStr}; +use yew::{component, html, use_context, Callback, Html, Properties}; + +const LABEL_ENABLED: &str = "LABEL.ENABLED"; +const LABEL_DISABLED: &str = "LABEL.DISABLED"; +const LABEL_GLOBAL: &str = "LABEL.CHANNEL_GROUPS_GLOBAL"; +const INFO_TARGET: &str = "INFO.CHANNEL_GROUPS_TARGET"; + +/// Select id of the entry that keeps the global value. +const GLOBAL: &str = "global"; + +/// The target block after the enable toggle; a block without settings is dropped so the target +/// YAML stays clean. +pub(super) fn toggle_channel_groups( + current: Option<&ChannelGroupsTargetConfig>, + enabled: bool, +) -> Option { + edit_channel_groups(current, |config| config.enabled = enabled) +} + +/// The target block with one setting changed. +pub(super) fn edit_channel_groups( + current: Option<&ChannelGroupsTargetConfig>, + edit: impl FnOnce(&mut ChannelGroupsTargetConfig), +) -> Option { + let mut config = current.copied().unwrap_or_default(); + edit(&mut config); + (!config.is_default()).then_some(config) +} + +/// The override a select entry stands for; the global entry is `None`. +fn parse_override(id: &str) -> Option { + if id == GLOBAL { + None + } else { + id.parse().ok() + } +} + +/// One override setting: its values, how they read and where they live in the target block. +struct OverrideField { + label: &'static str, + field_id: &'static str, + values: &'static [T], + get: fn(&ChannelGroupsTargetConfig) -> Option, + set: fn(&mut ChannelGroupsTargetConfig, Option), + global: fn(&ChannelGroupsConfig) -> T, +} + +#[derive(Properties, PartialEq)] +pub(super) struct TargetChannelGroupsViewProps { + pub config: Option, + pub readonly: bool, + pub on_change: Callback>, +} + +#[component] +pub(super) fn TargetChannelGroupsView(props: &TargetChannelGroupsViewProps) -> Html { + let translate = use_translation(); + let config_ctx = use_context::(); + let global = config_ctx + .as_ref() + .and_then(|ctx| ctx.config.as_ref()) + .and_then(|app_config| app_config.config.channel_groups.clone()) + .unwrap_or_default(); + let current = props.config.unwrap_or_default(); + let readonly = props.readonly; + + let on_toggle = { + let on_change = props.on_change.clone(); + let config = props.config; + Callback::from(move |enabled: bool| on_change.emit(toggle_channel_groups(config.as_ref(), enabled))) + }; + + let bool_label = |value: bool| translate.t(if value { LABEL_ENABLED } else { LABEL_DISABLED }); + let global_label = translate.t(LABEL_GLOBAL); + + let render = |label: &str, + field_id: &str, + options: Vec, + shown: String, + on_select: Callback<(String, DropDownSelection)>| { + html! { +
+ + if readonly { + { shown } + } else { +