diff --git a/CHANGELOG.md b/CHANGELOG.md index 28c374b02..de85c07c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -186,15 +186,36 @@ ## 🌟 New Features -- **Channel groups with stream failover.** A target with `options.channel_groups.enabled` collapses equal live +- **Failover groups with stream failover.** A target with `options.failover_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). + live once in `config.yml` (`failover_groups`, Web UI: Settings → Failover groups); a target turns the feature on and + can override matching, source order, grace and failover on its own Failover groups page. Off by default. See + [Failover Groups & Stream Failover](docs/src/configuration/failover-groups.md). + +- **Failover groups editor and failover files.** Automatic failover groups can be corrected and your own groups can + be created, either on the new Web UI page **Failover groups** (sidebar → Playlist) or by editing YAML files. The + files only hold deviations from the automatic result (corrections of automatic groups, manual groups, excluded + channels), so automatic grouping keeps working and channels that appear later are still grouped; the `.db` files + stay derived data. One `failover.yml` or a `failover.d/` directory with any number of `.yml` / `.yaml` files holds + any number of targets, each target in exactly one file. With `config_hot_reload: true` changes are picked up + without a restart, an invalid change keeps the previous state, order and policy changes take effect immediately + (also a new first member, the playlist entry refreshes with the next update) and structural changes wait for a + playlist update. The editor offers a group list with filters, member order by + drag and drop, a candidate panel, a preview of unsaved changes, save and save & apply, a conflict dialog when the + file changed elsewhere, member health columns and a problems view for outdated corrections, inactive manual + groups, conflicting or unresolved channels and sections of unknown targets or of targets with failover groups + disabled (with "Assign to target…"), a hint with a marker in the group list while the name and logo of a playlist + entry wait for the next update, and a refresh when a playlist update of the open target finishes (the WebSocket + `PlaylistUpdateResponse` frame carries the processed `targets`). Deep links + come from the source editor target block and the playlist explorer context menu. Saving from the Web UI rewrites + the owning file, comments and formatting of that file are lost. New REST routes below `/api/v1/failover/` and the + event `failover.overrides_changed` serve the editor. See + [Corrections and own groups](docs/src/configuration/failover-groups.md#corrections-and-own-groups-failover-files) + and the [REST API Cookbook](docs/src/rest-api-cookbook.md#failover-groups-editor). - **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 @@ -1124,6 +1145,12 @@ ## 🐛 Fixes +- **`.yaml` files in watched config directories now trigger a hot reload.** `mapping_path` and `template_path` + directories were loaded with `.yml` and `.yaml` files at start, but the file watcher only reacted to `.yml`, so a + `.yaml` file in `mapping.d` or `template.d` was never hot reloaded. +- **Hidden files and editor temporary files in `mapping.d` and `template.d` are skipped.** Files starting with `.` + and names ending in `~` or `.swp` are no longer read as mapping or template files, so a half-saved editor file no + longer breaks a reload. - **Long Telegram and ntfy notifications are no longer rejected.** Telegram accepts at most 4096 characters (UTF-16 units) and ntfy 4096 bytes per message; neither channel had a limit, so a large watch or statistics notification failed and landed in the dead letter queue. Telegram now cuts the final text, after `MarkdownV2` escaping, and drops @@ -1609,16 +1636,24 @@ ## ⚙️ New Settings -- **config.yml (`channel_groups`)**: - - Added the global channel group settings: `match_by`, `match_as_ascii`, `match_normalize`, `epg_conflict`, +- **config.yml (`failover_groups`)**: + - Added the global failover 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). + [Failover Groups & Stream Failover](docs/src/configuration/failover-groups.md). + +- **config.yml (`failover_path`) and CLI `--failover`**: + - Added `failover_path`, the file or directory with the failover files (corrections of automatic failover groups + and manual groups), relative to the config directory. The CLI argument `--failover ` overrides it. Without + either, `failover.yml` is used if it exists, else the directory `failover.d`, else `failover.yml`, which the Web + UI creates with the first save. See + [Corrections and own groups](docs/src/configuration/failover-groups.md#corrections-and-own-groups-failover-files). + Editable in the Web UI on the main configuration page (Failover Path). - **source.yml (target `options`)**: - - Added `channel_groups` (default off): `enabled` plus the optional overrides `match_by`, `match_as_ascii`, + - Added `failover_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`)**: diff --git a/Cargo.lock b/Cargo.lock index 20db13199..9593ae8b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4917,7 +4917,6 @@ dependencies = [ "lru", "rand 0.9.5", "reqwest", - "serde", "serde_json", "shared", "tempfile", diff --git a/README.md b/README.md index bdc3c85c9..3535a5663 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,8 @@ 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 +- **Failover Groups** — one channel per live program across inputs, with source selection and runtime failover; correct + automatic groups and build your own in the Web UI editor or in `failover.yml` / `failover.d/` files with hot reload - **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 @@ -167,9 +168,10 @@ 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 +- **Failover 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 + keeps watching. A Web UI editor and YAML failover files (`failover.yml` or `failover.d/`, hot reloaded) let you + correct automatic groups and create your own - 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 0279578c0..f807aab62 100644 --- a/backend/app/Cargo.toml +++ b/backend/app/Cargo.toml @@ -109,7 +109,7 @@ 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. +# Failover 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" diff --git a/backend/app/src/api/api_utils/channel_group.rs b/backend/app/src/api/api_utils/failover_group.rs similarity index 95% rename from backend/app/src/api/api_utils/channel_group.rs rename to backend/app/src/api/api_utils/failover_group.rs index 5f73aff7d..d0c249ee0 100644 --- a/backend/app/src/api/api_utils/channel_group.rs +++ b/backend/app/src/api/api_utils/failover_group.rs @@ -8,22 +8,22 @@ use axum::http::HeaderMap; use bytes::Bytes; use futures::StreamExt; use shared::model::{ - ChannelGroupPlayback, ChannelGroupPolicy, ChannelGroupStartConfig, PlaylistItemType, StreamChannel, + FailoverGroupPlayback, FailoverGroupPolicy, FailoverGroupStartConfig, 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_repository::failover_group_repository::{FailoverGroupRecord, GroupMember}; +pub(crate) use tuliprox_session::failover_group::{member_key, CircuitAdmission, CircuitView, SelectionMode}; use tuliprox_session::{ active_provider_manager::{CapacityTier, ConnectionKind, ManagedProviderHandle, PlaybackLeaseRef}, - channel_group::{rank_members, FailureClass, HalfOpenGuard, MemberHealthIndex, RankedCandidate}, + failover_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 }, + Group { group: Arc, mode: SelectionMode, prefer: Option }, } pub(crate) struct ResolvedGroupPlayback { @@ -37,10 +37,10 @@ pub(crate) fn group_shared_key(target_id: u16, group_vid: u32) -> String { forma pub(crate) struct GroupSelectionRequest<'a> { pub target_name: &'a Arc, - pub group: &'a ChannelGroupRecord, - pub policy: ChannelGroupPolicy, + pub group: &'a FailoverGroupRecord, + pub policy: FailoverGroupPolicy, pub mode: SelectionMode, - pub start: ChannelGroupStartConfig, + pub start: FailoverGroupStartConfig, pub addr: &'a SocketAddr, pub username: &'a str, pub priority: i8, @@ -60,8 +60,8 @@ 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, + group: &'a FailoverGroupRecord, + start: FailoverGroupStartConfig, addr: &'a SocketAddr, username: &'a str, priority: i8, @@ -360,7 +360,7 @@ async fn select_member_loop)>( pub(crate) async fn resolve_hls_group_entry( app_state: &Arc, - group: Arc, + group: Arc, req: GroupSelectionRequest<'_>, ) -> Result { let max_attempts = u32::from(req.start.max_attempts.max(1)); @@ -416,18 +416,18 @@ pub(crate) fn record_member_success(health: &MemberHealthIndex, member: &GroupMe } } -/// 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) +/// Selection settings of `target`, defaults when the target has no `failover_groups` block. +/// The `config.yml` failover group settings with the overrides of `target`. +pub(crate) fn group_playback_settings(app_state: &AppState, target: &ConfigTarget) -> FailoverGroupPlayback { + let target_cfg = target.options.as_ref().and_then(|options| options.failover_groups.as_ref()); + app_state.app_config.config.load().failover_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, + group: &FailoverGroupRecord, ) -> 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))) @@ -437,7 +437,7 @@ pub(crate) fn group_representative( /// The client channel of a group playback carrying the identity of `member`. pub(crate) fn member_stream_channel( base: &StreamChannel, - group: &ChannelGroupRecord, + group: &FailoverGroupRecord, member: &GroupMember, ) -> StreamChannel { let mut channel = base.clone(); @@ -583,7 +583,7 @@ pub(crate) struct GroupOpenRequest<'a> { pub user: &'a ProxyUserCredentials, pub fingerprint: &'a Fingerprint, pub req_headers: &'a HeaderMap, - pub group: &'a ChannelGroupRecord, + pub group: &'a FailoverGroupRecord, pub base_channel: &'a StreamChannel, pub stream_options: &'a StreamOptions, pub share_stream: bool, diff --git a/backend/app/src/api/api_utils/failover_group_hot_apply.rs b/backend/app/src/api/api_utils/failover_group_hot_apply.rs new file mode 100644 index 000000000..c01b5459c --- /dev/null +++ b/backend/app/src/api/api_utils/failover_group_hot_apply.rs @@ -0,0 +1,310 @@ +//! Applies a failover override change that only reorders members or switches the policy of persisted +//! failover groups directly to the stored records, without a playlist update. + +use crate::{ + api::model::AppState, + repository::{ + get_target_id_mapping_file, get_target_storage_path, FailoverGroupMeta, FailoverGroupOrigin, + FailoverGroupRecord, FailoverGroupStore, FailoverGroupTargetData, + }, +}; +use log::warn; +use shared::model::{ + AutoGroupOverrideDto, ChannelMemberRefDto, FailoverGroupPolicy, FailoverGroupsConfig, ManualFailoverGroupDto, + TargetFailoverOverridesDto, VirtualId, +}; +use std::{ + collections::{BTreeSet, HashMap, HashSet}, + hash::Hash, + sync::Arc, +}; +use tuliprox_core::model::{ConfigTarget, TargetOverridesEntry}; +pub use tuliprox_repository::failover_group_repository::HotApplyGroupChange; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct HotApplyPlan { + /// The changes to write (none when the effective state is unchanged). + pub changes: Vec, + /// Keys of the groups whose first member changes while the published playlist item still takes name, logo, + /// EPG id or playlist group from the old one. + pub display_stale: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HotApplyDecision { + /// Only order and policy changed, also of the first member: playback follows at once. + Apply(HotApplyPlan), + /// The change affects membership or metadata: wait for the next playlist update. + NeedsReprocessing, +} + +fn same_set(left: &[T], right: &[T]) -> bool { + if left.len() != right.len() { + return false; + } + let right: HashSet<&T> = right.iter().collect(); + left.iter().all(|item| right.contains(item)) +} + +fn current_order(record: &FailoverGroupRecord) -> Vec { + record.members.iter().map(|member| member.member_vid).collect() +} + +fn find_record<'a>( + groups: &'a FailoverGroupTargetData, + manual: bool, + key: &str, +) -> Option<&'a Arc> { + groups + .groups + .values() + .find(|record| record.key.as_ref() == key && (record.origin == FailoverGroupOrigin::Manual) == manual) +} + +/// New order of a manual group: the `members` list order, which must cover the stored members. +fn manual_order(record: &FailoverGroupRecord, members: &[ChannelMemberRefDto]) -> Option> { + let order: Vec = members.iter().filter_map(|member| record.member_vid(member)).collect(); + (order.len() == record.members.len()).then_some(order) +} + +/// New order of an auto group as processing computes it: members of `order` first, the rest in their +/// natural order. The natural order is only known for the members that were not ordered by the applied +/// section, so a rest member that the applied `order` placed explicitly forces a playlist update. +fn auto_order( + record: &FailoverGroupRecord, + applied_order: &[ChannelMemberRefDto], + next_order: &[ChannelMemberRefDto], +) -> Option> { + let mut order: Vec = Vec::with_capacity(record.members.len()); + let mut used: HashSet = HashSet::with_capacity(record.members.len()); + for member in next_order { + if let Some(vid) = record.member_vid(member) { + if used.insert(vid) { + order.push(vid); + } + } + } + + let previously_ordered: HashSet = + applied_order.iter().filter_map(|member| record.member_vid(member)).collect(); + for member in &record.members { + if used.contains(&member.member_vid) { + continue; + } + if previously_ordered.contains(&member.member_vid) { + return None; + } + order.push(member.member_vid); + } + Some(order) +} + +/// Everything of a manual group except order and policy. +fn manual_content_equal(applied: &ManualFailoverGroupDto, next: &ManualFailoverGroupDto) -> bool { + applied.id == next.id + && applied.name == next.name + && applied.logo == next.logo + && applied.epg_channel_id == next.epg_channel_id + && applied.playlist_group == next.playlist_group + && same_set(&applied.members, &next.members) +} + +/// Everything of an auto override except its key (the pairing) and order and policy. +fn auto_content_equal(applied: &AutoGroupOverrideDto, next: &AutoGroupOverrideDto) -> bool { + applied.disabled == next.disabled + && applied.name == next.name + && applied.logo == next.logo + && applied.epg_channel_id == next.epg_channel_id + && applied.playlist_group == next.playlist_group + && applied.add == next.add + && same_set(&applied.remove, &next.remove) +} + +/// Overrides by auto key; `None` when a key occurs twice, which no pairing can resolve. +fn overrides_by_key(overrides: &[AutoGroupOverrideDto]) -> Option> { + let by_key: HashMap<&str, &AutoGroupOverrideDto> = + overrides.iter().map(|over| (over.auto_key.as_str(), over)).collect(); + (by_key.len() == overrides.len()).then_some(by_key) +} + +/// Collects the group changes of a plan. +#[derive(Default)] +struct PlanBuilder { + plan: HotApplyPlan, +} + +impl PlanBuilder { + /// Records the new `order` and `policy` of `record`; nothing when they equal the stored ones. + /// `metadata_pinned`: the section pins everything the playlist item would take from the first member. + fn push( + &mut self, + record: &FailoverGroupRecord, + order: Vec, + policy: FailoverGroupPolicy, + metadata_pinned: bool, + ) { + if policy == record.policy && order == current_order(record) { + return; + } + let representative_changed = order.first().copied() != record.members.first().map(|member| member.member_vid); + if representative_changed && !metadata_pinned { + self.plan.display_stale.push(record.key.to_string()); + } + self.plan.changes.push(HotApplyGroupChange { group_vid: record.group_vid, member_order: order, policy }); + } +} + +fn plan_hot_apply( + applied: &TargetFailoverOverridesDto, + next: &TargetFailoverOverridesDto, + groups: &FailoverGroupTargetData, + config: &FailoverGroupsConfig, +) -> Option { + if applied.target != next.target + || applied.groups.len() != next.groups.len() + || !same_set(&applied.excluded, &next.excluded) + { + return None; + } + + let mut builder = PlanBuilder::default(); + + // Manual groups keep their position (it decides which group claims a channel), so they pair by id in order. + for (old, new) in applied.groups.iter().zip(&next.groups) { + if !manual_content_equal(old, new) { + return None; + } + let policy = new.policy.unwrap_or(config.policy); + let order_changed = old.members != new.members; + if !order_changed && old.policy.unwrap_or(config.policy) == policy { + continue; + } + let record = find_record(groups, true, &new.id)?; + let order = if order_changed { manual_order(record, &new.members)? } else { current_order(record) }; + let pinned = new.logo.is_some() && new.epg_channel_id.is_some() && new.playlist_group.is_some(); + builder.push(record, order, policy, pinned); + } + + // Auto overrides pair by key; a group without an override has the default (empty) one. + let applied_overrides = overrides_by_key(&applied.auto_overrides)?; + let next_overrides = overrides_by_key(&next.auto_overrides)?; + let keys: BTreeSet<&str> = applied_overrides.keys().chain(next_overrides.keys()).copied().collect(); + let default_override = AutoGroupOverrideDto::default(); + for key in keys { + let old = applied_overrides.get(key).copied().unwrap_or(&default_override); + let new = next_overrides.get(key).copied().unwrap_or(&default_override); + if !auto_content_equal(old, new) { + return None; + } + let policy = new.policy.unwrap_or(config.policy); + let order_changed = old.order != new.order; + if !order_changed && old.policy.unwrap_or(config.policy) == policy { + continue; + } + let record = find_record(groups, false, key)?; + let order = if order_changed { auto_order(record, &old.order, &new.order)? } else { current_order(record) }; + let pinned = + new.name.is_some() && new.logo.is_some() && new.epg_channel_id.is_some() && new.playlist_group.is_some(); + builder.push(record, order, policy, pinned); + } + Some(builder.plan) +} + +/// Decides whether `next` can be applied to the persisted `groups` without a playlist update: only the order +/// and the policy of groups may differ from the applied section, a new first member included. +/// +/// `applied` is the section that produced `groups` (`FailoverGroupMeta::applied_overrides`); without it +/// nothing can be compared and the answer is [`HotApplyDecision::NeedsReprocessing`]. `config` carries +/// the policy that groups without an explicit one use. +pub fn decide_hot_apply( + applied: Option<&TargetFailoverOverridesDto>, + next: &TargetFailoverOverridesDto, + groups: &FailoverGroupTargetData, + config: &FailoverGroupsConfig, +) -> HotApplyDecision { + applied + .and_then(|applied| plan_hot_apply(applied, next, groups, config)) + .map_or(HotApplyDecision::NeedsReprocessing, HotApplyDecision::Apply) +} + +/// The target named `target` when failover groups are enabled for it, with its grouping settings resolved +/// against `config.yml`. +pub(crate) fn enabled_failover_target( + app_state: &AppState, + target: &str, +) -> Option<(Arc, FailoverGroupsConfig)> { + let sources = app_state.app_config.sources.load(); + let found = sources.get_target_by_name(target)?; + let groups_config = + app_state.app_config.config.load().failover_groups.for_target(found.execution_plan.failover_groups.as_ref()?); + Some((Arc::clone(found), groups_config)) +} + +enum Outcome { + Refused, + /// The store already reflects `next`. + Unchanged, + Written, +} + +/// Applies `next` to the persisted failover groups of `target` when [`decide_hot_apply`] allows it. +/// +/// Runs under the write lock persistence uses for the target, so it never interleaves with a +/// publication. The section that produced the store is the one recorded in its metadata; without it +/// nothing can be compared and the change is refused. On success the metadata records `next`, so the +/// following decision diffs against it, groups whose first member changed without pinned metadata are +/// listed in `display_stale_groups`, and the failover group cache generation is bumped. Returns `false` +/// when the change needs a playlist update or anything failed (logged). +pub async fn try_hot_apply(app_state: &AppState, target: &str, next: &TargetOverridesEntry) -> bool { + let Some((_, groups_config)) = enabled_failover_target(app_state, target) else { + return false; + }; + let Some(target_path) = get_target_storage_path(&app_state.app_config.config.load(), target) else { + return false; + }; + + let _target_lock = app_state.app_config.file_locks.write_lock(&get_target_id_mapping_file(&target_path)).await; + let section = next.overrides.clone(); + let etag = Arc::clone(&next.etag); + let outcome = tokio::task::spawn_blocking(move || -> std::io::Result { + let data = FailoverGroupStore::load_target_data(&target_path, 0)?; + let HotApplyDecision::Apply(plan) = + decide_hot_apply(data.meta.applied_overrides.as_ref(), §ion, &data, &groups_config) + else { + return Ok(Outcome::Refused); + }; + if plan.changes.is_empty() && data.meta.overrides_etag.as_deref() == Some(etag.as_ref()) { + return Ok(Outcome::Unchanged); + } + let mut display_stale_groups = data.meta.display_stale_groups; + display_stale_groups.extend(plan.display_stale); + display_stale_groups.sort_unstable(); + display_stale_groups.dedup(); + let meta = FailoverGroupMeta { + overrides_etag: Some(etag), + overrides_error: data.meta.overrides_error, + applied_overrides: Some(section), + display_stale_groups, + }; + FailoverGroupStore::apply_hot_change(&target_path, &plan.changes, &meta)?; + Ok(Outcome::Written) + }) + .await; + + match outcome { + Ok(Ok(Outcome::Written)) => { + app_state.playlists.bump_failover_group_generation(target); + true + } + Ok(Ok(Outcome::Unchanged)) => true, + Ok(Ok(Outcome::Refused)) => false, + Ok(Err(err)) => { + warn!("Hot apply of failover group overrides for target {target} failed: {err}"); + false + } + Err(err) => { + warn!("Hot apply of failover group overrides for target {target} was aborted: {err}"); + false + } + } +} diff --git a/backend/app/src/api/api_utils/channel_group_failover/budget.rs b/backend/app/src/api/api_utils/failover_group_runtime/budget.rs similarity index 97% rename from backend/app/src/api/api_utils/channel_group_failover/budget.rs rename to backend/app/src/api/api_utils/failover_group_runtime/budget.rs index 2f8918d23..a37ca502d 100644 --- a/backend/app/src/api/api_utils/channel_group_failover/budget.rs +++ b/backend/app/src/api/api_utils/failover_group_runtime/budget.rs @@ -1,4 +1,4 @@ -use shared::model::ChannelGroupFailoverConfig; +use shared::model::FailoverGroupRuntimeConfig; use std::{ collections::VecDeque, time::{Duration, Instant}, @@ -36,7 +36,7 @@ pub(crate) struct FailoverBudget { } impl FailoverBudget { - pub(crate) fn new(failover: &ChannelGroupFailoverConfig) -> Self { + pub(crate) fn new(failover: &FailoverGroupRuntimeConfig) -> Self { Self { budget: Duration::from_secs(failover.max_outage_secs), stable_reset: Duration::from_secs(failover.stable_after_secs), @@ -127,17 +127,17 @@ impl FailoverBudget { #[cfg(test)] mod tests { use super::*; - use shared::model::ChannelGroupFailoverConfig; + use shared::model::FailoverGroupRuntimeConfig; use std::time::{Duration, Instant}; - fn config(max_switches: u8) -> ChannelGroupFailoverConfig { - ChannelGroupFailoverConfig { + fn config(max_switches: u8) -> FailoverGroupRuntimeConfig { + FailoverGroupRuntimeConfig { max_outage_secs: 20, stable_after_secs: 30, max_switches_window_secs: 600, max_switches, same_source_retries: 1, - ..ChannelGroupFailoverConfig::default() + ..FailoverGroupRuntimeConfig::default() } } diff --git a/backend/app/src/api/api_utils/channel_group_failover/mod.rs b/backend/app/src/api/api_utils/failover_group_runtime/mod.rs similarity index 97% rename from backend/app/src/api/api_utils/channel_group_failover/mod.rs rename to backend/app/src/api/api_utils/failover_group_runtime/mod.rs index eb0a4e9c2..517a955cc 100644 --- a/backend/app/src/api/api_utils/channel_group_failover/mod.rs +++ b/backend/app/src/api/api_utils/failover_group_runtime/mod.rs @@ -1,4 +1,4 @@ -//! Runtime failover of channel group playback: the provider body behind an open client response +//! Runtime failover of failover 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; @@ -6,11 +6,12 @@ mod budget; mod tests; use super::{ - channel_group::{ + connection_priority_for_kind, + failover_group::{ acquire_group_member, open_group_member, record_member_failure, GroupOpenRequest, GroupSelectionFailure, GroupSelectionRequest, OpenedGroupMember, ProbedPayload, }, - connection_priority_for_kind, get_stream_options, StreamResponseMode, + get_stream_options, StreamResponseMode, }; use crate::{ api::model::{AppState, BoxedProviderStream, StreamError}, @@ -22,7 +23,7 @@ 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 shared::model::{EventMessage, FailoverGroupPlayback, StreamChannel, SwitchReason, UserConnectionPermission}; use std::{ pin::Pin, sync::Arc, @@ -36,10 +37,10 @@ use tuliprox_mpegts::{ packet::ts_null_packet_bytes, splice::{RealtimeMeter, SpliceOutput, TsSpliceAligner}, }; -use tuliprox_repository::channel_group_repository::ChannelGroupRecord; +use tuliprox_repository::failover_group_repository::FailoverGroupRecord; use tuliprox_session::{ active_provider_manager::{ConnectionKind, ManagedProviderHandle}, - channel_group::{AudioSignature, FailureClass, SelectionMode}, + failover_group::{AudioSignature, FailureClass, SelectionMode}, MemberBinding, }; @@ -64,8 +65,8 @@ pub(crate) struct GroupFailoverContext { 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 group: Arc, + pub playback: FailoverGroupPlayback, pub user: ProxyUserCredentials, pub fingerprint: Fingerprint, pub req_headers: HeaderMap, @@ -341,7 +342,7 @@ async fn adopt_member( .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 { + app_state.event_manager.send_event(EventMessage::FailoverGroupMemberSwitched { target_id: ctx.target.id, group_vid: ctx.group.group_vid.get(), from_member_vid: ctx.group.members[previous].member_vid.get(), diff --git a/backend/app/src/api/api_utils/channel_group_failover/tests.rs b/backend/app/src/api/api_utils/failover_group_runtime/tests.rs similarity index 91% rename from backend/app/src/api/api_utils/channel_group_failover/tests.rs rename to backend/app/src/api/api_utils/failover_group_runtime/tests.rs index 8445b268f..62d852459 100644 --- a/backend/app/src/api/api_utils/channel_group_failover/tests.rs +++ b/backend/app/src/api/api_utils/failover_group_runtime/tests.rs @@ -1,6 +1,6 @@ use super::failure_class_for; use shared::model::SwitchReason; -use tuliprox_session::channel_group::FailureClass; +use tuliprox_session::failover_group::FailureClass; #[test] fn upstream_error_class_follows_http_status() { @@ -23,7 +23,7 @@ mod switcher { }; use bytes::Bytes; use futures::StreamExt; - use shared::model::ChannelGroupFailoverConfig; + use shared::model::FailoverGroupRuntimeConfig; use std::{ sync::Arc, task::{Context, Poll}, @@ -59,7 +59,7 @@ mod switcher { } } - async fn switcher(failover: ChannelGroupFailoverConfig) -> (GroupPlaybackEnv, GroupSwitcher) { + async fn switcher(failover: FailoverGroupRuntimeConfig) -> (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 { @@ -67,9 +67,9 @@ mod switcher { target: Arc::clone(&env.target), target_name: env.target.name.as_str().into(), group: Arc::clone(&env.group_record), - playback: shared::model::ChannelGroupPlayback { + playback: shared::model::FailoverGroupPlayback { failover, - ..shared::model::ChannelGroupsConfig::default().playback(None) + ..shared::model::FailoverGroupsConfig::default().playback(None) }, user: (*user).clone(), fingerprint: crate::auth::Fingerprint::new( @@ -89,8 +89,8 @@ mod switcher { (env, switcher) } - fn no_budget() -> ChannelGroupFailoverConfig { - ChannelGroupFailoverConfig { max_outage_secs: 0, ..ChannelGroupFailoverConfig::default() } + fn no_budget() -> FailoverGroupRuntimeConfig { + FailoverGroupRuntimeConfig { max_outage_secs: 0, ..FailoverGroupRuntimeConfig::default() } } async fn poll(switcher: &mut GroupSwitcher, host: &mut FakeHost) -> GroupPoll { @@ -103,7 +103,7 @@ mod switcher { #[tokio::test] async fn preempted_member_ends_without_switch() { - let (_env, mut switcher) = switcher(ChannelGroupFailoverConfig::default()).await; + let (_env, mut switcher) = switcher(FailoverGroupRuntimeConfig::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)); @@ -138,7 +138,7 @@ mod switcher { #[tokio::test] async fn stall_without_budget_ends_stalled() { - let selection = ChannelGroupFailoverConfig { no_data_timeout_secs: 1, ..no_budget() }; + let selection = FailoverGroupRuntimeConfig { 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 = @@ -166,11 +166,11 @@ mod switcher { #[tokio::test] async fn slow_member_triggers_underrun_switch() { - let selection = ChannelGroupFailoverConfig { + let selection = FailoverGroupRuntimeConfig { slow_source_pct: 90, slow_source_secs: 2, no_data_timeout_secs: 0, - ..ChannelGroupFailoverConfig::default() + ..FailoverGroupRuntimeConfig::default() }; let (_env, mut switcher) = switcher(selection).await; // Every 100 ms of wall time the provider delivers 40 ms of media. @@ -192,7 +192,7 @@ mod switcher { #[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 selection = FailoverGroupRuntimeConfig { 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 { diff --git a/backend/app/src/api/api_utils/mod.rs b/backend/app/src/api/api_utils/mod.rs index fd5c21be1..084f683ba 100644 --- a/backend/app/src/api/api_utils/mod.rs +++ b/backend/app/src/api/api_utils/mod.rs @@ -238,9 +238,10 @@ pub(crate) mod tests; mod activation; mod auth; mod catchup; -pub(crate) mod channel_group; -pub(crate) mod channel_group_failover; mod clock; +pub(crate) mod failover_group; +pub(crate) mod failover_group_hot_apply; +pub(crate) mod failover_group_runtime; mod local_files; mod maintenance; mod media_server; @@ -326,7 +327,7 @@ pub use self::{ 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}, + failover_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 bcc2b6de5..d1e9d05f8 100644 --- a/backend/app/src/api/api_utils/provider_acquire.rs +++ b/backend/app/src/api/api_utils/provider_acquire.rs @@ -1,16 +1,16 @@ use super::{ activate_session_before_stream_open, admission_failure_response, blocks_new_playback, - channel_group::{ + cleanup_failed_detected_catchup_hls, connection_priority_for_kind, create_stream_response_details, + detected_catchup_hls_response, + failover_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, - no_custom_video_fallback_status, prepare_body_stream, prepare_stream_metering, probe_catchup_payload, - record_connect_failed_attempt, reentry_suppressed_response, resolve_request_url_for_logging, + failover_group_runtime::{GroupFailoverContext, GroupFailoverDriver, GroupFailoverStream}, + get_session_reservation_ttl_secs, get_stream_options, is_socket_bound_playback_session, is_stream_share_enabled, + mark_response_as_uncompressed, no_custom_video_fallback_status, prepare_body_stream, prepare_stream_metering, + probe_catchup_payload, record_connect_failed_attempt, reentry_suppressed_response, resolve_request_url_for_logging, resolve_xtream_vod_provider_url, select_provider_stream_url, should_pin_provider_for_session, stream_admission_rejected_response, try_shared_stream_response_if_any, try_unwrap_body, CatchupPayload, ConnectFailedAttempt, DetectedCatchupHlsResponseParams, ExactProviderAcquire, PlaybackRequestClass, @@ -41,7 +41,7 @@ use shared::{ utils::{extract_extension_from_url, sanitize_sensitive_info, Internable}, }; use std::{borrow::Cow, sync::Arc, time::Duration}; -use tuliprox_session::{channel_group::FailureClass, ProviderSessionHeaders}; +use tuliprox_session::{failover_group::FailureClass, ProviderSessionHeaders}; pub(super) fn create_unmapped_provider_stream(app_config: &AppConfig) -> ProviderStreamState { ProviderStreamState::Custom { @@ -338,7 +338,7 @@ pub(crate) async fn stream_response_with_provider_handle( 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 { + app_state.event_manager.send_event(EventMessage::FailoverGroupExhausted { target_id: target.id, group_vid: group.group_vid.get(), }); @@ -729,7 +729,7 @@ pub(crate) async fn stream_response_with_provider_handle( ConnectFailureReason::Maintenance, ); } - app_state.event_manager.send_event(EventMessage::ChannelGroupExhausted { + app_state.event_manager.send_event(EventMessage::FailoverGroupExhausted { target_id: target.id, group_vid: group.group_vid.get(), }); @@ -751,7 +751,7 @@ pub(crate) async fn stream_response_with_provider_handle( .into_response(); }; - app_state.event_manager.send_event(EventMessage::ChannelGroupMemberSelected { + app_state.event_manager.send_event(EventMessage::FailoverGroupMemberSelected { target_id: target.id, group_vid: group.group_vid.get(), member_vid: opened.member.member_vid.get(), diff --git a/backend/app/src/api/api_utils/tests.rs b/backend/app/src/api/api_utils/tests.rs index 947421ddf..99eba44f5 100644 --- a/backend/app/src/api/api_utils/tests.rs +++ b/backend/app/src/api/api_utils/tests.rs @@ -38,9 +38,10 @@ 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; +mod failover_group_hot_apply; +mod failover_group_playback; +mod failover_group_probe; +mod failover_group_runtime; +mod failover_group_selection; +mod failover_group_sticky; pub(crate) mod group_playback_support; diff --git a/backend/app/src/api/api_utils/tests/failover_group_hot_apply.rs b/backend/app/src/api/api_utils/tests/failover_group_hot_apply.rs new file mode 100644 index 000000000..0ada8f674 --- /dev/null +++ b/backend/app/src/api/api_utils/tests/failover_group_hot_apply.rs @@ -0,0 +1,629 @@ +use super::support::{create_test_app_config, create_test_app_state_for_config, create_test_shared_target}; +use crate::{ + api::api_utils::failover_group_hot_apply::{ + decide_hot_apply, try_hot_apply, HotApplyDecision, HotApplyGroupChange, HotApplyPlan, + }, + model::{Config, ConfigSource, SourcesConfig}, + repository::{ + FailoverGroupMeta, FailoverGroupOrigin, FailoverGroupRecord, FailoverGroupSideFiles, FailoverGroupStore, + FailoverGroupTargetData, GroupMember, + }, +}; +use arc_swap::ArcSwap; +use shared::model::{ + AutoGroupOverrideDto, ChannelMemberRefDto, FailoverGroupPolicy, FailoverGroupsConfig, FailoverGroupsTargetConfig, + ManualFailoverGroupDto, PlaylistItemType, TargetFailoverOverridesDto, VirtualId, XtreamCluster, +}; +use std::sync::Arc; +use tuliprox_core::model::TargetOverridesEntry; + +const TARGET: &str = "hot target"; +const GROUP_VID: VirtualId = VirtualId(100); + +fn member(vid: VirtualId, input: &str, stream_id: &str) -> GroupMember { + GroupMember { + member_vid: vid, + input_name: Arc::from(input), + provider_id: 0, + input_stream_id: Arc::from(stream_id), + url: Arc::from("http://provider.test/live"), + item_type: PlaylistItemType::Live, + cluster: XtreamCluster::Live, + title: Arc::from("ZDF"), + archive: None, + epg_channel_id: None, + additional_properties: None, + } +} + +fn record(origin: FailoverGroupOrigin, key: &str, policy: FailoverGroupPolicy) -> FailoverGroupRecord { + FailoverGroupRecord { + group_vid: GROUP_VID, + key: Arc::from(key), + origin, + policy, + members: vec![member(VirtualId(1), "a", "1"), member(VirtualId(2), "b", "2"), member(VirtualId(3), "c", "3")], + orphaned: Vec::new(), + } +} + +fn data(records: Vec) -> FailoverGroupTargetData { + FailoverGroupTargetData { + groups: records.into_iter().map(|record| (record.group_vid, Arc::new(record))).collect(), + ..FailoverGroupTargetData::default() + } +} + +fn member_ref(input: &str, stream_id: &str) -> ChannelMemberRefDto { + ChannelMemberRefDto { input: input.to_string(), stream_id: stream_id.to_string() } +} + +fn auto_override(order: &[(&str, &str)]) -> AutoGroupOverrideDto { + AutoGroupOverrideDto { + auto_key: "zdf".to_string(), + order: order.iter().map(|(input, id)| member_ref(input, id)).collect(), + ..AutoGroupOverrideDto::default() + } +} + +fn pinned(mut ao: AutoGroupOverrideDto) -> AutoGroupOverrideDto { + ao.name = Some("ZDF".to_string()); + ao.logo = Some("http://logo.test/zdf.png".to_string()); + ao.epg_channel_id = Some("zdf.de".to_string()); + ao.playlist_group = Some("Vollprogramm".to_string()); + ao +} + +fn auto_section(ao: AutoGroupOverrideDto) -> TargetFailoverOverridesDto { + TargetFailoverOverridesDto { auto_overrides: vec![ao], ..TargetFailoverOverridesDto::new(TARGET) } +} + +fn manual(members: &[(&str, &str)]) -> ManualFailoverGroupDto { + ManualFailoverGroupDto { + id: "sport".to_string(), + name: "Sport".to_string(), + members: members.iter().map(|(input, id)| member_ref(input, id)).collect(), + ..ManualFailoverGroupDto::default() + } +} + +fn manual_section(group: ManualFailoverGroupDto) -> TargetFailoverOverridesDto { + TargetFailoverOverridesDto { groups: vec![group], ..TargetFailoverOverridesDto::new(TARGET) } +} + +fn auto_config() -> FailoverGroupsConfig { + FailoverGroupsConfig { policy: FailoverGroupPolicy::Auto, ..Default::default() } +} + +fn auto_store() -> FailoverGroupTargetData { + data(vec![record(FailoverGroupOrigin::AutoEdited, "zdf", FailoverGroupPolicy::Auto)]) +} + +fn manual_store() -> FailoverGroupTargetData { + data(vec![record(FailoverGroupOrigin::Manual, "sport", FailoverGroupPolicy::Auto)]) +} + +fn vids(raw: &[u32]) -> Vec { raw.iter().copied().map(VirtualId).collect() } + +fn change(member_order: &[VirtualId], policy: FailoverGroupPolicy) -> HotApplyGroupChange { + HotApplyGroupChange { group_vid: GROUP_VID, member_order: member_order.to_vec(), policy } +} + +/// The plan of a decision that must hot apply. +fn plan_of(decision: HotApplyDecision) -> HotApplyPlan { + match decision { + HotApplyDecision::Apply(plan) => plan, + HotApplyDecision::NeedsReprocessing => panic!("the change must hot apply"), + } +} + +#[test] +fn order_only_change_hot_applies() { + let applied = auto_section(auto_override(&[])); + let next = auto_section(auto_override(&[("a", "1"), ("c", "3")])); + match decide_hot_apply(Some(&applied), &next, &auto_store(), &auto_config()) { + HotApplyDecision::Apply(HotApplyPlan { changes, .. }) => { + assert_eq!(changes, vec![change(&[VirtualId(1), VirtualId(3), VirtualId(2)], FailoverGroupPolicy::Auto)]); + } + HotApplyDecision::NeedsReprocessing => panic!("order-only change must hot apply"), + } +} + +#[test] +fn policy_only_change_hot_applies() { + let applied = auto_section(auto_override(&[])); + let mut next_override = auto_override(&[]); + next_override.policy = Some(FailoverGroupPolicy::Priority); + let next = auto_section(next_override); + match decide_hot_apply(Some(&applied), &next, &auto_store(), &auto_config()) { + HotApplyDecision::Apply(HotApplyPlan { changes, .. }) => { + assert_eq!( + changes, + vec![change(&[VirtualId(1), VirtualId(2), VirtualId(3)], FailoverGroupPolicy::Priority)] + ); + } + HotApplyDecision::NeedsReprocessing => panic!("policy-only change must hot apply"), + } +} + +#[test] +fn explicit_policy_equal_to_the_default_changes_nothing() { + let applied = auto_section(auto_override(&[])); + let mut next_override = auto_override(&[]); + next_override.policy = Some(FailoverGroupPolicy::Auto); + let next = auto_section(next_override); + assert_eq!( + decide_hot_apply(Some(&applied), &next, &auto_store(), &auto_config()), + HotApplyDecision::Apply(HotApplyPlan::default()) + ); +} + +#[test] +fn representative_change_hot_applies_and_marks_display_stale() { + let applied = auto_section(auto_override(&[])); + let next = auto_section(auto_override(&[("b", "2")])); + let plan = plan_of(decide_hot_apply(Some(&applied), &next, &auto_store(), &auto_config())); + assert_eq!(plan.changes, vec![change(&[VirtualId(2), VirtualId(1), VirtualId(3)], FailoverGroupPolicy::Auto)]); + assert_eq!(plan.display_stale, ["zdf"]); +} + +#[test] +fn pinned_metadata_representative_change_is_not_display_stale() { + let applied = auto_section(pinned(auto_override(&[]))); + let next = auto_section(pinned(auto_override(&[("b", "2")]))); + let plan = plan_of(decide_hot_apply(Some(&applied), &next, &auto_store(), &auto_config())); + assert_eq!(plan.changes, vec![change(&[VirtualId(2), VirtualId(1), VirtualId(3)], FailoverGroupPolicy::Auto)]); + assert!(plan.display_stale.is_empty()); +} + +#[test] +fn partly_pinned_metadata_representative_change_is_display_stale() { + let mut applied_override = pinned(auto_override(&[])); + applied_override.logo = None; + let mut next_override = pinned(auto_override(&[("b", "2")])); + next_override.logo = None; + let plan = plan_of(decide_hot_apply( + Some(&auto_section(applied_override)), + &auto_section(next_override), + &auto_store(), + &auto_config(), + )); + assert_eq!(plan.display_stale, ["zdf"]); +} + +#[test] +fn order_change_keeping_the_representative_is_not_display_stale() { + let applied = auto_section(auto_override(&[])); + let next = auto_section(auto_override(&[("a", "1"), ("c", "3")])); + let plan = plan_of(decide_hot_apply(Some(&applied), &next, &auto_store(), &auto_config())); + assert_eq!(plan.changes.len(), 1); + assert!(plan.display_stale.is_empty()); +} + +#[test] +fn first_reorder_of_untouched_auto_group_hot_applies() { + // The applied section has no override at all for the group: the default (empty) override is the base. + let applied = TargetFailoverOverridesDto::new(TARGET); + let next = auto_section(auto_override(&[("a", "1"), ("c", "3")])); + let plan = plan_of(decide_hot_apply(Some(&applied), &next, &auto_store(), &auto_config())); + assert_eq!(plan.changes, vec![change(&[VirtualId(1), VirtualId(3), VirtualId(2)], FailoverGroupPolicy::Auto)]); +} + +#[test] +fn first_policy_change_of_untouched_auto_group_hot_applies() { + let applied = TargetFailoverOverridesDto::new(TARGET); + let mut next_override = auto_override(&[]); + next_override.policy = Some(FailoverGroupPolicy::Priority); + let plan = plan_of(decide_hot_apply(Some(&applied), &auto_section(next_override), &auto_store(), &auto_config())); + assert_eq!(plan.changes, vec![change(&[VirtualId(1), VirtualId(2), VirtualId(3)], FailoverGroupPolicy::Priority)]); +} + +#[test] +fn removing_policy_only_override_hot_applies() { + let next = TargetFailoverOverridesDto::new(TARGET); + let mut policy_only = auto_override(&[]); + policy_only.policy = Some(FailoverGroupPolicy::Priority); + let store = data(vec![record(FailoverGroupOrigin::AutoEdited, "zdf", FailoverGroupPolicy::Priority)]); + let plan = plan_of(decide_hot_apply(Some(&auto_section(policy_only)), &next, &store, &auto_config())); + assert_eq!(plan.changes, vec![change(&[VirtualId(1), VirtualId(2), VirtualId(3)], FailoverGroupPolicy::Auto)]); +} + +#[test] +fn removing_an_order_override_needs_reprocessing_while_the_natural_order_is_unknown() { + // The applied override moved "c" to the front; where "c" belongs naturally is not stored. + let applied = auto_section(auto_override(&[("c", "3")])); + let next = TargetFailoverOverridesDto::new(TARGET); + let store = data(vec![FailoverGroupRecord { + members: vec![member(VirtualId(3), "c", "3"), member(VirtualId(1), "a", "1"), member(VirtualId(2), "b", "2")], + ..record(FailoverGroupOrigin::AutoEdited, "zdf", FailoverGroupPolicy::Auto) + }]); + assert_eq!(decide_hot_apply(Some(&applied), &next, &store, &auto_config()), HotApplyDecision::NeedsReprocessing); +} + +#[test] +fn removing_an_empty_override_applies_nothing() { + let applied = auto_section(auto_override(&[])); + let next = TargetFailoverOverridesDto::new(TARGET); + assert_eq!( + decide_hot_apply(Some(&applied), &next, &auto_store(), &auto_config()), + HotApplyDecision::Apply(HotApplyPlan::default()) + ); +} + +#[test] +fn reordered_override_entries_do_not_force_reprocessing() { + let rtl_record = FailoverGroupRecord { + group_vid: VirtualId(101), + ..record(FailoverGroupOrigin::AutoEdited, "rtl", FailoverGroupPolicy::Auto) + }; + let store = data(vec![record(FailoverGroupOrigin::AutoEdited, "zdf", FailoverGroupPolicy::Auto), rtl_record]); + let rtl = |order: &[(&str, &str)]| AutoGroupOverrideDto { auto_key: "rtl".to_string(), ..auto_override(order) }; + let applied = TargetFailoverOverridesDto { + auto_overrides: vec![auto_override(&[]), rtl(&[])], + ..TargetFailoverOverridesDto::new(TARGET) + }; + // The entries come in the other order and only "rtl" changes its member order. + let next = TargetFailoverOverridesDto { + auto_overrides: vec![rtl(&[("a", "1"), ("c", "3")]), auto_override(&[])], + ..TargetFailoverOverridesDto::new(TARGET) + }; + let plan = plan_of(decide_hot_apply(Some(&applied), &next, &store, &auto_config())); + assert_eq!( + plan.changes, + vec![HotApplyGroupChange { + group_vid: VirtualId(101), + member_order: vids(&[1, 3, 2]), + policy: FailoverGroupPolicy::Auto, + }] + ); +} + +#[test] +fn duplicate_override_keys_need_reprocessing() { + let applied = auto_section(auto_override(&[])); + let next = TargetFailoverOverridesDto { + auto_overrides: vec![auto_override(&[("a", "1")]), auto_override(&[("c", "3")])], + ..TargetFailoverOverridesDto::new(TARGET) + }; + assert_eq!( + decide_hot_apply(Some(&applied), &next, &auto_store(), &auto_config()), + HotApplyDecision::NeedsReprocessing + ); +} + +#[test] +fn membership_change_needs_reprocessing() { + let applied = auto_section(auto_override(&[])); + let mut removed = auto_override(&[]); + removed.remove = vec![member_ref("c", "3")]; + let mut added = auto_override(&[]); + added.add = vec![member_ref("d", "4")]; + let mut dissolved = auto_override(&[]); + dissolved.disabled = true; + let mut renamed = auto_override(&[]); + renamed.name = Some("Zweites".to_string()); + for next_override in [removed, added, dissolved, renamed] { + assert_eq!( + decide_hot_apply(Some(&applied), &auto_section(next_override), &auto_store(), &auto_config()), + HotApplyDecision::NeedsReprocessing + ); + } +} + +#[test] +fn added_exclusions_or_manual_groups_need_reprocessing() { + let applied = auto_section(auto_override(&[])); + let mut excluded = auto_section(auto_override(&[])); + excluded.excluded = vec![member_ref("z", "9")]; + let with_manual = TargetFailoverOverridesDto { groups: vec![manual(&[])], ..applied.clone() }; + for next in [excluded, with_manual] { + assert_eq!( + decide_hot_apply(Some(&applied), &next, &auto_store(), &auto_config()), + HotApplyDecision::NeedsReprocessing + ); + } +} + +#[test] +fn unknown_applied_section_needs_reprocessing() { + let next = auto_section(auto_override(&[("a", "1"), ("c", "3")])); + assert_eq!(decide_hot_apply(None, &next, &auto_store(), &auto_config()), HotApplyDecision::NeedsReprocessing); +} + +#[test] +fn group_missing_from_the_store_needs_reprocessing() { + let applied = auto_section(auto_override(&[])); + let next = auto_section(auto_override(&[("a", "1"), ("c", "3")])); + assert_eq!( + decide_hot_apply(Some(&applied), &next, &FailoverGroupTargetData::default(), &auto_config()), + HotApplyDecision::NeedsReprocessing + ); + let other_origin = data(vec![record(FailoverGroupOrigin::Manual, "zdf", FailoverGroupPolicy::Auto)]); + assert_eq!( + decide_hot_apply(Some(&applied), &next, &other_origin, &auto_config()), + HotApplyDecision::NeedsReprocessing + ); +} + +#[test] +fn equal_sections_apply_nothing() { + let section = auto_section(auto_override(&[("a", "1")])); + assert_eq!( + decide_hot_apply(Some(§ion), §ion, &auto_store(), &auto_config()), + HotApplyDecision::Apply(HotApplyPlan::default()) + ); +} + +#[test] +fn partial_order_that_drops_a_previously_ordered_member_needs_reprocessing() { + // The stored order puts "b" and "c" first, so the natural order of "c" is not known any more. + let applied = auto_section(auto_override(&[("b", "2"), ("c", "3")])); + let next = auto_section(auto_override(&[("b", "2")])); + let store = data(vec![FailoverGroupRecord { + members: vec![member(VirtualId(2), "b", "2"), member(VirtualId(3), "c", "3"), member(VirtualId(1), "a", "1")], + ..record(FailoverGroupOrigin::AutoEdited, "zdf", FailoverGroupPolicy::Auto) + }]); + assert_eq!(decide_hot_apply(Some(&applied), &next, &store, &auto_config()), HotApplyDecision::NeedsReprocessing); +} + +#[test] +fn partial_order_extending_a_previous_order_hot_applies() { + let applied = auto_section(auto_override(&[("b", "2")])); + let next = auto_section(auto_override(&[("b", "2"), ("c", "3")])); + let store = data(vec![FailoverGroupRecord { + members: vec![member(VirtualId(2), "b", "2"), member(VirtualId(1), "a", "1"), member(VirtualId(3), "c", "3")], + ..record(FailoverGroupOrigin::AutoEdited, "zdf", FailoverGroupPolicy::Auto) + }]); + match decide_hot_apply(Some(&applied), &next, &store, &auto_config()) { + HotApplyDecision::Apply(HotApplyPlan { changes, .. }) => { + assert_eq!(changes, vec![change(&[VirtualId(2), VirtualId(3), VirtualId(1)], FailoverGroupPolicy::Auto)]); + } + HotApplyDecision::NeedsReprocessing => panic!("extending the order must hot apply"), + } +} + +#[test] +fn manual_group_order_change_keeping_the_first_member_hot_applies() { + let applied = manual_section(manual(&[("a", "1"), ("b", "2"), ("c", "3")])); + let next = manual_section(manual(&[("a", "1"), ("c", "3"), ("b", "2")])); + match decide_hot_apply(Some(&applied), &next, &manual_store(), &auto_config()) { + HotApplyDecision::Apply(HotApplyPlan { changes, .. }) => { + assert_eq!(changes, vec![change(&[VirtualId(1), VirtualId(3), VirtualId(2)], FailoverGroupPolicy::Auto)]); + } + HotApplyDecision::NeedsReprocessing => panic!("manual reorder must hot apply"), + } +} + +#[test] +fn manual_group_new_first_member_hot_applies_and_is_display_stale_unless_pinned() { + let applied = manual_section(manual(&[("a", "1"), ("b", "2"), ("c", "3")])); + let next = manual_section(manual(&[("c", "3"), ("a", "1"), ("b", "2")])); + let plan = plan_of(decide_hot_apply(Some(&applied), &next, &manual_store(), &auto_config())); + assert_eq!(plan.changes, vec![change(&[VirtualId(3), VirtualId(1), VirtualId(2)], FailoverGroupPolicy::Auto)]); + assert_eq!(plan.display_stale, ["sport"]); + + let pin = |mut group: ManualFailoverGroupDto| { + group.logo = Some("http://logo.test/sport.png".to_string()); + group.epg_channel_id = Some("sport.de".to_string()); + group.playlist_group = Some("Sport".to_string()); + group + }; + let applied = manual_section(pin(manual(&[("a", "1"), ("b", "2"), ("c", "3")]))); + let next = manual_section(pin(manual(&[("c", "3"), ("a", "1"), ("b", "2")]))); + let plan = plan_of(decide_hot_apply(Some(&applied), &next, &manual_store(), &auto_config())); + assert_eq!(plan.changes, vec![change(&[VirtualId(3), VirtualId(1), VirtualId(2)], FailoverGroupPolicy::Auto)]); + assert!(plan.display_stale.is_empty()); +} + +#[test] +fn manual_group_membership_change_needs_reprocessing() { + let applied = manual_section(manual(&[("a", "1"), ("b", "2"), ("c", "3")])); + let next = manual_section(manual(&[("a", "1"), ("b", "2")])); + assert_eq!( + decide_hot_apply(Some(&applied), &next, &manual_store(), &auto_config()), + HotApplyDecision::NeedsReprocessing + ); +} + +struct HotApplyEnv { + app_state: Arc, + target_path: std::path::PathBuf, + dir: tempfile::TempDir, +} + +fn env_with_store(applied: &TargetFailoverOverridesDto, etag: &str) -> HotApplyEnv { + let dir = tempfile::TempDir::new().expect("temp dir"); + let mut target = create_test_shared_target(); + target.name = TARGET.to_string(); + target.execution_plan.failover_groups = Some(FailoverGroupsTargetConfig { enabled: true, ..Default::default() }); + + let mut app_config = create_test_app_config(); + app_config.config = Arc::new(ArcSwap::from_pointee(Config { + storage_dir: dir.path().to_string_lossy().into_owned(), + failover_groups: auto_config(), + ..Config::default() + })); + app_config.sources = Arc::new(ArcSwap::from_pointee(SourcesConfig { + sources: vec![ConfigSource { inputs: Vec::new(), targets: vec![Arc::new(target)] }], + ..SourcesConfig::default() + })); + let app_state = create_test_app_state_for_config(Arc::new(app_config)); + + let target_path = dir.path().join(TARGET.replace(' ', "_")); + std::fs::create_dir_all(&target_path).expect("target dir"); + let meta = FailoverGroupMeta { + overrides_etag: Some(Arc::from(etag)), + applied_overrides: Some(applied.clone()), + ..FailoverGroupMeta::default() + }; + FailoverGroupStore::write( + &target_path, + &[record(FailoverGroupOrigin::AutoEdited, "zdf", FailoverGroupPolicy::Auto)], + &[], + Some(FailoverGroupSideFiles { catalog: &[], meta: &meta }), + ) + .expect("store written"); + HotApplyEnv { app_state, target_path, dir } +} + +fn entry(overrides: TargetFailoverOverridesDto, etag: &str) -> TargetOverridesEntry { + TargetOverridesEntry { + overrides, + file: std::path::PathBuf::from("failover.yml"), + etag: Arc::from(etag), + findings: Vec::new(), + } +} + +#[tokio::test] +async fn hot_apply_updates_store_meta_and_cache_generation() { + let applied = auto_section(auto_override(&[])); + let env = env_with_store(&applied, "etag-1"); + let mut next_override = auto_override(&[("a", "1"), ("c", "3")]); + next_override.policy = Some(FailoverGroupPolicy::Priority); + let next = entry(auto_section(next_override), "etag-2"); + + let generation = env.app_state.playlists.failover_group_generation(TARGET); + assert!(try_hot_apply(&env.app_state, TARGET, &next).await); + + let stored = FailoverGroupStore::read_group(&env.target_path, GROUP_VID).expect("read").expect("group"); + assert_eq!(stored.members.iter().map(|m| m.member_vid).collect::>(), vids(&[1, 3, 2])); + assert_eq!(stored.policy, FailoverGroupPolicy::Priority); + let meta = FailoverGroupStore::read_meta(&env.target_path).expect("meta"); + assert_eq!(meta.overrides_etag.as_deref(), Some("etag-2")); + assert_eq!(meta.applied_overrides.as_ref(), Some(&next.overrides)); + assert_eq!(env.app_state.playlists.failover_group_generation(TARGET), generation + 1); + + // The member index still resolves every member of the reordered group. + let (_, idx) = FailoverGroupStore::read_member(&env.target_path, VirtualId(3)).expect("read").expect("member"); + assert_eq!(idx, 1); + + // The next decision diffs against the section that was just applied. + let mut after_override = auto_override(&[("a", "1"), ("b", "2"), ("c", "3")]); + after_override.policy = Some(FailoverGroupPolicy::Priority); + let after = entry(auto_section(after_override), "etag-3"); + assert!(try_hot_apply(&env.app_state, TARGET, &after).await); + let stored = FailoverGroupStore::read_group(&env.target_path, GROUP_VID).expect("read").expect("group"); + assert_eq!(stored.members.iter().map(|m| m.member_vid).collect::>(), vids(&[1, 2, 3])); + let meta = FailoverGroupStore::read_meta(&env.target_path).expect("meta"); + assert_eq!(meta.overrides_etag.as_deref(), Some("etag-3")); +} + +#[tokio::test] +async fn structural_change_is_not_hot_applied() { + let applied = auto_section(auto_override(&[])); + let env = env_with_store(&applied, "etag-1"); + let mut removal = auto_override(&[]); + removal.remove = vec![member_ref("c", "3")]; + let next = entry(auto_section(removal), "etag-2"); + + let generation = env.app_state.playlists.failover_group_generation(TARGET); + assert!(!try_hot_apply(&env.app_state, TARGET, &next).await); + + let stored = FailoverGroupStore::read_group(&env.target_path, GROUP_VID).expect("read").expect("group"); + assert_eq!(stored.members.iter().map(|m| m.member_vid).collect::>(), vids(&[1, 2, 3])); + let meta = FailoverGroupStore::read_meta(&env.target_path).expect("meta"); + assert_eq!(meta.overrides_etag.as_deref(), Some("etag-1")); + assert_eq!(meta.applied_overrides.as_ref(), Some(&applied)); + assert_eq!(env.app_state.playlists.failover_group_generation(TARGET), generation); +} + +#[tokio::test] +async fn hot_apply_without_a_stored_applied_section_is_refused() { + let applied = auto_section(auto_override(&[])); + let env = env_with_store(&applied, "etag-1"); + let meta = FailoverGroupMeta::default(); + FailoverGroupStore::write_meta(&env.target_path, &meta).expect("meta reset"); + let next = entry(auto_section(auto_override(&[("a", "1"), ("c", "3")])), "etag-2"); + assert!(!try_hot_apply(&env.app_state, TARGET, &next).await); +} + +#[tokio::test] +async fn update_order_and_policy_rejects_incomplete_member_order() { + let applied = auto_section(auto_override(&[])); + let env = env_with_store(&applied, "etag-1"); + let bad = [change(&[VirtualId(1), VirtualId(2)], FailoverGroupPolicy::Auto)]; + assert!(FailoverGroupStore::update_order_and_policy(&env.target_path, &bad).is_err()); + let unknown = [change(&[VirtualId(1), VirtualId(2), VirtualId(9)], FailoverGroupPolicy::Auto)]; + assert!(FailoverGroupStore::update_order_and_policy(&env.target_path, &unknown).is_err()); + let stored = FailoverGroupStore::read_group(&env.target_path, GROUP_VID).expect("read").expect("group"); + assert_eq!(stored.members.iter().map(|m| m.member_vid).collect::>(), vids(&[1, 2, 3])); +} + +#[tokio::test] +async fn failover_order_change_hot_applies_on_reload() { + let applied = auto_section(auto_override(&[])); + let env = env_with_store(&applied, "etag-1"); + let config_dir = env.dir.path().join("conf"); + std::fs::create_dir_all(&config_dir).expect("config dir"); + let mut paths = (*env.app_state.app_config.paths.load_full()).clone(); + paths.config_path = config_dir.to_string_lossy().into_owned(); + env.app_state.app_config.paths.store(Arc::new(paths)); + + let failover_file = config_dir.join("failover.yml"); + let source = tuliprox_core::utils::FailoverSource::File(failover_file.clone()); + env.app_state + .app_config + .failover + .store(Arc::new(tuliprox_core::model::FailoverOverrides { source: Some(source), ..Default::default() })); + std::fs::write( + &failover_file, + format!( + "version: 1\ntargets:\n - target: {TARGET}\n auto_overrides:\n - auto_key: zdf\n order:\n - {{ input: a, stream_id: \"1\" }}\n - {{ input: c, stream_id: \"3\" }}\n" + ), + ) + .expect("failover file"); + let mut events = env.app_state.event_manager.get_event_channel(); + let generation = env.app_state.playlists.failover_group_generation(TARGET); + + crate::api::config_file::ConfigFile::Failover.reload(&failover_file, &env.app_state).await.expect("reload"); + + let stored = FailoverGroupStore::read_group(&env.target_path, GROUP_VID).expect("read").expect("group"); + assert_eq!(stored.members.iter().map(|m| m.member_vid).collect::>(), vids(&[1, 3, 2])); + let snapshot = env.app_state.app_config.failover.load_full(); + let reloaded = snapshot.sections.get(TARGET).expect("section loaded"); + let meta = FailoverGroupStore::read_meta(&env.target_path).expect("meta"); + assert_eq!(meta.overrides_etag.as_deref(), Some(reloaded.etag.as_ref())); + assert_eq!(env.app_state.playlists.failover_group_generation(TARGET), generation + 1); + match events.try_recv() { + Ok(crate::api::model::EventMessage::FailoverOverridesChanged { targets }) => { + assert_eq!(targets, vec![TARGET.to_string()]); + } + other => panic!("expected the overrides event, got {other:?}"), + } +} + +#[tokio::test] +async fn representative_change_is_hot_applied_and_listed_as_display_stale() { + let applied = auto_section(auto_override(&[])); + let env = env_with_store(&applied, "etag-1"); + let next = entry(auto_section(auto_override(&[("b", "2")])), "etag-2"); + + assert!(try_hot_apply(&env.app_state, TARGET, &next).await); + + let stored = FailoverGroupStore::read_group(&env.target_path, GROUP_VID).expect("read").expect("group"); + assert_eq!(stored.members.iter().map(|m| m.member_vid).collect::>(), vids(&[2, 1, 3])); + let meta = FailoverGroupStore::read_meta(&env.target_path).expect("meta"); + assert_eq!(meta.overrides_etag.as_deref(), Some("etag-2")); + assert_eq!(meta.applied_overrides.as_ref(), Some(&next.overrides)); + assert_eq!(meta.display_stale_groups, ["zdf"]); + + // A later order-only change keeps the marker until a playlist update publishes the representative. + let later = entry(auto_section(auto_override(&[("b", "2"), ("c", "3")])), "etag-3"); + assert!(try_hot_apply(&env.app_state, TARGET, &later).await); + let meta = FailoverGroupStore::read_meta(&env.target_path).expect("meta"); + assert_eq!(meta.display_stale_groups, ["zdf"]); +} + +#[tokio::test] +async fn pinned_metadata_representative_change_leaves_the_store_not_display_stale() { + let applied = auto_section(pinned(auto_override(&[]))); + let env = env_with_store(&applied, "etag-1"); + let next = entry(auto_section(pinned(auto_override(&[("b", "2")]))), "etag-2"); + + assert!(try_hot_apply(&env.app_state, TARGET, &next).await); + + let stored = FailoverGroupStore::read_group(&env.target_path, GROUP_VID).expect("read").expect("group"); + assert_eq!(stored.members.iter().map(|m| m.member_vid).collect::>(), vids(&[2, 1, 3])); + assert!(FailoverGroupStore::read_meta(&env.target_path).expect("meta").display_stale_groups.is_empty()); +} diff --git a/backend/app/src/api/api_utils/tests/channel_group_playback.rs b/backend/app/src/api/api_utils/tests/failover_group_playback.rs similarity index 96% rename from backend/app/src/api/api_utils/tests/channel_group_playback.rs rename to backend/app/src/api/api_utils/tests/failover_group_playback.rs index 1260b3c7c..da000f6f6 100644 --- a/backend/app/src/api/api_utils/tests/channel_group_playback.rs +++ b/backend/app/src/api/api_utils/tests/failover_group_playback.rs @@ -4,7 +4,7 @@ use super::{ }; use shared::model::AdmissionStrategy; use std::sync::Arc; -use tuliprox_session::channel_group::CircuitView; +use tuliprox_session::failover_group::CircuitView; #[tokio::test] async fn xtream_group_playback_uses_member_input() { @@ -152,14 +152,14 @@ async fn group_with_all_members_in_maintenance_returns_maintenance() { #[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() }, + let failover_groups = shared::model::FailoverGroupsConfig { + start: shared::model::FailoverGroupStartConfig { 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) + .with_failover_groups(failover_groups) .build() .await; let started = std::time::Instant::now(); diff --git a/backend/app/src/api/api_utils/tests/channel_group_probe.rs b/backend/app/src/api/api_utils/tests/failover_group_probe.rs similarity index 94% rename from backend/app/src/api/api_utils/tests/channel_group_probe.rs rename to backend/app/src/api/api_utils/tests/failover_group_probe.rs index e6419e646..b8555f39e 100644 --- a/backend/app/src/api/api_utils/tests/channel_group_probe.rs +++ b/backend/app/src/api/api_utils/tests/failover_group_probe.rs @@ -1,5 +1,5 @@ use super::{ - super::channel_group::{ + super::failover_group::{ probe_member_payload, wait_for_capacity, wait_for_user_capacity, ProbedPayload, PROBE_MIN_BYTES, }, group_playback_support::ts_fixture, @@ -144,8 +144,8 @@ async fn refresh_member_keys_keeps_only_group_member_probes() { 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); + health.patch_probe(&tuliprox_session::failover_group::member_key(&stranger), video, 4_000); + health.patch_probe(&tuliprox_session::failover_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()); @@ -160,7 +160,7 @@ async fn probe_rejects_short_text_body_with_sync_byte() { #[tokio::test] async fn prefixed_stream_passes_inner_after_head() { - use super::super::channel_group::PrefixedStream; + use super::super::failover_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(); @@ -182,12 +182,12 @@ async fn wait_for(timeout: Duration, mut condition: impl FnMut() -> bool) -> boo 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 key = tuliprox_session::failover_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); + let task = crate::api::tasks::exec_failover_group_member_keys(&env.app_state, &cancel); // The startup refresh registers the members. assert!( wait_for(Duration::from_secs(2), || { diff --git a/backend/app/src/api/api_utils/tests/channel_group_runtime.rs b/backend/app/src/api/api_utils/tests/failover_group_runtime.rs similarity index 95% rename from backend/app/src/api/api_utils/tests/channel_group_runtime.rs rename to backend/app/src/api/api_utils/tests/failover_group_runtime.rs index 9649f8a82..d7f525534 100644 --- a/backend/app/src/api/api_utils/tests/channel_group_runtime.rs +++ b/backend/app/src/api/api_utils/tests/failover_group_runtime.rs @@ -2,14 +2,14 @@ use super::{ group_playback_support::{has_pid, member_ts, read_body_until, GroupPlaybackEnv}, support::FakeOriginMode, }; -use shared::model::{ChannelGroupFailoverConfig, EventMessage, SwitchReason}; +use shared::model::{EventMessage, FailoverGroupRuntimeConfig, 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() } +fn runtime_selection(no_data_timeout_secs: u64, max_outage_secs: u64) -> FailoverGroupRuntimeConfig { + FailoverGroupRuntimeConfig { no_data_timeout_secs, max_outage_secs, ..FailoverGroupRuntimeConfig::default() } } async fn wait_until(timeout: Duration, condition: impl Fn() -> bool) -> bool { @@ -119,7 +119,7 @@ async fn switch_emits_member_switched_event() { 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, .. }) = + if let Ok(EventMessage::FailoverGroupMemberSwitched { from_member_vid, to_member_vid, reason, .. }) = events.recv().await { return (from_member_vid, to_member_vid, reason); @@ -193,10 +193,10 @@ async fn group_stream_sleep_timer_ends_without_switch() { .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 { + .with_failover(FailoverGroupRuntimeConfig { no_data_timeout_secs: 0, slow_source_pct: 0, - ..ChannelGroupFailoverConfig::default() + ..FailoverGroupRuntimeConfig::default() }) .sleep_timer_mins(1) .origin_mode("solis", FakeOriginMode::TsStream(member_ts(SOLIS_PID, 40))) @@ -209,7 +209,7 @@ async fn group_stream_sleep_timer_ends_without_switch() { 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), + matches!(env.circuit("solis"), tuliprox_session::failover_group::CircuitView::Closed), "the sleep timer is no member failure" ); } @@ -260,12 +260,12 @@ async fn stall_after_spent_budget_ends_stream() { #[tokio::test] async fn retry_same_member_on_single_connection_account() { - let selection = ChannelGroupFailoverConfig { + let selection = FailoverGroupRuntimeConfig { no_data_timeout_secs: 1, stable_after_secs: 1, same_source_retries: 1, max_outage_secs: 10, - ..ChannelGroupFailoverConfig::default() + ..FailoverGroupRuntimeConfig::default() }; let env = GroupPlaybackEnv::builder(&["solis", "korath"]) .with_qos("solis", 1, 95) @@ -283,7 +283,7 @@ async fn retry_same_member_on_single_connection_account() { #[tokio::test] async fn preempted_shared_group_member_does_not_fail_over() { - use super::super::channel_group_failover::{GroupFailoverContext, GroupFailoverDriver, GroupFailoverStream}; + use super::super::failover_group_runtime::{GroupFailoverContext, GroupFailoverDriver, GroupFailoverStream}; use futures::StreamExt; let env = GroupPlaybackEnv::builder(&["solis", "korath"]).build().await; @@ -293,7 +293,7 @@ async fn preempted_shared_group_member_does_not_fail_over() { 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), + playback: shared::model::FailoverGroupsConfig::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(), @@ -355,8 +355,8 @@ async fn group_session_provider_follows_runtime_switch() { 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) } +fn no_flap_guard(no_data_timeout_secs: u64, max_outage_secs: u64) -> FailoverGroupRuntimeConfig { + FailoverGroupRuntimeConfig { max_switches: 0, ..runtime_selection(no_data_timeout_secs, max_outage_secs) } } #[tokio::test] @@ -416,7 +416,7 @@ async fn give_up_still_records_member_failure() { 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)), + matches!(env.circuit("solis"), tuliprox_session::failover_group::CircuitView::Counting(1)), "the failure that ended the stream counts for the member" ); } @@ -488,7 +488,7 @@ async fn capacity_rejecting_member_is_opened_once_per_switch() { 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() }) + .with_failover(FailoverGroupRuntimeConfig { enabled: false, ..FailoverGroupRuntimeConfig::default() }) .origin_mode("solis", FakeOriginMode::FixedBytes(member_ts(SOLIS_PID, 10))) .origin_mode("korath", FakeOriginMode::TsStream(member_ts(KORATH_PID, 40))) .build() diff --git a/backend/app/src/api/api_utils/tests/channel_group_selection.rs b/backend/app/src/api/api_utils/tests/failover_group_selection.rs similarity index 91% rename from backend/app/src/api/api_utils/tests/channel_group_selection.rs rename to backend/app/src/api/api_utils/tests/failover_group_selection.rs index 1ec8cce99..226ec6fcc 100644 --- a/backend/app/src/api/api_utils/tests/channel_group_selection.rs +++ b/backend/app/src/api/api_utils/tests/failover_group_selection.rs @@ -1,5 +1,5 @@ use super::{ - super::channel_group::{ + super::failover_group::{ acquire_group_member, acquire_group_member_with_race, group_playback_settings, group_representative, member_stream_channel, GroupSelectionFailure, GroupSelectionRequest, }, @@ -9,7 +9,7 @@ use crate::api::model::AppState; use arc_swap::ArcSwap; use shared::{ model::{ - ChannelGroupPolicy, ChannelGroupsConfig, InputType, PlaylistItemType, ReverseProxyConfigDto, StreamConfigDto, + FailoverGroupPolicy, FailoverGroupsConfig, InputType, PlaylistItemType, ReverseProxyConfigDto, StreamConfigDto, VirtualId, XtreamCluster, }, utils::Internable, @@ -17,12 +17,12 @@ use shared::{ 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, + failover_group_repository::{FailoverGroupRecord, GroupMember}, + FailoverGroupOrigin, }; use tuliprox_session::{ active_provider_manager::{CapacityTier, ConnectionKind}, - channel_group::SelectionMode, + failover_group::SelectionMode, }; static CLIENT_ADDR: std::sync::LazyLock = @@ -78,8 +78,8 @@ fn app_config_with_sources_and_config(sources: SourcesConfig, mut config: Config struct SelectionEnv { app_state: Arc, - group: ChannelGroupRecord, - config: ChannelGroupsConfig, + group: FailoverGroupRecord, + config: FailoverGroupsConfig, target: Arc, } @@ -94,11 +94,11 @@ impl SelectionEnv { ..SourcesConfig::default() }; let app_state = create_test_app_state_for_config(Arc::new(app_config_with_sources(sources))); - let group = ChannelGroupRecord { + let group = FailoverGroupRecord { group_vid: VirtualId::new(1), key: "zdf".into(), - origin: ChannelGroupOrigin::Auto, - policy: ChannelGroupPolicy::Priority, + origin: FailoverGroupOrigin::Auto, + policy: FailoverGroupPolicy::Priority, orphaned: Vec::new(), members: members .iter() @@ -113,7 +113,7 @@ impl SelectionEnv { }) .collect(), }; - Self { app_state, group, config: ChannelGroupsConfig::default(), target: "Main".into() } + Self { app_state, group, config: FailoverGroupsConfig::default(), target: "Main".into() } } fn hold(&self, input: &str, priority: i8) -> ProviderHandle { @@ -247,11 +247,11 @@ async fn representative_of_group_without_resolvable_member_is_none() { #[test] fn member_stream_channel_carries_member_identity() { - let env_group = ChannelGroupRecord { + let env_group = FailoverGroupRecord { group_vid: VirtualId::new(7), key: "zdf".into(), - origin: ChannelGroupOrigin::Auto, - policy: ChannelGroupPolicy::Priority, + origin: FailoverGroupOrigin::Auto, + policy: FailoverGroupPolicy::Priority, orphaned: Vec::new(), members: vec![group_member("korath", "42", 42, 300)], }; @@ -269,11 +269,11 @@ fn member_stream_channel_carries_member_identity() { #[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 global = FailoverGroupsConfig { + start: shared::model::FailoverGroupStartConfig { timeout_secs: 7, max_attempts: 2 }, + ..FailoverGroupsConfig::default() }; - let config = Config { channel_groups: global.clone(), ..Config::default() }; + let config = Config { failover_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, @@ -283,7 +283,7 @@ async fn group_playback_settings_apply_target_overrides_to_global_config() { let dto = shared::model::ConfigTargetDto { options: Some(shared::model::ConfigTargetOptions { - channel_groups: Some(shared::model::ChannelGroupsTargetConfig { + failover_groups: Some(shared::model::FailoverGroupsTargetConfig { enabled: true, allow_provider_grace: Some(false), failover: Some(false), diff --git a/backend/app/src/api/api_utils/tests/channel_group_sticky.rs b/backend/app/src/api/api_utils/tests/failover_group_sticky.rs similarity index 100% rename from backend/app/src/api/api_utils/tests/channel_group_sticky.rs rename to backend/app/src/api/api_utils/tests/failover_group_sticky.rs 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 index a11284398..eceac4a7d 100644 --- a/backend/app/src/api/api_utils/tests/group_playback_support.rs +++ b/backend/app/src/api/api_utils/tests/group_playback_support.rs @@ -1,7 +1,7 @@ use super::support::FakeOriginMode; use crate::{ api::{ - api_utils::channel_group::{member_key, CircuitView}, + api_utils::failover_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, @@ -13,8 +13,9 @@ use crate::{ M3uTargetOutput, ProxyUserCredentials, SourcesConfig, StreamConfig, TargetOutput, XtreamTargetOutput, }, repository::{ - get_target_storage_path, persist_playlist_views_with_groups, ChannelGroupOrigin, ChannelGroupRecord, - ChannelGroupStore, PendingChannelGroup, PlaylistStorageState, TargetPlaylistPersistOptions, + get_target_storage_path, persist_playlist_views_with_groups, FailoverGroupBatch, FailoverGroupOrigin, + FailoverGroupRecord, FailoverGroupStore, PendingFailoverGroup, PlaylistStorageState, + TargetPlaylistPersistOptions, }, }; use arc_swap::ArcSwap; @@ -26,10 +27,10 @@ use axum::{ 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, + AdmissionStrategy, ClusterFlags, ConfigPaths, ConfigTargetOptions, FailoverGroupPolicy, FailoverGroupsConfig, + FailoverGroupsTargetConfig, HdHomeRunTargetOutputDto, InputType, LiveStreamProperties, M3uTargetOutputDto, + PlaylistItem, PlaylistItemHeader, PlaylistItemType, ProcessingOrder, ProxyUserStatus, StreamProperties, + TargetType, UUIDType, VirtualId, XtreamCluster, XtreamTargetOutputDto, }, utils::Internable, }; @@ -299,7 +300,7 @@ pub(crate) struct GroupPlaybackEnv { pub group_vid: u32, pub plain_vid: u32, pub favourite_vid: u32, - pub group_record: Arc, + pub group_record: Arc, pub origins: HashMap, pub router: axum::Router, pub hdhr_router: Option, @@ -326,7 +327,7 @@ impl GroupPlaybackEnv { epg: None, epg_channel_id: None, hdhomerun: false, - channel_groups: None, + failover_groups: None, hls_cache: false, share_ts: false, sleep_timer_mins: None, @@ -459,7 +460,7 @@ impl GroupPlaybackEnv { } self.app_state.member_health.record_classified_failure( &key, - tuliprox_session::channel_group::FailureClass::Stream, + tuliprox_session::failover_group::FailureClass::Stream, std::time::Instant::now(), ); } @@ -566,7 +567,7 @@ impl GroupPlaybackEnv { &self.target, Some(&self.app_state.playlists), TargetPlaylistPersistOptions::default(), - &mut [], + &mut FailoverGroupBatch::default(), ) .await .expect("republished without groups"); @@ -590,7 +591,7 @@ pub(crate) struct GroupPlaybackEnvBuilder { epg: Option, epg_channel_id: Option, hdhomerun: bool, - channel_groups: Option, + failover_groups: Option, hls_cache: bool, share_ts: bool, sleep_timer_mins: Option, @@ -627,16 +628,16 @@ impl GroupPlaybackEnvBuilder { self } - /// The `config.yml` channel group settings. - pub fn with_channel_groups(mut self, channel_groups: ChannelGroupsConfig) -> Self { - self.channel_groups = Some(channel_groups); + /// The `config.yml` failover group settings. + pub fn with_failover_groups(mut self, failover_groups: FailoverGroupsConfig) -> Self { + self.failover_groups = Some(failover_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 with_failover(self, failover: shared::model::FailoverGroupRuntimeConfig) -> Self { + let failover_groups = FailoverGroupsConfig { failover, ..self.failover_groups.clone().unwrap_or_default() }; + self.with_failover_groups(failover_groups) } pub fn origin_mode(mut self, input: &str, mode: FakeOriginMode) -> Self { @@ -779,7 +780,7 @@ impl GroupPlaybackEnvBuilder { 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(), + failover_groups: self.failover_groups.clone().unwrap_or_default(), reverse_proxy: Some(crate::model::ReverseProxyConfig { resource_rewrite_disabled: false, rewrite_secret: [0; 16], @@ -833,9 +834,9 @@ impl GroupPlaybackEnvBuilder { enabled: true, name: "test_target".to_string(), options: Some(ConfigTargetOptions { - channel_groups: Some(ChannelGroupsTargetConfig { + failover_groups: Some(FailoverGroupsTargetConfig { enabled: true, - ..ChannelGroupsTargetConfig::default() + ..FailoverGroupsTargetConfig::default() }), share_live_streams: shared::model::ConfigTargetShareLiveStreams { hls: self.hls_cache, @@ -878,6 +879,7 @@ impl GroupPlaybackEnvBuilder { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })); @@ -919,7 +921,7 @@ impl GroupPlaybackEnvBuilder { 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), + failover_group_uuid: Some(group_uuid), epg_channel_id: group_epg.clone(), additional_properties: group_additional_props, ..PlaylistItemHeader::default() @@ -1001,7 +1003,7 @@ impl GroupPlaybackEnvBuilder { let fav_channel = PlaylistItem { header: PlaylistItemHeader { uuid: fav_uuid, - channel_group_uuid: Some(group_uuid), + failover_group_uuid: Some(group_uuid), group: "Favourites".intern(), ..group_channel.header.clone() }, @@ -1014,15 +1016,15 @@ impl GroupPlaybackEnvBuilder { }); } - let mut pending = vec![PendingChannelGroup { + let mut pending = FailoverGroupBatch::with_groups(vec![PendingFailoverGroup { group_uuid, key: Arc::from("zdf hd"), - origin: ChannelGroupOrigin::Auto, - policy: ChannelGroupPolicy::Auto, + origin: FailoverGroupOrigin::Auto, + policy: FailoverGroupPolicy::Auto, members, orphaned: Vec::new(), epg_split: false, - }]; + }]); let app_cfg = Arc::new(app_cfg); let playlists = Arc::new(PlaylistStorageState::new()); @@ -1048,7 +1050,7 @@ impl GroupPlaybackEnvBuilder { 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"); + FailoverGroupStore::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)); @@ -1073,8 +1075,8 @@ impl GroupPlaybackEnvBuilder { 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 { + tuliprox_session::failover_group::QosKey::Provider { input: Arc::from(input.as_str()), provider_id }, + tuliprox_session::failover_group::QosPart { score_24h: score, score_7d: score, confidence: 100, diff --git a/backend/app/src/api/api_utils/tests/support.rs b/backend/app/src/api/api_utils/tests/support.rs index e630d4b10..15482bd8b 100644 --- a/backend/app/src/api/api_utils/tests/support.rs +++ b/backend/app/src/api/api_utils/tests/support.rs @@ -355,6 +355,7 @@ pub(in crate::api::api_utils::tests) fn create_test_app_config() -> AppConfig { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -362,6 +363,7 @@ pub(in crate::api::api_utils::tests) fn create_test_app_config() -> AppConfig { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), } } @@ -399,6 +401,7 @@ pub(in crate::api::api_utils::tests) fn create_test_provider_app_config() -> App mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -406,6 +409,7 @@ pub(in crate::api::api_utils::tests) fn create_test_provider_app_config() -> App access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), } } @@ -454,6 +458,7 @@ pub(in crate::api::api_utils::tests) fn create_test_dual_provider_app_config() - mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -461,6 +466,7 @@ pub(in crate::api::api_utils::tests) fn create_test_dual_provider_app_config() - access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), } } diff --git a/backend/app/src/api/config_file.rs b/backend/app/src/api/config_file.rs index 3a6245b32..076159581 100644 --- a/backend/app/src/api/config_file.rs +++ b/backend/app/src/api/config_file.rs @@ -1,6 +1,9 @@ use crate::{ - api::model::{update_app_state_config, update_app_state_sources, AppState, EventMessage}, - config_loader::{prepare_sources_batch, read_sources_file_with_revision}, + api::{ + api_utils::failover_group_hot_apply::try_hot_apply, + model::{update_app_state_config, update_app_state_sources, AppState, EventMessage}, + }, + config_loader::{prepare_sources_batch, read_sources_file_with_revision, resolve_failover_source}, model::{CompiledMappings, Config, ProcessTargets, SourcesConfig}, utils, utils::read_mappings_file_with_templates, @@ -14,6 +17,8 @@ use std::{ path::{Path, PathBuf}, sync::Arc, }; +use tuliprox_core::{model::FailoverOverrides, utils::FailoverSource}; +use tuliprox_repository::{load_failover_snapshot, reload_failover_snapshot}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ConfigFile { @@ -23,6 +28,7 @@ pub enum ConfigFile { Template, Sources, SourceFile, + Failover, } #[derive(Debug)] @@ -48,6 +54,35 @@ enum PreparedFollowUp { Sources(Box), } +/// A failover snapshot published ahead of the config update that moved the failover location. +struct PublishedFailover { + previous: Arc, + next: Arc, +} + +impl PublishedFailover { + fn publish(app_state: &Arc, next: FailoverOverrides) -> Self { + let next = Arc::new(next); + Self { previous: app_state.app_config.failover.swap(Arc::clone(&next)), next } + } + + fn restore(&self, app_state: &Arc) { app_state.app_config.failover.store(Arc::clone(&self.previous)); } +} + +/// Targets whose failover section was added, removed or edited (different etag) between two snapshots, sorted. +fn changed_override_targets(previous: &FailoverOverrides, next: &FailoverOverrides) -> Vec { + let mut changed: Vec<&str> = next + .sections + .iter() + .filter(|(target, entry)| previous.sections.get(*target).is_none_or(|old| old.etag != entry.etag)) + .map(|(target, _)| target.as_ref()) + .chain(previous.sections.keys().filter(|target| !next.sections.contains_key(*target)).map(AsRef::as_ref)) + .collect(); + // The two halves are disjoint (present in `next` vs. only in `previous`), so sorting is enough. + changed.sort_unstable(); + changed.into_iter().map(str::to_string).collect() +} + /// Refreshes CLI-forced target IDs against an incoming `SourcesConfig`. /// /// IDs are positional and can shift when source ordering changes. Targets that no @@ -267,6 +302,59 @@ impl ConfigFile { Ok(()) } + // ----------------------------------------------------------------- + // Failover override files + // ----------------------------------------------------------------- + + /// Reads the failover files of `source` under the failover mutation lock. The live snapshot stays untouched, + /// so a failing read keeps the last good state active. + async fn prepare_failover_reload( + app_state: &Arc, + source: &FailoverSource, + ) -> Result { + load_failover_snapshot(&app_state.app_config, source) + .await + .inspect_err(|err| error!("Failed to load failover overrides: {err}")) + } + + /// Hot-applies the targets whose section changed between two snapshots and tells listeners which targets + /// those are. Nothing is emitted when no section changed. + async fn announce_failover_change( + app_state: &Arc, + previous: &FailoverOverrides, + next: &FailoverOverrides, + ) { + let changed = changed_override_targets(previous, next); + if changed.is_empty() { + return; + } + for target in &changed { + // A removed section is structural and waits for the next playlist update. + if let Some(entry) = next.sections.get(target.as_str()) { + let applied = try_hot_apply(app_state, target, entry).await; + debug!("Failover overrides of target {target}: hot applied = {applied}"); + } + } + info!("Loaded failover overrides, changed targets: {}", changed.join(", ")); + app_state.event_manager.send_event(EventMessage::FailoverOverridesChanged { targets: changed }); + } + + /// Entry-point for failover file-watch events; the source is resolved from the live config and paths. + /// The files are read and the snapshot is swapped under the failover mutation lock, so a reload never + /// overwrites a concurrent Web UI save with older content. + pub(crate) async fn load_failover(app_state: &Arc) -> Result<(), TuliproxError> { + let source = { + let paths = app_state.app_config.paths.load(); + let config = app_state.app_config.config.load(); + resolve_failover_source(&paths, &config, true) + }; + let (previous, next) = reload_failover_snapshot(&app_state.app_config, &source) + .await + .inspect_err(|err| error!("Failed to load failover overrides: {err}"))?; + Self::announce_failover_change(app_state, &previous, &next).await; + Ok(()) + } + // ----------------------------------------------------------------- // Main config (config.yml) — full prepare-then-apply // ----------------------------------------------------------------- @@ -325,13 +413,27 @@ impl ConfigFile { PreparedFollowUp::Unchanged }; + // A changed failover location is read before anything is applied, so an unreadable one rejects the reload. + let next_failover_source = resolve_failover_source(&effective_paths, &config, true); + let prepared_failover = if app_state.app_config.failover.load().source.as_ref() == Some(&next_failover_source) { + None + } else { + Some(Self::prepare_failover_reload(app_state, &next_failover_source).await?) + }; + let previous_config: Config = (*app_state.app_config.config.load_full()).clone(); let previous_sources: SourcesConfig = (*app_state.app_config.sources.load_full()).clone(); let previous_forced_targets = app_state.playlist_updates.forced_targets.load_full(); + // The config watcher restarted by the config update reads the failover location from the snapshot, so a + // changed location is published first. A failed reload puts the previous snapshot back. + let failover_swap = prepared_failover.map(|next| PublishedFailover::publish(app_state, next)); + let restore_failover = || failover_swap.iter().for_each(|swap| swap.restore(app_state)); + // Update live state only after every dependent value has been prepared. if let Err(err) = update_app_state_config(app_state, config).await { error!("Failed to apply config reload: {err}. Attempting config rollback."); + restore_failover(); if let Err(rollback_err) = update_app_state_config(app_state, previous_config).await { error!("Failed to rollback config after reload failure: {rollback_err}"); error!( @@ -352,6 +454,7 @@ impl ConfigFile { if let Err(err) = follow_up_result { error!("Failed to apply dependent reload after config update: {err}. Attempting rollback."); + restore_failover(); if let Err(rollback_err) = update_app_state_config(app_state, previous_config).await { error!("Failed to rollback config after dependent reload failure: {rollback_err}"); @@ -372,6 +475,10 @@ impl ConfigFile { return Err(err); } + if let Some(swap) = &failover_swap { + Self::announce_failover_change(app_state, &swap.previous, &swap.next).await; + } + info!("Loaded config file {config_file}"); Ok(()) } @@ -415,6 +522,7 @@ impl ConfigFile { ConfigFile::reload_source_file(app_state).await?; app_state.event_manager.send_event(EventMessage::ConfigChange(ConfigType::Sources)); } + ConfigFile::Failover => ConfigFile::load_failover(app_state).await?, } Ok(()) } @@ -422,10 +530,208 @@ impl ConfigFile { #[cfg(test)] mod tests { - use super::refresh_forced_targets; - use crate::model::{ConfigSource, ConfigTarget, ProcessTargets, SourcesConfig}; + use super::{changed_override_targets, refresh_forced_targets, ConfigFile}; + use crate::{ + api::model::{create_test_app_state, AppState, EventMessage}, + model::{Config, ConfigSource, ConfigTarget, ProcessTargets, SourcesConfig}, + }; use shared::model::ProcessingOrder; - use std::sync::Arc; + use std::{path::Path, sync::Arc}; + use tuliprox_core::{ + model::{FailoverOverrides, TargetOverridesEntry}, + utils::FailoverSource, + }; + + fn section_yaml(target: &str, stream_ids: &[&str]) -> String { + let excluded = stream_ids + .iter() + .map(|id| format!(" - {{ input: a, stream_id: \"{id}\" }}\n")) + .collect::>() + .concat(); + format!("version: 1\ntargets:\n - target: {target}\n excluded:\n{excluded}") + } + + fn app_state_in(config_dir: &Path) -> Arc { + let app_state = create_test_app_state(Config::default()); + let mut paths = (*app_state.app_config.paths.load_full()).clone(); + paths.config_path = config_dir.to_string_lossy().into_owned(); + paths.config_file_path = config_dir.join("config.yml").to_string_lossy().into_owned(); + app_state.app_config.paths.store(Arc::new(paths)); + app_state + } + + /// Mirrors the start-up load so the snapshot carries the source a reload compares against. + fn load_initial_failover(app_state: &Arc) { + let paths = app_state.app_config.paths.load(); + let config = app_state.app_config.config.load(); + let source = crate::config_loader::resolve_failover_source(&paths, &config, true); + let overrides = tuliprox_repository::load_failover_overrides(&source).expect("initial failover load"); + app_state.app_config.failover.store(Arc::new(overrides)); + } + + fn drain_overrides_events(events: &mut tokio::sync::broadcast::Receiver) -> Vec> { + let mut changed = Vec::new(); + while let Ok(event) = events.try_recv() { + if let EventMessage::FailoverOverridesChanged { targets } = event { + changed.push(targets); + } + } + changed + } + + fn entry(etag: &str) -> Arc { + Arc::new(TargetOverridesEntry { + overrides: shared::model::TargetFailoverOverridesDto::new("t"), + file: std::path::PathBuf::from("failover.yml"), + etag: Arc::from(etag), + findings: Vec::new(), + }) + } + + fn snapshot(sections: &[(&str, &str)]) -> FailoverOverrides { + FailoverOverrides { + sections: sections.iter().map(|(target, etag)| (Arc::from(*target), entry(etag))).collect(), + ..FailoverOverrides::default() + } + } + + #[test] + fn changed_targets_are_edited_added_and_removed_sections_sorted() { + let previous = snapshot(&[("b", "1"), ("same", "9"), ("gone", "1"), ("a", "1")]); + let next = snapshot(&[("b", "2"), ("same", "9"), ("new", "1"), ("a", "2")]); + + assert_eq!(changed_override_targets(&previous, &next), vec!["a", "b", "gone", "new"]); + assert!(changed_override_targets(&next, &next).is_empty()); + } + + #[tokio::test] + async fn failover_change_swaps_snapshot_and_emits_event() -> Result<(), Box> { + let dir = tempfile::tempdir()?; + let file = dir.path().join("failover.yml"); + std::fs::write(&file, section_yaml("Main", &["1"]))?; + let app_state = app_state_in(dir.path()); + load_initial_failover(&app_state); + let old_etag = Arc::clone(&app_state.app_config.failover.load().sections["Main"].etag); + let mut events = app_state.event_manager.get_event_channel(); + + std::fs::write(&file, section_yaml("Main", &["1", "2"]))?; + ConfigFile::Failover.reload(&file, &app_state).await?; + + let snapshot = app_state.app_config.failover.load_full(); + assert_ne!(snapshot.sections["Main"].etag, old_etag); + assert_eq!(snapshot.source, Some(FailoverSource::File(file.clone()))); + assert_eq!(drain_overrides_events(&mut events), vec![vec!["Main".to_string()]]); + + // Same content again: swapped, but no section changed, so nothing is announced. + ConfigFile::Failover.reload(&file, &app_state).await?; + assert!(drain_overrides_events(&mut events).is_empty()); + + // A section added in a second target and the first one removed are both reported, sorted. + std::fs::write(&file, section_yaml("Other", &["1"]))?; + ConfigFile::Failover.reload(&file, &app_state).await?; + assert_eq!(drain_overrides_events(&mut events), vec![vec!["Main".to_string(), "Other".to_string()]]); + Ok(()) + } + + #[tokio::test] + async fn invalid_failover_change_keeps_previous_snapshot() -> Result<(), Box> { + let dir = tempfile::tempdir()?; + let file = dir.path().join("failover.yml"); + std::fs::write(&file, section_yaml("Main", &["1"]))?; + let app_state = app_state_in(dir.path()); + load_initial_failover(&app_state); + let before = app_state.app_config.failover.load_full(); + let mut events = app_state.event_manager.get_event_channel(); + + std::fs::write(&file, "version: 9\ntargets: []\n")?; + let result = ConfigFile::Failover.reload(&file, &app_state).await; + + assert!(result.is_err(), "an unsupported version must fail the reload"); + let after = app_state.app_config.failover.load_full(); + assert!(Arc::ptr_eq(&before, &after), "the previous snapshot must stay active"); + assert!(after.error.is_none()); + assert!(after.sections.contains_key("Main")); + assert!(drain_overrides_events(&mut events).is_empty()); + Ok(()) + } + + const MINIMAL_CONFIG: &str = "api:\n host: localhost\n port: 8901\n web_root: ./web\n"; + + #[tokio::test] + async fn config_failover_path_change_reloads_failover() -> Result<(), Box> { + let dir = tempfile::tempdir()?; + std::fs::write(dir.path().join("failover.yml"), section_yaml("Main", &["1"]))?; + std::fs::write(dir.path().join("other.yml"), section_yaml("Other", &["1"]))?; + let storage = dir.path().join("storage"); + let config_yml = dir.path().join("config.yml"); + let config_text = |extra: &str| format!("{MINIMAL_CONFIG}storage_dir: {}\n{extra}", storage.display()); + std::fs::write(&config_yml, config_text(""))?; + let app_state = app_state_in(dir.path()); + load_initial_failover(&app_state); + assert!(app_state.app_config.failover.load().sections.contains_key("Main")); + let mut events = app_state.event_manager.get_event_channel(); + + // Unchanged location: the failover snapshot is not touched. + let before = app_state.app_config.failover.load_full(); + ConfigFile::Config.reload(&config_yml, &app_state).await?; + assert!(Arc::ptr_eq(&before, &app_state.app_config.failover.load_full())); + + std::fs::write(&config_yml, config_text("failover_path: other.yml\n"))?; + ConfigFile::Config.reload(&config_yml, &app_state).await?; + + let snapshot = app_state.app_config.failover.load_full(); + assert_eq!(snapshot.source, Some(FailoverSource::File(dir.path().join("other.yml")))); + assert!(snapshot.sections.contains_key("Other") && !snapshot.sections.contains_key("Main")); + assert_eq!(drain_overrides_events(&mut events), vec![vec!["Main".to_string(), "Other".to_string()]]); + + // An unreadable new location rejects the whole reload and keeps config and snapshot. + std::fs::write(dir.path().join("broken.yml"), "version: 9\ntargets: []\n")?; + std::fs::write(&config_yml, config_text("failover_path: broken.yml\n"))?; + assert!(ConfigFile::Config.reload(&config_yml, &app_state).await.is_err()); + assert_eq!( + app_state.app_config.failover.load().source, + Some(FailoverSource::File(dir.path().join("other.yml"))) + ); + assert_eq!(app_state.app_config.config.load().failover_path.as_deref(), Some("other.yml")); + Ok(()) + } + + #[tokio::test] + async fn failover_path_change_watches_new_source() -> Result<(), Box> { + let dir = tempfile::tempdir()?; + std::fs::write(dir.path().join("failover.yml"), section_yaml("Main", &["1"]))?; + let other = dir.path().join("other.yml"); + std::fs::write(&other, section_yaml("Other", &["1"]))?; + let storage = dir.path().join("storage"); + let config_yml = dir.path().join("config.yml"); + let config_text = |extra: &str| { + format!("{MINIMAL_CONFIG}storage_dir: {}\nconfig_hot_reload: true\n{extra}", storage.display()) + }; + std::fs::write(&config_yml, config_text(""))?; + let app_state = app_state_in(dir.path()); + load_initial_failover(&app_state); + + // The config reload restarts the file watcher; it must already see the new failover location. + std::fs::write(&config_yml, config_text("failover_path: other.yml\n"))?; + ConfigFile::Config.reload(&config_yml, &app_state).await?; + let mut events = app_state.event_manager.get_event_channel(); + drain_overrides_events(&mut events); + + std::fs::write(&other, section_yaml("Other", &["1", "2"]))?; + let changed = tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + match events.recv().await { + Ok(EventMessage::FailoverOverridesChanged { targets }) => break Some(targets), + Ok(_) => {} + Err(_) => break None, + } + } + }) + .await; + app_state.cancel_tokens.load().file_watch.cancel(); + assert_eq!(changed?, Some(vec!["Other".to_string()])); + Ok(()) + } fn target(id: u16, name: &str) -> Arc { Arc::new(ConfigTarget { diff --git a/backend/app/src/api/endpoints/api_playlist_utils.rs b/backend/app/src/api/endpoints/api_playlist_utils.rs index c1e3d7edc..c8c3501b1 100644 --- a/backend/app/src/api/endpoints/api_playlist_utils.rs +++ b/backend/app/src/api/endpoints/api_playlist_utils.rs @@ -314,6 +314,7 @@ mod tests { category_id: 0, rating: 0.0, input_name: "test".intern(), + input_stream_id: "".intern(), epg_channel_id: None, } } 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 deb0d415e..e59fd9bfe 100644 --- a/backend/app/src/api/endpoints/custom_video_stream_api.rs +++ b/backend/app/src/api/endpoints/custom_video_stream_api.rs @@ -671,6 +671,7 @@ mod tests { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -678,6 +679,7 @@ mod tests { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), }; app_cfg.custom_stream_response.store(Some(Arc::new(test_custom_stream_response()))); @@ -711,7 +713,7 @@ mod tests { qos_aggregation: CancellationToken::new(), recordings: CancellationToken::new(), hls_cache: CancellationToken::new(), - channel_groups: tokio_util::sync::CancellationToken::new(), + failover_groups: tokio_util::sync::CancellationToken::new(), }; let metadata_manager = Arc::new(MetadataUpdateManager::new(tokens.metadata.clone())); diff --git a/backend/app/src/api/endpoints/failover_api.rs b/backend/app/src/api/endpoints/failover_api.rs new file mode 100644 index 000000000..5377b700c --- /dev/null +++ b/backend/app/src/api/endpoints/failover_api.rs @@ -0,0 +1,506 @@ +use crate::api::{ + api_utils::failover_group_hot_apply::enabled_failover_target, auth_middleware::permission_layer, model::AppState, +}; +use axum::{ + extract::{Query, State}, + http::{header, HeaderValue, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use serde::Serialize; +use shared::model::{ + permission::Permission, CandidateDto, CandidatePageDto, ChannelMemberRefDto, FailoverCandidateFilter, + FailoverGroupDto, FailoverGroupHealthDto, FailoverGroupListDto, FailoverGroupMemberDto, FailoverGroupOrigin, + FailoverGroupTargetStatusDto, FailoverGroupTargetsDto, FailoverGroupsConfig, FailoverTargetState, GroupCapacityDto, + MemberHealthDto, OverrideValidationError, TargetFailoverOverridesDto, TargetOverridesResponseDto, VirtualId, +}; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, + time::Instant, +}; +use tuliprox_core::model::{ConfigTarget, TargetOverridesEntry}; +use tuliprox_processing::processor::failover_group::{ + derive_group_metadata, resolve_failover_groups, CatalogEntry, FailoverGroupResolution, MemberMetadata, + ResolvedCandidate, +}; +use tuliprox_repository::{run_blocking, CatalogRecord, FailoverGroupRecord, FailoverGroupTargetData}; +use tuliprox_session::failover_group::{rank_members, CircuitView, SelectionMode}; + +const DEFAULT_PAGE_LIMIT: usize = 100; +const MAX_PAGE_LIMIT: usize = 500; +/// A route failure that is already the HTTP answer. +type ApiResult = Result; + +/// Boxed so the `Err` side of every route result stays small. +struct ApiError(Box); + +const NO_CATALOG_MESSAGE: &str = "run a playlist update first"; + +pub fn failover_api_register_with_permissions(app_state: &Arc) -> Router> { + let read_routes = read_routes().layer(permission_layer!(app_state, Permission::PlaylistRead)); + let write_routes = write::write_routes().layer(permission_layer!(app_state, Permission::PlaylistWrite)); + Router::new().merge(read_routes).merge(write_routes) +} + +pub fn failover_api_register_unprotected() -> Router> { + Router::new().merge(read_routes()).merge(write::write_routes()) +} + +fn read_routes() -> Router> { + Router::new() + .route("/failover/targets", get(get_targets)) + .route("/failover/groups", get(get_groups)) + .route("/failover/overrides", get(get_overrides)) + .route("/failover/preview", post(post_preview)) + .route("/failover/candidates", get(get_candidates)) + .route("/failover/health", get(get_health)) +} + +#[derive(serde::Deserialize)] +struct TargetQuery { + target: String, +} + +#[derive(serde::Deserialize)] +struct CandidateQuery { + target: String, + q: Option, + #[serde(default)] + filter: FailoverCandidateFilter, + offset: Option, + limit: Option, +} + +#[derive(serde::Deserialize)] +struct HealthQuery { + target: String, + /// Virtual id of the group. + group: u32, +} + +fn error_response(status: StatusCode, message: impl std::fmt::Display) -> ApiError { + ApiError(Box::new((status, Json(serde_json::json!({ "error": message.to_string() }))).into_response())) +} + +/// An error answer with a structured body instead of the plain `{"error": ...}` message. +fn json_error(status: StatusCode, body: &impl Serialize) -> ApiError { + ApiError(Box::new((status, Json(body)).into_response())) +} + +fn respond(result: ApiResult) -> Response { respond_with(StatusCode::OK, result) } + +fn respond_with(status: StatusCode, result: ApiResult) -> Response { + let mut response = match result { + Ok(value) => (status, Json(value)).into_response(), + Err(ApiError(response)) => *response, + }; + response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + response +} + +/// `(offset, limit)` of a page; the limit defaults to 100 and never exceeds 500. +fn page_window(offset: Option, limit: Option) -> (usize, usize) { + (offset.unwrap_or(0), limit.unwrap_or(DEFAULT_PAGE_LIMIT).min(MAX_PAGE_LIMIT)) +} + +fn enabled_target(state: &AppState, name: &str) -> ApiResult<(Arc, FailoverGroupsConfig)> { + enabled_failover_target(state, name).ok_or_else(|| { + error_response(StatusCode::NOT_FOUND, format!("Target '{name}' not found or failover groups are disabled")) + }) +} + +async fn load_stored(state: &AppState, target: &ConfigTarget) -> Arc { + state.playlists.failover_groups.get_or_load(&state.app_config, &state.playlists, target).await +} + +/// The cached catalog; an empty one means no playlist update has published it yet. +async fn load_catalog(state: &AppState, target: &ConfigTarget) -> ApiResult> { + match state.playlists.failover_groups.get_or_load_catalog(&state.app_config, &state.playlists, target).await { + Ok(catalog) if catalog.is_empty() => Err(error_response(StatusCode::CONFLICT, NO_CATALOG_MESSAGE)), + Ok(catalog) => Ok(catalog), + Err(err) => Err(error_response(StatusCode::INTERNAL_SERVER_ERROR, err)), + } +} + +/// Runs CPU or file bound work off the async runtime. +async fn run_off_runtime(work: impl FnOnce() -> T + Send + 'static) -> ApiResult { + run_blocking(work).await.map_err(|err| error_response(StatusCode::INTERNAL_SERVER_ERROR, err)) +} + +fn path_string(path: &std::path::Path) -> String { path.to_string_lossy().into_owned() } + +fn section_status( + name: &str, + section: Option<&TargetOverridesEntry>, + stored: Option<&FailoverGroupTargetData>, + state: FailoverTargetState, +) -> FailoverGroupTargetStatusDto { + let findings: Vec = section.map(|entry| entry.findings.clone()).unwrap_or_default(); + let unresolved_groups = stored.map_or(0, |data| data.groups.values().filter(|g| !g.orphaned.is_empty()).count()); + FailoverGroupTargetStatusDto { + name: name.to_string(), + etag: section.map(|entry| entry.etag.to_string()), + applied_etag: stored.and_then(|data| data.meta.overrides_etag.as_deref()).map(str::to_string), + file: section.map(|entry| path_string(&entry.file)), + group_count: stored.map_or(0, |data| data.groups.len()), + problem_count: findings.len() + unresolved_groups + usize::from(state != FailoverTargetState::Enabled), + findings, + state, + } +} + +async fn get_targets(State(state): State>) -> Response { + let overrides = state.app_config.failover.load_full(); + let sources = state.app_config.sources.load(); + let configured: Vec<&Arc> = sources.sources.iter().flat_map(|source| &source.targets).collect(); + let enabled: Vec<&Arc> = + configured.iter().copied().filter(|target| target.execution_plan.failover_groups.is_some()).collect(); + + let mut targets = Vec::with_capacity(enabled.len() + overrides.sections.len()); + for target in enabled { + let stored = load_stored(&state, target).await; + let section = overrides.sections.get(target.name.as_str()); + targets.push(section_status( + &target.name, + section.map(AsRef::as_ref), + Some(&stored), + FailoverTargetState::Enabled, + )); + } + // Sections nothing can process: the target is configured without failover groups, or does not exist. + let mut unprocessed: Vec<(&Arc, &Arc, FailoverTargetState)> = overrides + .sections + .iter() + .filter_map(|(name, entry)| { + let state = match configured.iter().find(|target| target.name == **name) { + Some(target) if target.execution_plan.failover_groups.is_some() => return None, + Some(_) => FailoverTargetState::Disabled, + None => FailoverTargetState::Orphan, + }; + Some((name, entry, state)) + }) + .collect(); + unprocessed.sort_by(|left, right| left.0.cmp(right.0)); + targets.extend(unprocessed.into_iter().map(|(name, entry, state)| section_status(name, Some(entry), None, state))); + + respond(Ok(FailoverGroupTargetsDto { + source: overrides.source.as_ref().map(|source| path_string(source.path())), + files_used: overrides.files_used.iter().map(|path| path_string(path)).collect(), + load_error: overrides.error.clone(), + targets, + })) +} + +async fn get_overrides(State(state): State>, Query(query): Query) -> Response { + respond(overrides_response(&state, &query.target)) +} + +fn overrides_response(state: &AppState, target_name: &str) -> ApiResult { + let (target, _) = enabled_target(state, target_name)?; + Ok(match state.app_config.failover_section(&target.name) { + Some(entry) => TargetOverridesResponseDto { + etag: Some(entry.etag.to_string()), + file: Some(path_string(&entry.file)), + overrides: entry.overrides.clone(), + }, + None => TargetOverridesResponseDto { + etag: None, + file: None, + overrides: TargetFailoverOverridesDto::new(&target.name), + }, + }) +} + +/// Persisted groups by `(manual, key)`: manual ids and auto keys are separate namespaces. +fn stored_by_key(stored: &FailoverGroupTargetData) -> HashMap<(bool, &str), &FailoverGroupRecord> { + stored + .groups + .values() + .map(|record| ((record.origin == FailoverGroupOrigin::Manual, record.key.as_ref()), record.as_ref())) + .collect() +} + +fn group_dto( + config: &FailoverGroupsConfig, + candidate: ResolvedCandidate, + catalog: &[CatalogRecord], + stored: &HashMap<(bool, &str), &FailoverGroupRecord>, +) -> FailoverGroupDto { + let members: Vec<&CatalogRecord> = candidate.entries.iter().filter_map(|&entry| catalog.get(entry)).collect(); + let metadata = derive_group_metadata( + &candidate, + members.iter().map(|member| MemberMetadata { + name: &member.name, + title: &member.title, + logo: &member.logo, + epg_channel_id: member.epg_channel_id.as_deref(), + playlist_group: &member.playlist_group, + }), + config.name_strip_quality, + ); + let ResolvedCandidate { key, origin, policy, unresolved, .. } = candidate; + let origin = FailoverGroupOrigin::from(origin); + let record = stored.get(&(origin == FailoverGroupOrigin::Manual, key.as_str())).copied(); + + let members = members + .into_iter() + .map(|member| FailoverGroupMemberDto { + member_vid: record.and_then(|record| record.member_vid(&member.member_ref)), + member_ref: member.member_ref.clone(), + title: member.title.to_string(), + input_name: member.member_ref.input.clone(), + provider_id: member.provider_id, + has_archive: member.has_archive, + }) + .collect(); + + FailoverGroupDto { + group_vid: record.map(|record| record.group_vid), + key, + origin, + policy, + name: metadata.name, + logo: Some(metadata.logo).filter(|logo| !logo.is_empty()), + epg_channel_id: metadata.epg_channel_id, + playlist_group: metadata.playlist_group, + members, + unresolved, + } +} + +/// Resolves the catalog with `overrides` exactly as processing does and maps the result to DTOs. +fn resolve_group_list( + config: &FailoverGroupsConfig, + overrides: Option<&TargetFailoverOverridesDto>, + catalog: &[CatalogRecord], + stored: &FailoverGroupTargetData, +) -> (Vec, shared::model::FailoverOverrideDiagnosticsDto) { + let entries: Vec> = catalog + .iter() + .map(|record| CatalogEntry { + member_ref: &record.member_ref, + natural_key: record.natural_key.as_deref(), + dsl_forced: record.dsl_forced, + epg_channel_id: record.epg_channel_id.as_deref(), + }) + .collect(); + let FailoverGroupResolution { candidates, diagnostics } = resolve_failover_groups(config, overrides, &entries); + let stored = stored_by_key(stored); + let groups = candidates.into_iter().map(|candidate| group_dto(config, candidate, catalog, &stored)).collect(); + (groups, diagnostics) +} + +async fn get_groups(State(state): State>, Query(query): Query) -> Response { + respond(groups_response(&state, &query.target).await) +} + +/// The persisted state: the catalog resolved with the section the last playlist update applied. +async fn groups_response(state: &AppState, target_name: &str) -> ApiResult { + let (target, config) = enabled_target(state, target_name)?; + let catalog = load_catalog(state, &target).await?; + let stored = load_stored(state, &target).await; + let etag = state.app_config.failover_section(&target.name).map(|entry| entry.etag.to_string()); + let applied_etag = stored.meta.overrides_etag.as_deref().map(str::to_string); + let display_stale = stored.meta.display_stale_groups.clone(); + run_off_runtime(move || { + let (groups, diagnostics) = + resolve_group_list(&config, stored.meta.applied_overrides.as_ref(), &catalog, &stored); + FailoverGroupListDto { etag, applied_etag, groups, diagnostics, display_stale } + }) + .await +} + +async fn post_preview( + State(state): State>, + Query(query): Query, + Json(draft): Json, +) -> Response { + respond(preview_response(&state, &query.target, draft).await) +} + +/// The groups a draft section would produce; nothing is written. +async fn preview_response( + state: &AppState, + target_name: &str, + mut draft: TargetFailoverOverridesDto, +) -> ApiResult { + let (target, config) = enabled_target(state, target_name)?; + let catalog = load_catalog(state, &target).await?; + let stored = load_stored(state, &target).await; + draft.target.clone_from(&target.name); + draft.canonicalize(); + let etag = state.app_config.failover_section(&target.name).map(|entry| entry.etag.to_string()); + let applied_etag = stored.meta.overrides_etag.as_deref().map(str::to_string); + let display_stale = stored.meta.display_stale_groups.clone(); + run_off_runtime(move || { + let (groups, diagnostics) = resolve_group_list(&config, Some(&draft), &catalog, &stored); + FailoverGroupListDto { etag, applied_etag, groups, diagnostics, display_stale } + }) + .await +} + +/// Case-insensitive substring test; `needle_lower` is already lowercase. +fn contains_ignore_case(haystack: &str, needle_lower: &str) -> bool { + if needle_lower.is_empty() { + return true; + } + if haystack.is_ascii() && needle_lower.is_ascii() { + let needle = needle_lower.as_bytes(); + return haystack.as_bytes().windows(needle.len()).any(|window| window.eq_ignore_ascii_case(needle)); + } + haystack.to_lowercase().contains(needle_lower) +} + +fn search_catalog( + catalog: &[CatalogRecord], + stored: &FailoverGroupTargetData, + excluded: &[ChannelMemberRefDto], + filter: FailoverCandidateFilter, + query: &str, + (offset, limit): (usize, usize), +) -> CandidatePageDto { + let needle = query.trim().to_lowercase(); + let excluded: HashSet<&ChannelMemberRefDto> = excluded.iter().collect(); + let group_of: HashMap<(&str, &str), &str> = stored + .groups + .values() + .flat_map(|group| { + group + .members + .iter() + .map(|member| ((member.input_name.as_ref(), member.input_stream_id.as_ref()), group.key.as_ref())) + }) + .collect(); + + let mut total = 0; + let mut items = Vec::with_capacity(limit.min(catalog.len())); + for record in catalog { + let group_key = + group_of.get(&(record.member_ref.input.as_str(), record.member_ref.stream_id.as_str())).copied(); + let is_excluded = excluded.contains(&record.member_ref); + let selected = match filter { + FailoverCandidateFilter::All => true, + FailoverCandidateFilter::Ungrouped => group_key.is_none(), + FailoverCandidateFilter::Grouped => group_key.is_some(), + FailoverCandidateFilter::Excluded => is_excluded, + }; + if !selected || !contains_ignore_case(&record.title, &needle) { + continue; + } + if total >= offset && items.len() < limit { + items.push(CandidateDto { + member_ref: record.member_ref.clone(), + title: record.title.to_string(), + logo: record.logo.to_string(), + playlist_group: record.playlist_group.to_string(), + provider_id: record.provider_id, + has_archive: record.has_archive, + group_key: group_key.map(str::to_string), + excluded: is_excluded, + }); + } + total += 1; + } + CandidatePageDto { total, items } +} + +async fn get_candidates(State(state): State>, Query(query): Query) -> Response { + respond(candidates_response(&state, query).await) +} + +/// Channels of the catalog with their persisted group; `excluded` reflects the current failover files. +async fn candidates_response(state: &AppState, query: CandidateQuery) -> ApiResult { + let (target, _) = enabled_target(state, &query.target)?; + let catalog = load_catalog(state, &target).await?; + let stored = load_stored(state, &target).await; + let section = state.app_config.failover_section(&target.name); + let window = page_window(query.offset, query.limit); + run_off_runtime(move || { + let excluded = section.as_ref().map_or(&[][..], |entry| entry.overrides.excluded.as_slice()); + search_catalog(&catalog, &stored, excluded, query.filter, query.q.as_deref().unwrap_or_default(), window) + }) + .await +} + +fn circuit_label(view: CircuitView) -> (&'static str, Option) { + match view { + CircuitView::Closed => ("closed", None), + CircuitView::Counting(_) => ("counting", None), + CircuitView::Open { remaining } => ("open", Some(remaining.as_secs())), + CircuitView::HalfOpenInFlight => ("half_open", None), + } +} + +async fn get_health(State(state): State>, Query(query): Query) -> Response { + respond(health_response(&state, &query).await) +} + +/// Member health of one group from the in-memory indexes; no provider is contacted. +async fn health_response(state: &AppState, query: &HealthQuery) -> ApiResult { + let (target, _) = enabled_target(state, &query.target)?; + let stored = load_stored(state, &target).await; + let Some(group) = stored.groups.get(&VirtualId::new(query.group)) else { + return Err(error_response(StatusCode::NOT_FOUND, format!("Failover group {} not found", query.group))); + }; + + let target_name: Arc = Arc::from(target.name.as_str()); + let now = Instant::now(); + let health = &state.member_health; + let ranked = rank_members(&target_name, &group.members, group.policy, health, &SelectionMode::Live, now); + + let mut accounts: HashMap, (usize, usize)> = HashMap::new(); + let mut members = Vec::with_capacity(ranked.len()); + for (position, candidate) in ranked.into_iter().enumerate() { + let Some(member) = group.members.get(candidate.index) else { + continue; + }; + let member_health = health.health(&target_name, member, now); + let (circuit, circuit_remaining_secs) = circuit_label(member_health.circuit); + let (input_circuit, _) = circuit_label(health.input_circuit_view(&member.input_name, now)); + + let (mut slots_used, mut slots_max) = (0, 0); + for (account, used, max) in state.active_provider.provider_capacities_for_input(&member.input_name) { + slots_used += used; + slots_max += max; + accounts.insert(account, (used, max)); + } + + members.push(MemberHealthDto { + member_vid: member.member_vid, + member_ref: ChannelMemberRefDto { + input: member.input_name.to_string(), + stream_id: member.input_stream_id.to_string(), + }, + title: member.title.to_string(), + rank: position + 1, + qos_24h: member_health.qos.map(|qos| qos.score_24h), + qos_7d: member_health.qos.map(|qos| qos.score_7d), + confidence: member_health.qos.map(|qos| qos.confidence), + failure_streak: member_health.qos.map_or(0, |qos| qos.failure_streak), + resolution: member_health.probe.as_ref().map(|probe| probe.resolution.clone()), + codec: member_health.probe.as_ref().map(|probe| probe.codec.clone()), + bitrate_kbps: member_health.probe.as_ref().map(|probe| probe.bitrate_kbps), + circuit: circuit.to_string(), + circuit_remaining_secs, + input_circuit: input_circuit.to_string(), + slots_used: u32::try_from(slots_used).unwrap_or(u32::MAX), + slots_max: u32::try_from(slots_max).unwrap_or(u32::MAX), + score: candidate.score, + }); + } + + let capacity = accounts.values().fold(GroupCapacityDto::default(), |mut capacity, &(used, max)| { + capacity.accounts += 1; + capacity.in_use = capacity.in_use.saturating_add(u32::try_from(used).unwrap_or(u32::MAX)); + capacity.max_connections = capacity.max_connections.saturating_add(u32::try_from(max).unwrap_or(u32::MAX)); + capacity + }); + Ok(FailoverGroupHealthDto { members, capacity }) +} + +mod write; + +#[cfg(test)] +mod tests; diff --git a/backend/app/src/api/endpoints/failover_api/tests.rs b/backend/app/src/api/endpoints/failover_api/tests.rs new file mode 100644 index 000000000..a88fa287e --- /dev/null +++ b/backend/app/src/api/endpoints/failover_api/tests.rs @@ -0,0 +1,705 @@ +use super::*; +use crate::{ + api::model::create_test_app_state, + auth::create_jwt_web_user, + model::{Config, SourcesConfig}, +}; +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use http_body_util::BodyExt; +use serde::de::DeserializeOwned; +use shared::model::{ + permission::Permission, CandidatePageDto, ChannelMemberRefDto, ConfigInputDto, ConfigSourceDto, ConfigTargetDto, + ConfigTargetOptions, FailoverGroupHealthDto, FailoverGroupListDto, FailoverGroupOrigin, FailoverGroupPolicy, + FailoverGroupTargetsDto, FailoverGroupsTargetConfig, FailoverTargetState, ManualFailoverGroupDto, + OverrideValidationError, SourcesConfigDto, TargetFailoverOverridesDto, TargetOverridesResponseDto, UserId, + VirtualId, WebAuthConfigDto, WebUiConfigDto, +}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, + time::{Duration, Instant}, +}; +use tempfile::TempDir; +use tower::ServiceExt; +use tuliprox_core::model::{FailoverOverrides, TargetOverridesEntry}; +use tuliprox_repository::{ + get_target_storage_path, CatalogRecord, FailoverGroupMeta, FailoverGroupRecord, FailoverGroupSideFiles, + FailoverGroupStore, GroupMember, +}; +use tuliprox_session::{CircuitPolicy, QosKey, QosPart}; + +const TARGET: &str = "Main"; + +struct Env { + _dir: TempDir, + state: Arc, + storage: PathBuf, +} + +fn member_ref(input: &str, stream_id: &str) -> ChannelMemberRefDto { + ChannelMemberRefDto { input: input.to_string(), stream_id: stream_id.to_string() } +} + +fn input(name: &str, max_connections: u16) -> ConfigInputDto { + ConfigInputDto { + name: name.to_string().into(), + url: format!("http://{name}.invalid/playlist.m3u"), + max_connections, + ..Default::default() + } +} + +fn target_dto(name: &str, failover: bool) -> ConfigTargetDto { + ConfigTargetDto { + name: name.to_string(), + options: failover.then(|| ConfigTargetOptions { + failover_groups: Some(FailoverGroupsTargetConfig { enabled: true, ..Default::default() }), + ..Default::default() + }), + ..Default::default() + } +} + +fn state_with_config(config: Config) -> Arc { with_sources(create_test_app_state(config)) } + +fn with_sources(state: Arc) -> Arc { + let dto = SourcesConfigDto { + inputs: vec![input("p1", 2), input("p2", 3)], + sources: vec![ConfigSourceDto { + inputs: vec!["p1".to_string().into(), "p2".to_string().into()], + targets: vec![target_dto(TARGET, true), target_dto("Plain", false)], + }], + ..Default::default() + }; + state.app_config.sources.store(Arc::new(SourcesConfig::try_from(&dto).unwrap())); + state.active_provider.update_config(&state.app_config); + state +} + +fn env() -> Env { + let dir = TempDir::new().unwrap(); + let config = Config { storage_dir: dir.path().to_string_lossy().to_string(), ..Config::default() }; + let state = state_with_config(config); + let storage = get_target_storage_path(&state.app_config.config.load(), TARGET).unwrap(); + std::fs::create_dir_all(&storage).unwrap(); + Env { _dir: dir, state, storage } +} + +fn catalog_record(input: &str, stream_id: &str, title: &str, key: &str, provider_id: u32) -> CatalogRecord { + CatalogRecord { + member_ref: member_ref(input, stream_id), + natural_key: Some(key.into()), + dsl_forced: false, + epg_channel_id: None, + name: title.into(), + title: title.into(), + logo: format!("http://logo.invalid/{stream_id}.png").into(), + playlist_group: "News".into(), + provider_id, + has_archive: stream_id == "1", + } +} + +fn catalog() -> Vec { + vec![ + catalog_record("p1", "1", "ZDF HD", "zdf", 101), + catalog_record("p2", "1", "ZDF", "zdf", 201), + catalog_record("p1", "2", "RTL", "rtl", 102), + catalog_record("p2", "2", "RTL HD", "rtl", 202), + catalog_record("p1", "3", "Arte", "arte", 103), + ] +} + +fn group_member(input: &str, stream_id: &str, title: &str, provider_id: u32, vid: u32) -> GroupMember { + GroupMember { + member_vid: VirtualId::new(vid), + input_name: input.into(), + provider_id, + input_stream_id: stream_id.into(), + url: format!("http://{input}.invalid/{stream_id}.ts").into(), + item_type: shared::model::PlaylistItemType::Live, + cluster: shared::model::XtreamCluster::Live, + title: title.into(), + archive: None, + epg_channel_id: None, + additional_properties: None, + } +} + +fn group_record(vid: u32, key: &str, members: Vec) -> FailoverGroupRecord { + FailoverGroupRecord { + group_vid: VirtualId::new(vid), + key: key.into(), + origin: FailoverGroupOrigin::Auto, + policy: FailoverGroupPolicy::Auto, + members, + orphaned: Vec::new(), + } +} + +fn persisted_groups() -> Vec { + vec![ + group_record( + 1000, + "zdf", + vec![group_member("p1", "1", "ZDF HD", 101, 1001), group_member("p2", "1", "ZDF", 201, 1002)], + ), + group_record( + 2000, + "rtl", + vec![group_member("p1", "2", "RTL", 102, 2001), group_member("p2", "2", "RTL HD", 202, 2002)], + ), + ] +} + +fn publish(env: &Env, records: &[FailoverGroupRecord], meta: &FailoverGroupMeta) { + let catalog = catalog(); + FailoverGroupStore::write(&env.storage, records, &[], Some(FailoverGroupSideFiles { catalog: &catalog, meta })) + .unwrap(); + env.state.playlists.bump_failover_group_generation(TARGET); +} + +fn seeded_env() -> Env { + let env = env(); + publish(&env, &persisted_groups(), &FailoverGroupMeta::default()); + env +} + +fn section_entry( + section: TargetFailoverOverridesDto, + file: &Path, + findings: Vec, +) -> Arc { + let etag: Arc = Arc::from(section.etag()); + Arc::new(TargetOverridesEntry { overrides: section, file: file.to_path_buf(), etag, findings }) +} + +fn store_snapshot(env: &Env, entries: Vec>) { + let mut overrides = FailoverOverrides::default(); + for entry in entries { + overrides.sections.insert(Arc::from(entry.overrides.target.as_str()), entry); + } + env.state.app_config.failover.store(Arc::new(overrides)); +} + +async fn call(router: &Router, method: &str, uri: &str, body: Option) -> (StatusCode, Vec) { + let mut builder = Request::builder().method(method).uri(uri); + if body.is_some() { + builder = builder.header(axum::http::header::CONTENT_TYPE, "application/json"); + } + let request = builder.body(body.map_or_else(Body::empty, Body::from)).unwrap(); + let response = router.clone().oneshot(request).await.unwrap(); + let status = response.status(); + (status, response.into_body().collect().await.unwrap().to_bytes().to_vec()) +} + +async fn get_json(router: &Router, uri: &str) -> T { + let (status, body) = call(router, "GET", uri, None).await; + assert_eq!(status, StatusCode::OK, "GET {uri}: {}", String::from_utf8_lossy(&body)); + serde_json::from_slice(&body).unwrap() +} + +fn router(env: &Env) -> Router { failover_api_register_unprotected().with_state(Arc::clone(&env.state)) } + +#[tokio::test] +async fn targets_lists_enabled_targets_files_and_orphan_sections() { + let env = seeded_env(); + let meta = FailoverGroupMeta { overrides_etag: Some("applied-1".into()), ..FailoverGroupMeta::default() }; + publish(&env, &persisted_groups(), &meta); + let file = PathBuf::from("/cfg/failover.yml"); + let main = + section_entry(TargetFailoverOverridesDto::new(TARGET), &file, vec![OverrideValidationError::EmptyManualId]); + let gone = section_entry(TargetFailoverOverridesDto::new("Gone"), &file, Vec::new()); + let plain = section_entry(TargetFailoverOverridesDto::new("Plain"), &file, Vec::new()); + let main_etag = main.etag.to_string(); + let mut overrides = FailoverOverrides::default(); + overrides.sections.insert(Arc::from(TARGET), main); + overrides.sections.insert(Arc::from("Gone"), gone); + overrides.sections.insert(Arc::from("Plain"), plain); + overrides.files_used = vec![file.clone()]; + overrides.source = Some(tuliprox_core::utils::FailoverSource::File(file.clone())); + env.state.app_config.failover.store(Arc::new(overrides)); + + let listing: FailoverGroupTargetsDto = get_json(&router(&env), "/failover/targets").await; + + assert_eq!(listing.source.as_deref(), Some("/cfg/failover.yml")); + assert_eq!(listing.files_used, vec!["/cfg/failover.yml".to_string()]); + assert_eq!(listing.load_error, None); + let names: Vec<&str> = listing.targets.iter().map(|target| target.name.as_str()).collect(); + assert_eq!(names, vec![TARGET, "Gone", "Plain"]); + let main = &listing.targets[0]; + assert_eq!(main.state, FailoverTargetState::Enabled); + assert_eq!(main.etag.as_deref(), Some(main_etag.as_str())); + assert_eq!(main.applied_etag.as_deref(), Some("applied-1")); + assert_eq!(main.file.as_deref(), Some("/cfg/failover.yml")); + assert_eq!(main.group_count, 2); + assert_eq!(main.findings, vec![OverrideValidationError::EmptyManualId]); + assert_eq!(main.problem_count, 1); + let gone = &listing.targets[1]; + assert_eq!(gone.state, FailoverTargetState::Orphan); + assert_eq!(gone.group_count, 0); + assert_eq!(gone.problem_count, 1); + // A configured target without failover groups keeps its section visible, with the reason. + let plain = &listing.targets[2]; + assert_eq!(plain.state, FailoverTargetState::Disabled); + assert_eq!(plain.file.as_deref(), Some("/cfg/failover.yml")); + assert_eq!(plain.problem_count, 1); +} + +#[tokio::test] +async fn targets_reports_load_error_and_lists_enabled_target_without_section() { + let env = env(); + let overrides = FailoverOverrides { error: Some("duplicate target".to_string()), ..FailoverOverrides::default() }; + env.state.app_config.failover.store(Arc::new(overrides)); + + let listing: FailoverGroupTargetsDto = get_json(&router(&env), "/failover/targets").await; + + assert_eq!(listing.load_error.as_deref(), Some("duplicate target")); + assert_eq!(listing.targets.len(), 1); + let main = &listing.targets[0]; + assert_eq!((main.name.as_str(), main.etag.as_ref(), main.file.as_ref(), main.group_count), (TARGET, None, None, 0)); +} + +#[tokio::test] +async fn groups_and_preview_expose_display_stale_groups() { + let env = env(); + let meta = FailoverGroupMeta { display_stale_groups: vec!["zdf".to_string()], ..FailoverGroupMeta::default() }; + publish(&env, &persisted_groups(), &meta); + let router = router(&env); + + let list: FailoverGroupListDto = get_json(&router, "/failover/groups?target=Main").await; + assert_eq!(list.display_stale, ["zdf"]); + + let body = serde_json::to_string(&TargetFailoverOverridesDto::new(TARGET)).unwrap(); + let (status, bytes) = call(&router, "POST", "/failover/preview?target=Main", Some(body)).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(serde_json::from_slice::(&bytes).unwrap().display_stale, ["zdf"]); +} + +#[tokio::test] +async fn list_returns_groups_diagnostics_and_etags() { + let env = seeded_env(); + let router = router(&env); + let section = TargetFailoverOverridesDto::new(TARGET); + let entry = section_entry(section, Path::new("failover.yml"), Vec::new()); + let current_etag = entry.etag.to_string(); + store_snapshot(&env, vec![entry]); + + let list: FailoverGroupListDto = get_json(&router, "/failover/groups?target=Main").await; + + assert_eq!(list.etag.as_deref(), Some(current_etag.as_str())); + assert_eq!(list.applied_etag, None); + assert!(list.diagnostics.is_empty()); + assert!(list.display_stale.is_empty()); + assert_eq!(list.groups.len(), 2); + let zdf = list.groups.iter().find(|group| group.key == "zdf").unwrap(); + assert_eq!(zdf.group_vid, Some(VirtualId::new(1000))); + assert_eq!(zdf.origin, FailoverGroupOrigin::Auto); + assert_eq!(zdf.name, "ZDF HD"); + assert_eq!(zdf.logo.as_deref(), Some("http://logo.invalid/1.png")); + assert_eq!(zdf.playlist_group.as_deref(), Some("News")); + let members: Vec<_> = zdf.members.iter().map(|m| (m.member_vid, m.member_ref.clone(), m.title.as_str())).collect(); + assert_eq!( + members, + vec![ + (Some(VirtualId::new(1001)), member_ref("p1", "1"), "ZDF HD"), + (Some(VirtualId::new(1002)), member_ref("p2", "1"), "ZDF"), + ] + ); + assert!(zdf.members[0].has_archive); + assert_eq!(zdf.members[0].provider_id, 101); + assert_eq!(zdf.members[0].input_name, "p1"); + + // The applied section drives the resolution, so its diagnostics surface here. + let mut applied = TargetFailoverOverridesDto::new(TARGET); + applied + .auto_overrides + .push(shared::model::AutoGroupOverrideDto { auto_key: "vanished".to_string(), ..Default::default() }); + let meta = FailoverGroupMeta { + overrides_etag: Some("applied-2".into()), + applied_overrides: Some(applied), + ..FailoverGroupMeta::default() + }; + publish(&env, &persisted_groups(), &meta); + + let list: FailoverGroupListDto = get_json(&router, "/failover/groups?target=Main").await; + assert_eq!(list.applied_etag.as_deref(), Some("applied-2")); + assert_eq!(list.diagnostics.stale_auto_overrides, vec!["vanished".to_string()]); + assert_eq!(list.groups.len(), 2); +} + +#[tokio::test] +async fn overrides_get_returns_section_etag_and_file() { + let env = seeded_env(); + let router = router(&env); + let mut section = TargetFailoverOverridesDto::new(TARGET); + section.excluded.push(member_ref("p1", "3")); + let entry = section_entry(section.clone(), Path::new("/cfg/failover.d/main.yml"), Vec::new()); + let etag = entry.etag.to_string(); + store_snapshot(&env, vec![entry]); + + let response: TargetOverridesResponseDto = get_json(&router, "/failover/overrides?target=Main").await; + assert_eq!(response.etag.as_deref(), Some(etag.as_str())); + assert_eq!(response.file.as_deref(), Some("/cfg/failover.d/main.yml")); + assert_eq!(response.overrides, section); + + store_snapshot(&env, Vec::new()); + let response: TargetOverridesResponseDto = get_json(&router, "/failover/overrides?target=Main").await; + assert_eq!((response.etag, response.file), (None, None)); + assert_eq!(response.overrides, TargetFailoverOverridesDto::new(TARGET)); +} + +#[tokio::test] +async fn preview_reflects_draft_and_writes_nothing() { + let env = seeded_env(); + let router = router(&env); + let before_groups = FailoverGroupStore::read_all_groups(&env.storage).unwrap(); + let before_meta = FailoverGroupStore::read_meta(&env.storage).unwrap(); + let current = section_entry(TargetFailoverOverridesDto::new(TARGET), Path::new("failover.yml"), Vec::new()); + let current_etag = current.etag.to_string(); + store_snapshot(&env, vec![current]); + let before_snapshot = env.state.app_config.failover.load_full(); + let generation = env.state.playlists.failover_group_generation(TARGET); + + let mut draft = TargetFailoverOverridesDto::new(TARGET); + draft.groups.push(ManualFailoverGroupDto { + id: "mine".to_string(), + name: "Mine".to_string(), + members: vec![member_ref("p1", "3"), member_ref("p2", "2")], + ..Default::default() + }); + let body = serde_json::to_string(&draft).unwrap(); + let (status, bytes) = call(&router, "POST", "/failover/preview?target=Main", Some(body)).await; + + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&bytes)); + let list: FailoverGroupListDto = serde_json::from_slice(&bytes).unwrap(); + let keys: Vec<(&str, FailoverGroupOrigin)> = list.groups.iter().map(|g| (g.key.as_str(), g.origin)).collect(); + assert_eq!(keys, vec![("mine", FailoverGroupOrigin::Manual), ("zdf", FailoverGroupOrigin::Auto)]); + let mine = &list.groups[0]; + assert_eq!(mine.group_vid, None); + assert_eq!(mine.name, "Mine"); + assert_eq!( + mine.members.iter().map(|m| m.member_ref.clone()).collect::>(), + vec![member_ref("p1", "3"), member_ref("p2", "2")] + ); + assert!(mine.members.iter().all(|m| m.member_vid.is_none())); + assert_eq!(list.groups[1].group_vid, Some(VirtualId::new(1000))); + assert_eq!(list.etag.as_deref(), Some(current_etag.as_str())); + assert_ne!(list.etag, Some(draft.etag())); + + assert_eq!(FailoverGroupStore::read_all_groups(&env.storage).unwrap(), before_groups); + assert_eq!(FailoverGroupStore::read_meta(&env.storage).unwrap(), before_meta); + assert!(Arc::ptr_eq(&env.state.app_config.failover.load_full(), &before_snapshot)); + assert_eq!(env.state.playlists.failover_group_generation(TARGET), generation); +} + +#[tokio::test] +async fn preview_reports_diagnostics_of_the_draft() { + let env = seeded_env(); + let mut draft = TargetFailoverOverridesDto::new(TARGET); + draft.groups.push(ManualFailoverGroupDto { + id: "lonely".to_string(), + name: "Lonely".to_string(), + members: vec![member_ref("p1", "3"), member_ref("p9", "9")], + ..Default::default() + }); + let body = serde_json::to_string(&draft).unwrap(); + + let (status, bytes) = call(&router(&env), "POST", "/failover/preview?target=Main", Some(body)).await; + + assert_eq!(status, StatusCode::OK); + let list: FailoverGroupListDto = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(list.diagnostics.inactive_manual_groups.len(), 1); + assert_eq!(list.diagnostics.unresolved_refs.len(), 1); + assert_eq!(list.diagnostics.unresolved_refs[0].member_ref, member_ref("p9", "9")); +} + +#[tokio::test] +async fn preview_of_empty_draft_matches_the_persisted_listing() { + let env = seeded_env(); + let router = router(&env); + let listing: FailoverGroupListDto = get_json(&router, "/failover/groups?target=Main").await; + let body = serde_json::to_string(&TargetFailoverOverridesDto::new(TARGET)).unwrap(); + + let (status, bytes) = call(&router, "POST", "/failover/preview?target=Main", Some(body)).await; + + assert_eq!(status, StatusCode::OK); + let preview: FailoverGroupListDto = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(preview.groups, listing.groups); + assert_eq!(preview.diagnostics, listing.diagnostics); +} + +#[tokio::test] +async fn preview_group_name_matches_processing() { + let env = env(); + let mut catalog = catalog(); + catalog[0].name = "ZDF Raw Name".into(); + catalog[0].title = "ZDF Display Title".into(); + let meta = FailoverGroupMeta::default(); + FailoverGroupStore::write( + &env.storage, + &persisted_groups(), + &[], + Some(FailoverGroupSideFiles { catalog: &catalog, meta: &meta }), + ) + .unwrap(); + env.state.playlists.bump_failover_group_generation(TARGET); + let router = router(&env); + let body = serde_json::to_string(&TargetFailoverOverridesDto::new(TARGET)).unwrap(); + + let (status, bytes) = call(&router, "POST", "/failover/preview?target=Main", Some(body)).await; + + assert_eq!(status, StatusCode::OK); + let preview: FailoverGroupListDto = serde_json::from_slice(&bytes).unwrap(); + let zdf = preview.groups.iter().find(|group| group.key == "zdf").expect("zdf group"); + assert_eq!(zdf.name, "ZDF Raw Name", "name comes from header.name like the published group, not the title"); + assert_eq!(zdf.members[0].title, "ZDF Display Title"); +} + +#[test] +fn query_match_ignores_case_for_ascii_and_unicode() { + assert!(contains_ignore_case("ZDF HD", "zdf")); + assert!(contains_ignore_case("ZDF HD", "")); + assert!(!contains_ignore_case("ZDF", "zdf hd")); + assert!(contains_ignore_case("Das Erste Ärzte", "ärzte")); + assert!(!contains_ignore_case("Arte", "ärzte")); +} + +#[tokio::test] +async fn candidates_paged_and_filtered() { + let env = seeded_env(); + let router = router(&env); + let mut section = TargetFailoverOverridesDto::new(TARGET); + section.excluded.push(member_ref("p1", "3")); + store_snapshot(&env, vec![section_entry(section, Path::new("failover.yml"), Vec::new())]); + + let all: CandidatePageDto = get_json(&router, "/failover/candidates?target=Main").await; + assert_eq!(all.total, 5); + assert_eq!(all.items.len(), 5); + let zdf = &all.items[0]; + assert_eq!(zdf.member_ref, member_ref("p1", "1")); + assert_eq!(zdf.group_key.as_deref(), Some("zdf")); + assert!(!zdf.excluded); + assert_eq!((zdf.title.as_str(), zdf.provider_id, zdf.has_archive), ("ZDF HD", 101, true)); + + let ungrouped: CandidatePageDto = get_json(&router, "/failover/candidates?target=Main&filter=ungrouped").await; + assert_eq!(ungrouped.total, 1); + assert_eq!(ungrouped.items[0].member_ref, member_ref("p1", "3")); + assert_eq!(ungrouped.items[0].group_key, None); + + let grouped: CandidatePageDto = get_json(&router, "/failover/candidates?target=Main&filter=grouped").await; + assert_eq!(grouped.total, 4); + assert!(grouped.items.iter().all(|item| item.group_key.is_some())); + + let excluded: CandidatePageDto = get_json(&router, "/failover/candidates?target=Main&filter=excluded").await; + assert_eq!(excluded.total, 1); + assert!(excluded.items[0].excluded); + assert_eq!(excluded.items[0].title, "Arte"); + + let query: CandidatePageDto = get_json(&router, "/failover/candidates?target=Main&q=zDf").await; + assert_eq!(query.total, 2); + assert!(query.items.iter().all(|item| item.title.starts_with("ZDF"))); + + let page: CandidatePageDto = get_json(&router, "/failover/candidates?target=Main&offset=1&limit=2").await; + assert_eq!(page.total, 5); + assert_eq!( + page.items.iter().map(|item| item.member_ref.clone()).collect::>(), + vec![member_ref("p2", "1"), member_ref("p1", "2")] + ); + + let past_end: CandidatePageDto = get_json(&router, "/failover/candidates?target=Main&offset=50").await; + assert_eq!((past_end.total, past_end.items.len()), (5, 0)); +} + +#[tokio::test] +async fn health_reports_score_breakdown_and_capacity() { + let env = seeded_env(); + let health = &env.state.member_health; + health.set_policy(CircuitPolicy::new(1, Duration::from_secs(60), Duration::from_secs(60))); + health.replace_qos([ + ( + QosKey::Provider { input: "p1".into(), provider_id: 101 }, + QosPart { score_24h: 90, score_7d: 80, confidence: 100, failure_streak: 0 }, + ), + ( + QosKey::Provider { input: "p2".into(), provider_id: 201 }, + QosPart { score_24h: 20, score_7d: 20, confidence: 100, failure_streak: 0 }, + ), + ]); + + let report: FailoverGroupHealthDto = get_json(&router(&env), "/failover/health?target=Main&group=1000").await; + + assert_eq!(report.members.len(), 2); + let first = &report.members[0]; + assert_eq!((first.member_vid, first.rank), (VirtualId::new(1001), 1)); + assert_eq!(first.member_ref, member_ref("p1", "1")); + assert_eq!((first.qos_24h, first.qos_7d, first.confidence), (Some(90), Some(80), Some(100))); + assert_eq!(first.score.qos, 90); + assert!(!first.score.demoted); + assert_eq!(first.circuit, "closed"); + assert_eq!((first.slots_used, first.slots_max), (0, 2)); + let second = &report.members[1]; + assert_eq!((second.member_vid, second.rank), (VirtualId::new(1002), 2)); + assert_eq!(second.score.qos, 20); + assert!(first.score.total > second.score.total); + assert_eq!((second.slots_used, second.slots_max), (0, 3)); + assert_eq!(report.capacity.accounts, 2); + assert_eq!(report.capacity.max_connections, 5); + assert_eq!(report.capacity.in_use, 0); + + // An open member circuit demotes the member behind a healthy one, whatever its score. + health.record_failure(&(Arc::from("p1"), Arc::from("1")), Instant::now()); + let report: FailoverGroupHealthDto = get_json(&router(&env), "/failover/health?target=Main&group=1000").await; + assert_eq!(report.members[0].member_vid, VirtualId::new(1002)); + let demoted = &report.members[1]; + assert_eq!(demoted.circuit, "open"); + assert!(demoted.circuit_remaining_secs.is_some()); + assert!(demoted.score.demoted); +} + +#[tokio::test] +async fn health_counts_shared_accounts_once() { + let env = env(); + let records = vec![group_record( + 3000, + "dup", + vec![group_member("p1", "1", "A", 101, 3001), group_member("p1", "3", "B", 103, 3002)], + )]; + publish(&env, &records, &FailoverGroupMeta::default()); + + let report: FailoverGroupHealthDto = get_json(&router(&env), "/failover/health?target=Main&group=3000").await; + + assert_eq!(report.members.len(), 2); + assert_eq!((report.capacity.accounts, report.capacity.max_connections), (1, 2)); + assert!(report.members.iter().all(|member| member.slots_max == 2)); +} + +#[tokio::test] +async fn health_of_unknown_group_is_not_found() { + let env = seeded_env(); + let (status, _) = call(&router(&env), "GET", "/failover/health?target=Main&group=4242", None).await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn unknown_and_disabled_targets_are_not_found() { + let env = seeded_env(); + let router = router(&env); + for target in ["Nope", "Plain"] { + for (method, route) in + [("GET", "groups"), ("GET", "overrides"), ("POST", "preview"), ("GET", "candidates"), ("GET", "health")] + { + let uri = format!("/failover/{route}?target={target}&group=1"); + let body = + (method == "POST").then(|| serde_json::to_string(&TargetFailoverOverridesDto::new(target)).unwrap()); + let (status, _) = call(&router, method, &uri, body).await; + assert_eq!(status, StatusCode::NOT_FOUND, "{method} {uri}"); + } + } +} + +#[tokio::test] +async fn no_catalog_returns_409_run_update_first() { + let env = env(); + let router = router(&env); + let body = serde_json::to_string(&TargetFailoverOverridesDto::new(TARGET)).unwrap(); + for (method, uri) in [ + ("GET", "/failover/groups?target=Main"), + ("POST", "/failover/preview?target=Main"), + ("GET", "/failover/candidates?target=Main"), + ] { + let (status, bytes) = call(&router, method, uri, (method == "POST").then(|| body.clone())).await; + assert_eq!(status, StatusCode::CONFLICT, "{method} {uri}"); + let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(value["error"], "run a playlist update first"); + } + + // Reading the stored section does not need a catalog. + let (status, _) = call(&router, "GET", "/failover/overrides?target=Main", None).await; + assert_eq!(status, StatusCode::OK); +} + +#[tokio::test] +async fn catalog_is_read_once_for_repeated_requests() { + let env = seeded_env(); + let router = router(&env); + let _: CandidatePageDto = get_json(&router, "/failover/candidates?target=Main").await; + let loads = env.state.playlists.failover_groups.catalog_load_count(); + let _: CandidatePageDto = get_json(&router, "/failover/candidates?target=Main&q=zdf").await; + let _: FailoverGroupListDto = get_json(&router, "/failover/groups?target=Main").await; + assert_eq!(env.state.playlists.failover_groups.catalog_load_count(), loads); +} + +#[test] +fn page_window_defaults_and_clamps_limit() { + assert_eq!(page_window(None, None), (0, 100)); + assert_eq!(page_window(Some(7), Some(10)), (7, 10)); + assert_eq!(page_window(None, Some(10_000)), (0, 500)); + assert_eq!(page_window(None, Some(0)), (0, 0)); +} + +/// A config with web auth enabled, so the permission layers of the router are active. +fn secured_config(storage_dir: &Path) -> Config { + let web_ui = WebUiConfigDto { + auth: Some(WebAuthConfigDto { + enabled: true, + issuer: "failover-test".to_owned(), + secret: "failover-test-secret".to_owned(), + ..Default::default() + }), + ..Default::default() + }; + Config { + web_ui: Some((&web_ui).into()), + storage_dir: storage_dir.to_string_lossy().to_string(), + ..Default::default() + } +} + +fn token_with(config: &Config, name: &str, permission: Permission) -> Result> { + let Some(web_auth) = config.web_ui.as_ref().and_then(|web_ui| web_ui.auth.as_ref()) else { + return Err("missing web auth".into()); + }; + Ok(create_jwt_web_user(web_auth, name, permission.into(), 0, UserId::web(name))?) +} + +fn json_request(method: &str, uri: &str, token: &str, body: Vec) -> Result, axum::http::Error> { + Request::builder() + .method(method) + .uri(uri) + .header("Authorization", format!("Bearer {token}")) + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) +} + +#[tokio::test] +async fn read_routes_require_playlist_read() -> Result<(), Box> { + let dir = TempDir::new()?; + let config = secured_config(dir.path()); + let reader = token_with(&config, "reader", Permission::PlaylistRead)?; + let system = token_with(&config, "system", Permission::SystemRead)?; + let state = state_with_config(config); + let router = failover_api_register_with_permissions(&state).with_state(Arc::clone(&state)); + let body = serde_json::to_vec(&TargetFailoverOverridesDto::new(TARGET))?; + + for (method, uri) in [ + ("GET", "/failover/targets"), + ("GET", "/failover/groups?target=Main"), + ("GET", "/failover/overrides?target=Main"), + ("POST", "/failover/preview?target=Main"), + ("GET", "/failover/candidates?target=Main"), + ("GET", "/failover/health?target=Main&group=1"), + ] { + let denied = router.clone().oneshot(json_request(method, uri, &system, body.clone())?).await?; + assert_eq!(denied.status(), StatusCode::FORBIDDEN, "{method} {uri}"); + let allowed = router.clone().oneshot(json_request(method, uri, &reader, body.clone())?).await?; + assert_ne!(allowed.status(), StatusCode::FORBIDDEN, "{method} {uri}"); + assert_ne!(allowed.status(), StatusCode::UNAUTHORIZED, "{method} {uri}"); + } + Ok(()) +} + +mod write; diff --git a/backend/app/src/api/endpoints/failover_api/tests/write.rs b/backend/app/src/api/endpoints/failover_api/tests/write.rs new file mode 100644 index 000000000..76a99f478 --- /dev/null +++ b/backend/app/src/api/endpoints/failover_api/tests/write.rs @@ -0,0 +1,405 @@ +use super::*; +use crate::api::model::{create_test_app_state_with_manual_update_sender, EventMessage, ManualPlaylistUpdateRequest}; +use shared::model::{ + AutoGroupOverrideDto, FailoverFileDto, FailoverGroupConflictDto, FailoverGroupSaveResultDto, OperationRunAccepted, + ReassignOverridesRequestDto, SaveOverridesRequestDto, +}; +use tokio::sync::{broadcast, mpsc}; +use tuliprox_repository::load_failover_overrides; + +struct WriteEnv { + env: Env, + failover_file: PathBuf, + updates: mpsc::Receiver, +} + +impl WriteEnv { + fn router(&self) -> Router { router(&self.env) } + + fn events(&self) -> broadcast::Receiver { self.env.state.event_manager.get_event_channel() } + + fn snapshot_etag(&self, target: &str) -> Option { + self.env.state.app_config.failover.load().sections.get(target).map(|entry| entry.etag.to_string()) + } + + /// Writes `targets` to the failover file and loads them into the snapshot like start-up does. + fn seed(&self, targets: Vec) { + write_file(&self.failover_file, targets); + self.reload_snapshot(); + } + + fn reload_snapshot(&self) { + let source = tuliprox_core::utils::FailoverSource::File(self.failover_file.clone()); + self.env.state.app_config.failover.store(Arc::new(load_failover_overrides(&source).unwrap())); + } + + fn file_targets(&self) -> Vec { + let dto: FailoverFileDto = + serde_saphyr::from_str(&std::fs::read_to_string(&self.failover_file).unwrap()).unwrap(); + dto.targets.into_iter().map(|section| section.target).collect() + } +} + +fn write_env() -> WriteEnv { write_env_with_config(|config| config) } + +fn write_env_with_config(adjust: impl FnOnce(Config) -> Config) -> WriteEnv { + let dir = TempDir::new().unwrap(); + let config_dir = dir.path().join("config"); + std::fs::create_dir_all(&config_dir).unwrap(); + let config = adjust(Config { storage_dir: dir.path().to_string_lossy().to_string(), ..Config::default() }); + let (sender, updates) = mpsc::channel(1); + let state = with_sources(create_test_app_state_with_manual_update_sender(config, sender)); + let mut paths = (*state.app_config.paths.load_full()).clone(); + paths.config_path = config_dir.to_string_lossy().into_owned(); + state.app_config.paths.store(Arc::new(paths)); + let storage = get_target_storage_path(&state.app_config.config.load(), TARGET).unwrap(); + std::fs::create_dir_all(&storage).unwrap(); + WriteEnv { env: Env { _dir: dir, state, storage }, failover_file: config_dir.join("failover.yml"), updates } +} + +fn write_file(path: &Path, targets: Vec) { + let dto = FailoverFileDto { version: 1, targets }; + std::fs::write(path, serde_saphyr::to_string(&dto).unwrap()).unwrap(); +} + +fn excluding(target: &str, stream_ids: &[&str]) -> TargetFailoverOverridesDto { + let mut section = TargetFailoverOverridesDto::new(target); + section.excluded = stream_ids.iter().map(|id| member_ref("p1", id)).collect(); + section +} + +fn save_body(expected_etag: Option<&str>, overrides: TargetFailoverOverridesDto) -> String { + serde_json::to_string(&SaveOverridesRequestDto { expected_etag: expected_etag.map(str::to_string), overrides }) + .unwrap() +} + +fn parse(body: &[u8]) -> T { serde_json::from_slice(body).unwrap() } + +fn drain_changed(events: &mut broadcast::Receiver) -> Vec> { + let mut changed = Vec::new(); + while let Ok(event) = events.try_recv() { + if let EventMessage::FailoverOverridesChanged { targets } = event { + changed.push(targets); + } + } + changed +} + +fn pinned_zdf(order: &[(&str, &str)]) -> TargetFailoverOverridesDto { + let mut section = TargetFailoverOverridesDto::new(TARGET); + section.auto_overrides.push(AutoGroupOverrideDto { + auto_key: "zdf".to_string(), + order: order.iter().map(|(input, id)| member_ref(input, id)).collect(), + name: Some("ZDF".to_string()), + logo: Some("http://logo.invalid/zdf.png".to_string()), + epg_channel_id: Some("zdf.de".to_string()), + playlist_group: Some("News".to_string()), + ..Default::default() + }); + section +} + +#[tokio::test] +async fn save_writes_yaml_and_returns_new_etag() { + let env = write_env(); + let router = env.router(); + + let (status, body) = + call(&router, "PUT", "/failover/overrides?target=Main", Some(save_body(None, excluding(TARGET, &["3"])))).await; + + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); + let saved: FailoverGroupSaveResultDto = parse(&body); + assert!(!saved.hot_applied); + assert_eq!(saved.file.as_deref(), Some(env.failover_file.to_string_lossy().as_ref())); + assert_eq!(env.file_targets(), vec![TARGET.to_string()]); + assert_eq!(saved.etag, env.snapshot_etag(TARGET)); + assert!(saved.etag.is_some()); + + let stored: TargetOverridesResponseDto = get_json(&router, "/failover/overrides?target=Main").await; + assert_eq!(stored.etag, saved.etag); + assert_eq!(stored.overrides.excluded, vec![member_ref("p1", "3")]); +} + +#[tokio::test] +async fn save_conflict_returns_409_with_current_etag() { + let env = write_env(); + env.seed(vec![excluding(TARGET, &["1"])]); + let current = env.snapshot_etag(TARGET); + let router = env.router(); + + let (status, body) = + call(&router, "PUT", "/failover/overrides?target=Main", Some(save_body(None, excluding(TARGET, &["3"])))).await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(parse::(&body).current_etag, current); + + let (status, _) = call( + &router, + "PUT", + "/failover/overrides?target=Main", + Some(save_body(Some("stale"), excluding(TARGET, &["3"]))), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(env.snapshot_etag(TARGET), current, "a conflicting save leaves the section untouched"); +} + +#[tokio::test] +async fn save_invalid_returns_422_findings() { + let env = write_env(); + let mut invalid = TargetFailoverOverridesDto::new(TARGET); + invalid.groups.push(ManualFailoverGroupDto { + id: "Not Valid".to_string(), + name: "Bad".to_string(), + ..Default::default() + }); + + let (status, body) = + call(&env.router(), "PUT", "/failover/overrides?target=Main", Some(save_body(None, invalid))).await; + + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!( + parse::>(&body), + vec![OverrideValidationError::InvalidManualId("Not Valid".to_string())] + ); + assert!(!env.failover_file.exists()); +} + +#[tokio::test] +async fn save_refused_with_423_when_files_invalid() { + let env = write_env(); + std::fs::write(&env.failover_file, "version: 1\ntargets: [not a mapping").unwrap(); + let source = tuliprox_core::utils::FailoverSource::File(env.failover_file.clone()); + let failed = load_failover_overrides(&source).unwrap_err(); + env.env.state.app_config.failover.store(Arc::new(FailoverOverrides { + source: Some(source), + error: Some(failed.to_string()), + ..FailoverOverrides::default() + })); + let router = env.router(); + + for (method, uri, body) in [ + ("PUT", "/failover/overrides?target=Main", Some(save_body(None, excluding(TARGET, &["3"])))), + ("DELETE", "/failover/overrides?target=Main&expected_etag=x", None), + ] { + let (status, body) = call(&router, method, uri, body).await; + assert_eq!(status, StatusCode::LOCKED, "{method}"); + let message = parse::(&body)["error"].as_str().unwrap_or_default().to_string(); + assert!(!message.is_empty(), "{method}"); + } + assert_eq!(std::fs::read_to_string(&env.failover_file).unwrap(), "version: 1\ntargets: [not a mapping"); +} + +#[tokio::test] +async fn save_for_unknown_or_disabled_target_is_not_found() { + let env = write_env(); + let router = env.router(); + for target in ["Plain", "Nope"] { + let (status, _) = call( + &router, + "PUT", + &format!("/failover/overrides?target={target}"), + Some(save_body(None, excluding(target, &["3"]))), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND, "{target}"); + } + assert!(!env.failover_file.exists()); +} + +#[tokio::test] +async fn order_only_save_hot_applies() { + let env = write_env(); + let applied = pinned_zdf(&[("p1", "1"), ("p2", "1")]); + env.seed(vec![applied.clone()]); + let meta = FailoverGroupMeta { + overrides_etag: env.snapshot_etag(TARGET).map(Arc::from), + applied_overrides: Some(applied), + ..FailoverGroupMeta::default() + }; + publish(&env.env, &persisted_groups(), &meta); + let etag = env.snapshot_etag(TARGET); + + let reordered = pinned_zdf(&[("p2", "1"), ("p1", "1")]); + let (status, body) = + call(&env.router(), "PUT", "/failover/overrides?target=Main", Some(save_body(etag.as_deref(), reordered))) + .await; + + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); + let saved: FailoverGroupSaveResultDto = parse(&body); + assert!(saved.hot_applied); + assert_ne!(saved.etag, etag); + let data = FailoverGroupStore::load_target_data(&env.env.storage, 0).unwrap(); + let zdf = data.groups.get(&VirtualId::new(1000)).unwrap(); + let order: Vec = zdf.members.iter().map(|member| member.member_vid.0).collect(); + assert_eq!(order, vec![1002, 1001]); + assert_eq!(data.meta.overrides_etag.as_deref(), saved.etag.as_deref()); +} + +#[tokio::test] +async fn structural_save_is_not_hot_applied() { + let env = write_env(); + let applied = pinned_zdf(&[("p1", "1"), ("p2", "1")]); + env.seed(vec![applied.clone()]); + let meta = FailoverGroupMeta { + overrides_etag: env.snapshot_etag(TARGET).map(Arc::from), + applied_overrides: Some(applied.clone()), + ..FailoverGroupMeta::default() + }; + publish(&env.env, &persisted_groups(), &meta); + let etag = env.snapshot_etag(TARGET); + + let mut changed = applied; + changed.excluded.push(member_ref("p1", "3")); + let (status, body) = + call(&env.router(), "PUT", "/failover/overrides?target=Main", Some(save_body(etag.as_deref(), changed))).await; + + assert_eq!(status, StatusCode::OK); + assert!(!parse::(&body).hot_applied); +} + +#[tokio::test] +async fn delete_removes_section() { + let env = write_env(); + env.seed(vec![excluding(TARGET, &["1"]), excluding("Other", &["2"])]); + let etag = env.snapshot_etag(TARGET).unwrap(); + let router = env.router(); + let mut events = env.events(); + + let (status, _) = call(&router, "DELETE", "/failover/overrides?target=Main&expected_etag=stale", None).await; + assert_eq!(status, StatusCode::CONFLICT); + assert!(drain_changed(&mut events).is_empty()); + + let (status, body) = + call(&router, "DELETE", &format!("/failover/overrides?target=Main&expected_etag={etag}"), None).await; + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); + let result: FailoverGroupSaveResultDto = parse(&body); + assert_eq!((result.etag, result.file, result.hot_applied), (None, None, false)); + assert_eq!(env.file_targets(), vec!["Other".to_string()]); + assert_eq!(env.snapshot_etag(TARGET), None); + assert_eq!(drain_changed(&mut events), vec![vec![TARGET.to_string()]]); +} + +#[tokio::test] +async fn reassign_moves_orphan_section() { + let env = write_env(); + env.seed(vec![excluding("Gone", &["1"])]); + let etag = env.snapshot_etag("Gone").unwrap(); + let router = env.router(); + let mut events = env.events(); + let request = |from: &str, to: &str, expected_etag: &str| { + Some( + serde_json::to_string(&ReassignOverridesRequestDto { + from: from.to_string(), + to: to.to_string(), + expected_etag: expected_etag.to_string(), + }) + .unwrap(), + ) + }; + + let (status, _) = call(&router, "POST", "/failover/overrides/reassign", request("Gone", "Missing", &etag)).await; + assert_eq!(status, StatusCode::NOT_FOUND, "the new owner must be a configured target"); + let (status, _) = call(&router, "POST", "/failover/overrides/reassign", request("Gone", "Plain", &etag)).await; + assert_eq!(status, StatusCode::NOT_FOUND, "the new owner must have failover groups enabled"); + assert_eq!(env.snapshot_etag("Gone"), Some(etag.clone())); + let (status, body) = call(&router, "POST", "/failover/overrides/reassign", request("Gone", TARGET, "stale")).await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(parse::(&body).current_etag.as_deref(), Some(etag.as_str())); + assert!(drain_changed(&mut events).is_empty()); + + let (status, body) = call(&router, "POST", "/failover/overrides/reassign", request("Gone", TARGET, &etag)).await; + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); + let result: FailoverGroupSaveResultDto = parse(&body); + assert_eq!(result.etag, env.snapshot_etag(TARGET)); + assert!(!result.hot_applied); + assert_eq!(env.file_targets(), vec![TARGET.to_string()]); + assert_eq!(env.snapshot_etag("Gone"), None); + assert_eq!(drain_changed(&mut events), vec![vec!["Gone".to_string(), TARGET.to_string()]]); +} + +#[tokio::test] +async fn save_emits_overrides_changed_event() { + let env = write_env(); + let mut events = env.events(); + + let (status, _) = + call(&env.router(), "PUT", "/failover/overrides?target=Main", Some(save_body(None, excluding(TARGET, &["3"])))) + .await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(drain_changed(&mut events), vec![vec![TARGET.to_string()]]); +} + +#[tokio::test] +async fn apply_enqueues_target_update() { + let mut env = write_env(); + let router = env.router(); + + let (status, body) = call(&router, "POST", "/failover/apply?target=Main", None).await; + + assert_eq!(status, StatusCode::ACCEPTED, "{}", String::from_utf8_lossy(&body)); + let accepted: OperationRunAccepted = parse(&body); + let queued = env.updates.try_recv().unwrap(); + assert_eq!(Some(queued.run_id.clone()), accepted.run_id); + assert_eq!(queued.targets.target_names, vec![TARGET.to_string()]); + assert!(queued.input_action.is_none()); + + for target in ["Plain", "Nope"] { + let (status, _) = call(&router, "POST", &format!("/failover/apply?target={target}"), None).await; + assert_eq!(status, StatusCode::NOT_FOUND, "{target}"); + } + assert!(env.updates.try_recv().is_err(), "rejected requests enqueue nothing"); +} + +#[tokio::test] +async fn apply_busy_returns_409() { + let env = write_env(); + let router = env.router(); + let (status, _) = call(&router, "POST", "/failover/apply?target=Main", None).await; + assert_eq!(status, StatusCode::ACCEPTED); + + let (status, body) = call(&router, "POST", "/failover/apply?target=Main", None).await; + + assert_eq!(status, StatusCode::CONFLICT); + assert!(parse::(&body)["error"].is_string()); +} + +#[tokio::test] +async fn apply_without_worker_returns_503() { + let WriteEnv { env, updates, .. } = write_env(); + drop(updates); + + let (status, _) = call(&router(&env), "POST", "/failover/apply?target=Main", None).await; + + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); +} + +#[tokio::test] +async fn write_routes_require_playlist_write() -> Result<(), Box> { + let env = write_env_with_config(|config| secured_config(Path::new(&config.storage_dir))); + let config = env.env.state.app_config.config.load_full(); + let reader = token_with(&config, "reader", Permission::PlaylistRead)?; + let writer = token_with(&config, "writer", Permission::PlaylistWrite)?; + let router = failover_api_register_with_permissions(&env.env.state).with_state(Arc::clone(&env.env.state)); + let save = + serde_json::to_vec(&SaveOverridesRequestDto { expected_etag: None, overrides: excluding(TARGET, &["3"]) })?; + let reassign = serde_json::to_vec(&ReassignOverridesRequestDto { + from: "Gone".to_string(), + to: TARGET.to_string(), + expected_etag: "x".to_string(), + })?; + + for (method, uri, body) in [ + ("PUT", "/failover/overrides?target=Main", save), + ("DELETE", "/failover/overrides?target=Main&expected_etag=x", Vec::new()), + ("POST", "/failover/overrides/reassign", reassign), + ("POST", "/failover/apply?target=Main", Vec::new()), + ] { + let denied = router.clone().oneshot(json_request(method, uri, &reader, body.clone())?).await?; + assert_eq!(denied.status(), StatusCode::FORBIDDEN, "{method} {uri}"); + let allowed = router.clone().oneshot(json_request(method, uri, &writer, body)?).await?; + assert_ne!(allowed.status(), StatusCode::FORBIDDEN, "{method} {uri}"); + assert_ne!(allowed.status(), StatusCode::UNAUTHORIZED, "{method} {uri}"); + } + Ok(()) +} diff --git a/backend/app/src/api/endpoints/failover_api/write.rs b/backend/app/src/api/endpoints/failover_api/write.rs new file mode 100644 index 000000000..59ae39a62 --- /dev/null +++ b/backend/app/src/api/endpoints/failover_api/write.rs @@ -0,0 +1,145 @@ +//! Write routes of the failover group editor: save, reset and reassign a target's override section, +//! and start a playlist update for one target. + +use super::{ + enabled_target, error_response, json_error, path_string, respond, respond_with, ApiError, ApiResult, TargetQuery, +}; +use crate::api::{ + api_utils::failover_group_hot_apply::try_hot_apply, + endpoints::v1_api_playlist::{ + enqueue_manual_playlist_update, resolve_manual_playlist_update_targets, ManualUpdateEnqueueError, + QUEUE_BUSY_MESSAGE, + }, + model::{AppState, EventMessage}, +}; +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::Response, + routing::{post, put}, + Json, Router, +}; +use log::error; +use shared::model::{ + FailoverGroupConflictDto, FailoverGroupSaveResultDto, OperationRunAccepted, PlaylistUpdateRequestDto, + ReassignOverridesRequestDto, SaveOverridesRequestDto, +}; +use std::sync::Arc; +use tuliprox_core::model::TargetOverridesEntry; +use tuliprox_repository::{reassign_failover_section, write_failover_section, FailoverWriteError}; + +pub(super) fn write_routes() -> Router> { + Router::new() + .route("/failover/overrides", put(put_overrides).delete(delete_overrides)) + .route("/failover/overrides/reassign", post(post_reassign)) + .route("/failover/apply", post(post_apply)) +} + +#[derive(serde::Deserialize)] +struct DeleteQuery { + target: String, + expected_etag: String, +} + +impl From for ApiError { + fn from(err: FailoverWriteError) -> Self { + match err { + FailoverWriteError::Conflict { current_etag } => { + json_error(StatusCode::CONFLICT, &FailoverGroupConflictDto { current_etag }) + } + FailoverWriteError::Invalid(findings) => json_error(StatusCode::UNPROCESSABLE_ENTITY, &findings), + FailoverWriteError::Locked(message) => error_response(StatusCode::LOCKED, message), + FailoverWriteError::Io(err) => { + error!("Failed to write failover overrides: {err}"); + error_response(StatusCode::INTERNAL_SERVER_ERROR, err) + } + } + } +} + +fn notify_changed(state: &AppState, targets: Vec) { + state.event_manager.send_event(EventMessage::FailoverOverridesChanged { targets }); +} + +fn save_result(entry: Option<&TargetOverridesEntry>, hot_applied: bool) -> FailoverGroupSaveResultDto { + FailoverGroupSaveResultDto { + etag: entry.map(|entry| entry.etag.to_string()), + file: entry.map(|entry| path_string(&entry.file)), + hot_applied, + } +} + +async fn put_overrides( + State(state): State>, + Query(query): Query, + Json(request): Json, +) -> Response { + respond(save_overrides(&state, &query.target, request).await) +} + +/// Writes the section (an empty one removes it) and hot applies it when only order or policy changed. +async fn save_overrides( + state: &AppState, + target_name: &str, + request: SaveOverridesRequestDto, +) -> ApiResult { + let (target, _) = enabled_target(state, target_name)?; + let SaveOverridesRequestDto { expected_etag, overrides } = request; + let entry = + write_failover_section(&state.app_config, &target.name, expected_etag.as_deref(), Some(overrides)).await?; + let hot_applied = match &entry { + Some(entry) => try_hot_apply(state, &target.name, entry).await, + None => false, + }; + notify_changed(state, vec![target.name.clone()]); + Ok(save_result(entry.as_deref(), hot_applied)) +} + +async fn delete_overrides(State(state): State>, Query(query): Query) -> Response { + respond(reset_overrides(&state, query).await) +} + +/// Removes the whole section of a target, also one whose target no longer exists. +async fn reset_overrides(state: &AppState, query: DeleteQuery) -> ApiResult { + write_failover_section(&state.app_config, &query.target, Some(&query.expected_etag), None).await?; + notify_changed(state, vec![query.target]); + Ok(save_result(None, false)) +} + +async fn post_reassign( + State(state): State>, + Json(request): Json, +) -> Response { + respond(reassign_overrides(&state, request).await) +} + +/// Moves the section of `from` (typically an orphan) to the target `to`, which must have failover groups enabled. +async fn reassign_overrides( + state: &AppState, + request: ReassignOverridesRequestDto, +) -> ApiResult { + let ReassignOverridesRequestDto { from, to, expected_etag } = request; + enabled_target(state, &to)?; + let entry = reassign_failover_section(&state.app_config, &from, &to, &expected_etag).await?; + notify_changed(state, vec![from, to]); + Ok(save_result(Some(&entry), false)) +} + +async fn post_apply(State(state): State>, Query(query): Query) -> Response { + respond_with(StatusCode::ACCEPTED, apply_response(&state, &query.target)) +} + +/// Queues a manual playlist update (default policy) for exactly this target. +fn apply_response(state: &AppState, target_name: &str) -> ApiResult { + let (target, _) = enabled_target(state, target_name)?; + let request = PlaylistUpdateRequestDto { targets: vec![target.name.clone()], ..Default::default() }; + let targets = resolve_manual_playlist_update_targets(&state.app_config.sources.load(), &request) + .map_err(|err| error_response(StatusCode::BAD_REQUEST, err))?; + match enqueue_manual_playlist_update(&state.playlist_updates.manual_update_sender, Arc::new(targets), None) { + Ok(run_id) => Ok(OperationRunAccepted::playlist_update(run_id)), + Err(ManualUpdateEnqueueError::Busy) => Err(error_response(StatusCode::CONFLICT, QUEUE_BUSY_MESSAGE)), + Err(ManualUpdateEnqueueError::Unavailable) => { + Err(error_response(StatusCode::SERVICE_UNAVAILABLE, "Playlist update worker is not available")) + } + } +} 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 6d3750705..2b1ad61d0 100644 --- a/backend/app/src/api/endpoints/hls_api/segment/entry.rs +++ b/backend/app/src/api/endpoints/hls_api/segment/entry.rs @@ -42,7 +42,7 @@ use tuliprox_hls::api::{ HlsSessionKey, }; use tuliprox_session::{ - channel_group::{FailureClass, HalfOpenGuard}, + failover_group::{FailureClass, HalfOpenGuard}, MemberBinding, }; diff --git a/backend/app/src/api/endpoints/hls_api/tests.rs b/backend/app/src/api/endpoints/hls_api/tests.rs index 0a005f5f6..3071701ba 100644 --- a/backend/app/src/api/endpoints/hls_api/tests.rs +++ b/backend/app/src/api/endpoints/hls_api/tests.rs @@ -139,4 +139,4 @@ mod terminal_tail_terminal_cutover; mod terminal_tail_terminal_fallback; mod terminal_tail_terminal_preflight; -mod channel_group; +mod failover_group; diff --git a/backend/app/src/api/endpoints/hls_api/tests/channel_group.rs b/backend/app/src/api/endpoints/hls_api/tests/failover_group.rs similarity index 100% rename from backend/app/src/api/endpoints/hls_api/tests/channel_group.rs rename to backend/app/src/api/endpoints/hls_api/tests/failover_group.rs 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 5a678d435..605d51ce1 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 @@ -53,6 +53,7 @@ pub(in crate::api::endpoints::hls_api::tests) fn test_app_config() -> Arc Arc { 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 } + PlaybackSource::Group { group, mode: tuliprox_session::failover_group::SelectionMode::Live, prefer } } tuliprox_repository::target_stream_item::ResolvedStreamTarget::Member { group, index } => { PlaybackSource::Group { group, - mode: tuliprox_session::channel_group::SelectionMode::Live, + mode: tuliprox_session::failover_group::SelectionMode::Live, prefer: Some(index), } } @@ -837,13 +837,13 @@ async fn m3u_api_stream( let source = match resolved_target { tuliprox_repository::target_stream_item::ResolvedStreamTarget::Group(group) => Some(PlaybackSource::Group { group, - mode: tuliprox_session::channel_group::SelectionMode::Live, + mode: tuliprox_session::failover_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, + mode: tuliprox_session::failover_group::SelectionMode::Live, prefer: Some(index), }) } diff --git a/backend/app/src/api/endpoints/mod.rs b/backend/app/src/api/endpoints/mod.rs index 0348c28e4..959c7281d 100644 --- a/backend/app/src/api/endpoints/mod.rs +++ b/backend/app/src/api/endpoints/mod.rs @@ -2,6 +2,7 @@ mod api_playlist_utils; pub(in crate::api) mod custom_video_stream_api; pub(in crate::api) mod epg_grid_api; mod extract_accept_header; +pub(in crate::api) mod failover_api; pub(in crate::api) mod hdhomerun_api; pub(in crate::api) mod hls_api; mod hls_terminal_response; diff --git a/backend/app/src/api/endpoints/target_bouquet_api.rs b/backend/app/src/api/endpoints/target_bouquet_api.rs index 18069cb7e..4f5af5625 100644 --- a/backend/app/src/api/endpoints/target_bouquet_api.rs +++ b/backend/app/src/api/endpoints/target_bouquet_api.rs @@ -69,8 +69,7 @@ fn resolve_target_with_inputs( async fn target_summary(state: &AppState, target_name: &str) -> Result, TuliproxError> { let sources = state.app_config.sources.load(); - let target = - sources.sources.iter().flat_map(|source| &source.targets).find(|target| target.name == target_name).cloned(); + let target = sources.get_target_by_name(target_name).cloned(); drop(sources); let Some(target) = target else { return Ok(None) }; let bouquet = tuliprox_repository::load_target_bouquet(&state.app_config, &target.name).await?; @@ -273,9 +272,7 @@ async fn save_target_bouquet_handler( Json(bouquet): Json, ) -> Response { let sources = app_state.app_config.sources.load(); - let Some(target) = - sources.sources.iter().flat_map(|source| &source.targets).find(|target| target.name == query.target) - else { + let Some(target) = sources.get_target_by_name(&query.target) else { return ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": format!("Target '{}' not found", query.target) })), @@ -296,9 +293,7 @@ async fn delete_target_bouquet_handler( Query(query): Query, ) -> Response { let sources = app_state.app_config.sources.load(); - let Some(target) = - sources.sources.iter().flat_map(|source| &source.targets).find(|target| target.name == query.target) - else { + let Some(target) = sources.get_target_by_name(&query.target) else { return ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": format!("Target '{}' not found", query.target) })), @@ -372,6 +367,7 @@ mod tests { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })); diff --git a/backend/app/src/api/endpoints/v1_api.rs b/backend/app/src/api/endpoints/v1_api.rs index 08cef8ffc..8e98476cf 100644 --- a/backend/app/src/api/endpoints/v1_api.rs +++ b/backend/app/src/api/endpoints/v1_api.rs @@ -4,6 +4,7 @@ use crate::{ auth_middleware::permission_layer, endpoints::{ extract_accept_header::ExtractAcceptHeader, + failover_api::{failover_api_register_unprotected, failover_api_register_with_permissions}, library_api::library_api_register, rbac_api::{rbac_api_register, rbac_api_register_unprotected}, recording_api::{recording_api_register, recording_availability_register, recording_enabled_layer}, @@ -199,6 +200,7 @@ pub fn v1_api_register( .merge(v1_api_playlist_register_with_permissions(axum::routing::Router::new(), app_state)) .merge(library_api_register(axum::routing::Router::new(), Some(app_state))) .merge(target_bouquet_api_register_with_permissions(app_state)) + .merge(failover_api_register_with_permissions(app_state)) .merge(super::maintenance_api::maintenance_api_register_with_permissions(app_state)) .merge(rbac_api_register(Arc::clone(app_state))) .merge(recording_availability_register(axum::routing::Router::new())) @@ -220,6 +222,7 @@ pub fn v1_api_register( .merge(v1_api_playlist_register_protected(axum::routing::Router::new())) .merge(library_api_register(axum::routing::Router::new(), None)) .merge(target_bouquet_api_register_unprotected()) + .merge(failover_api_register_unprotected()) .merge(super::maintenance_api::maintenance_api_register_unprotected()) .merge(rbac_api_register_unprotected(Arc::clone(app_state))) .merge(recording_availability_register(axum::routing::Router::new())) diff --git a/backend/app/src/api/endpoints/v1_api_config.rs b/backend/app/src/api/endpoints/v1_api_config.rs index 9bc148e40..f035bf822 100644 --- a/backend/app/src/api/endpoints/v1_api_config.rs +++ b/backend/app/src/api/endpoints/v1_api_config.rs @@ -1421,6 +1421,7 @@ mod tests { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })); diff --git a/backend/app/src/api/endpoints/v1_api_playlist.rs b/backend/app/src/api/endpoints/v1_api_playlist.rs index d2b213909..163baf116 100644 --- a/backend/app/src/api/endpoints/v1_api_playlist.rs +++ b/backend/app/src/api/endpoints/v1_api_playlist.rs @@ -69,9 +69,6 @@ use self::recording::epg_channel_id_matches; use self::recording::select_epg_channel_candidate; #[cfg(test)] use self::recording::RecordingStreamQuery; -pub(in crate::api) use self::recording::{ - build_webplayer_recording_url, resolve_target_live_recording_source_by_epg_channel, resolve_target_recording_source, -}; #[cfg(test)] use self::recording::{recording_candidate, RecordingCandidateFields}; #[cfg(test)] @@ -81,12 +78,9 @@ pub use self::routes::{ }; #[cfg(test)] use self::update::read_playlist_update_input_status; -#[cfg(test)] -use self::update::resolve_manual_playlist_update_targets; use self::{ content::{playlist_content, playlist_episode_item, playlist_series_info}, epg::playlist_epg, - error::ManualUpdateEnqueueError, filter_preview::playlist_filter_preview, recording::playlist_recording_stream, resources::{ @@ -96,3 +90,11 @@ use self::{ update::{playlist_update, playlist_update_status}, webplayer::{build_playlist_webplayer_url, playlist_webplayer, playlist_webplayer_stream}, }; +pub(in crate::api) use self::{ + error::{ManualUpdateEnqueueError, QUEUE_BUSY_MESSAGE}, + recording::{ + build_webplayer_recording_url, resolve_target_live_recording_source_by_epg_channel, + resolve_target_recording_source, + }, + update::{enqueue_manual_playlist_update, resolve_manual_playlist_update_targets}, +}; diff --git a/backend/app/src/api/endpoints/v1_api_playlist/error.rs b/backend/app/src/api/endpoints/v1_api_playlist/error.rs index a1935ae8e..1c44d66d5 100644 --- a/backend/app/src/api/endpoints/v1_api_playlist/error.rs +++ b/backend/app/src/api/endpoints/v1_api_playlist/error.rs @@ -1,5 +1,8 @@ +/// Answer text of a manual playlist update that finds the queue full. +pub(in crate::api) const QUEUE_BUSY_MESSAGE: &str = "Another playlist update is already queued; retry this request"; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum ManualUpdateEnqueueError { +pub(in crate::api) enum ManualUpdateEnqueueError { Busy, Unavailable, } 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 8ae54d9bc..4f97b4124 100644 --- a/backend/app/src/api/endpoints/v1_api_playlist/recording.rs +++ b/backend/app/src/api/endpoints/v1_api_playlist/recording.rs @@ -134,14 +134,7 @@ pub(in crate::api) async fn resolve_target_recording_source( /// `SourcesConfigDto::prepare` rejects duplicate target names across sources. fn find_target_by_name(app_config: &crate::model::AppConfig, target_name: &str) -> Option> { - app_config - .sources - .load() - .sources - .iter() - .flat_map(|source| &source.targets) - .find(|target| target.name == target_name) - .cloned() + app_config.sources.load().get_target_by_name(target_name).cloned() } /// Whether `input_name` belongs to the source that configures `target`. 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 17d6124a3..a4e8f67b9 100644 --- a/backend/app/src/api/endpoints/v1_api_playlist/tests.rs +++ b/backend/app/src/api/endpoints/v1_api_playlist/tests.rs @@ -17,4 +17,4 @@ mod behavior_epg_preview; mod behavior_manual_update; mod behavior_provider_resolution; mod behavior_recording_routes; -mod channel_groups; +mod failover_groups; diff --git a/backend/app/src/api/endpoints/v1_api_playlist/tests/behavior_provider_resolution.rs b/backend/app/src/api/endpoints/v1_api_playlist/tests/behavior_provider_resolution.rs index 5c62d24ef..274ada0a7 100644 --- a/backend/app/src/api/endpoints/v1_api_playlist/tests/behavior_provider_resolution.rs +++ b/backend/app/src/api/endpoints/v1_api_playlist/tests/behavior_provider_resolution.rs @@ -224,6 +224,7 @@ fn resolve_provider_url_passthrough_for_ambiguous_target_request() { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -231,6 +232,7 @@ fn resolve_provider_url_passthrough_for_ambiguous_target_request() { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(crate::model::MediaToolCapabilities::default()), + failover: Arc::default(), }; let original = "provider://shared/live/user/pass/1359.ts"; 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/failover_groups.rs similarity index 98% rename from backend/app/src/api/endpoints/v1_api_playlist/tests/channel_groups.rs rename to backend/app/src/api/endpoints/v1_api_playlist/tests/failover_groups.rs index 950bddbbb..70aed7ad7 100644 --- a/backend/app/src/api/endpoints/v1_api_playlist/tests/channel_groups.rs +++ b/backend/app/src/api/endpoints/v1_api_playlist/tests/failover_groups.rs @@ -22,7 +22,7 @@ fn test_epg(channel_id: &str, title: &str, programme_title: &str) -> Epg { } #[tokio::test] -async fn channel_groups_outputs_and_epg_sanity() { +async fn failover_groups_outputs_and_epg_sanity() { let epg = test_epg("zdf.de", "ZDF", "Heute Journal"); let env = GroupPlaybackEnv::builder(&["solis", "korath"]) .without_plain_channel() 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 713661d39..36f804401 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 @@ -100,6 +100,7 @@ pub(in crate::api::endpoints::v1_api_playlist::tests) fn test_app_config( mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -107,6 +108,7 @@ pub(in crate::api::endpoints::v1_api_playlist::tests) fn test_app_config( access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(crate::model::MediaToolCapabilities::default()), + failover: Arc::default(), } } @@ -145,7 +147,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(), + failover_groups: tokio_util::sync::CancellationToken::new(), }; let metadata_manager = Arc::new(MetadataUpdateManager::new(tokens.metadata.clone())); diff --git a/backend/app/src/api/endpoints/v1_api_playlist/update.rs b/backend/app/src/api/endpoints/v1_api_playlist/update.rs index 5c2611cca..d34cdb4d2 100644 --- a/backend/app/src/api/endpoints/v1_api_playlist/update.rs +++ b/backend/app/src/api/endpoints/v1_api_playlist/update.rs @@ -1,4 +1,4 @@ -use super::{ManualUpdateEnqueueError, PLAYLIST_UPDATE_STATUS_READ_CONCURRENCY}; +use super::{error::QUEUE_BUSY_MESSAGE, ManualUpdateEnqueueError, PLAYLIST_UPDATE_STATUS_READ_CONCURRENCY}; use crate::{ api::{ auth_middleware::{check_permission, VerifiedClaims}, @@ -21,7 +21,7 @@ use shared::{ }; use std::sync::Arc; -fn enqueue_manual_playlist_update( +pub(in crate::api) fn enqueue_manual_playlist_update( sender: &tokio::sync::mpsc::Sender, targets: Arc, input_action: Option, @@ -77,11 +77,7 @@ pub(super) async fn playlist_update( } Err(ManualUpdateEnqueueError::Busy) => { debug!("Manual playlist update rejected: another update is already queued"); - ( - axum::http::StatusCode::CONFLICT, - axum::Json(json!({"error": "Another playlist update is already queued; retry this request"})), - ) - .into_response() + (axum::http::StatusCode::CONFLICT, axum::Json(json!({"error": QUEUE_BUSY_MESSAGE}))).into_response() } Err(ManualUpdateEnqueueError::Unavailable) => { debug!("Manual playlist update rejected: worker channel closed (server shutting down)"); @@ -96,7 +92,7 @@ pub(super) async fn playlist_update( } } -pub(super) fn resolve_manual_playlist_update_targets( +pub(in crate::api) fn resolve_manual_playlist_update_targets( sources: &SourcesConfig, request: &PlaylistUpdateRequestDto, ) -> Result { diff --git a/backend/app/src/api/endpoints/websocket_api.rs b/backend/app/src/api/endpoints/websocket_api.rs index c1a00e16b..d766aef2d 100644 --- a/backend/app/src/api/endpoints/websocket_api.rs +++ b/backend/app/src/api/endpoints/websocket_api.rs @@ -478,6 +478,7 @@ fn to_protocol_message(event: EventMessage) -> Option<(ProtocolMessage, &'static EventMessage::PlaylistUpdate(summary) => { ( ProtocolMessage::PlaylistUpdateResponse(PlaylistUpdateRunStateEvent { + targets: summary.processed_targets().map(str::to_string).collect(), run_id: summary.run_id, execution_order: summary.execution_order, state: summary.state, @@ -502,6 +503,9 @@ fn to_protocol_message(event: EventMessage) -> Option<(ProtocolMessage, &'static ProtocolMessage::MaintenanceChangedResponse(unwrap_or_clone(entries)), "Input maintenance changed", ), + EventMessage::FailoverOverridesChanged { targets } => { + (ProtocolMessage::FailoverOverridesChanged(targets), "Failover group overrides changed") + } // Handled by `handle_event_message`, not translatable here: // `RecordingChanged` needs a per-session snapshot re-fetch, and the @@ -541,10 +545,10 @@ fn to_protocol_message(event: EventMessage) -> Option<(ProtocolMessage, &'static // Web UI socket: there is no panel that renders them, and pushing // every sign-in to every connected admin is noise. | EventMessage::AuthAudit(_) - | EventMessage::ChannelGroupMemberSelected { .. } - | EventMessage::ChannelGroupExhausted { .. } - | EventMessage::ChannelGroupCircuitChanged { .. } - | EventMessage::ChannelGroupMemberSwitched { .. } => return None, + | EventMessage::FailoverGroupMemberSelected { .. } + | EventMessage::FailoverGroupExhausted { .. } + | EventMessage::FailoverGroupCircuitChanged { .. } + | EventMessage::FailoverGroupMemberSwitched { .. } => return None, }) } @@ -781,10 +785,10 @@ mod tests { EventKind::AuthSignInFailed, EventKind::AuthSignInThrottled, EventKind::AuthPermissionDenied, - EventKind::ChannelGroupMemberSelected, - EventKind::ChannelGroupExhausted, - EventKind::ChannelGroupCircuitChanged, - EventKind::ChannelGroupMemberSwitched, + EventKind::FailoverGroupMemberSelected, + EventKind::FailoverGroupExhausted, + EventKind::FailoverGroupCircuitChanged, + EventKind::FailoverGroupMemberSwitched, ]; for (event, kind) in sample_event_of_every_kind() { @@ -813,6 +817,37 @@ mod tests { assert_eq!(terminal.state, PlaylistUpdateState::Partial); } + #[test] + fn playlist_update_run_websocket_terminal_names_the_processed_targets() { + use shared::model::{SourceStats, TargetStats}; + let mut summary = PlaylistUpdateSummary::for_run( + PlaylistUpdateRunId::from("websocket-run"), + PlaylistUpdateRunOrder::from(30), + PlaylistUpdateState::Success, + ); + summary.stats = vec![ + SourceStats { + inputs: Vec::new(), + targets: vec![TargetStats::success("Main TV"), TargetStats::failure("Kids")], + }, + SourceStats { inputs: Vec::new(), targets: vec![TargetStats::success("Sport")] }, + ]; + let mapped = to_protocol_message(EventMessage::PlaylistUpdate(summary)).expect("wire mapped"); + let ProtocolMessage::PlaylistUpdateResponse(terminal) = mapped.0 else { + panic!("expected playlist update terminal response"); + }; + assert_eq!(terminal.targets, vec!["Main TV".to_string(), "Kids".to_string(), "Sport".to_string()]); + + let bare = to_protocol_message(EventMessage::PlaylistUpdate(PlaylistUpdateSummary::state_only( + PlaylistUpdateState::Success, + ))) + .expect("wire mapped"); + let ProtocolMessage::PlaylistUpdateResponse(terminal) = bare.0 else { + panic!("expected playlist update terminal response"); + }; + assert!(terminal.targets.is_empty()); + } + fn provider_fetch_failure() -> ProviderFetchFailure { ProviderFetchFailure { input: "i".into(), @@ -949,25 +984,26 @@ mod tests { auth_audit(AuthAuditOutcome::SignInThrottled), auth_audit(AuthAuditOutcome::PermissionDenied), EventMessage::MaintenanceChanged(Arc::default()), - EventMessage::ChannelGroupMemberSelected { + EventMessage::FailoverGroupMemberSelected { target_id: 1, group_vid: 10, member_vid: 11, - tier: shared::model::channel_group::CapacityTier::Free, + tier: shared::model::failover_group::CapacityTier::Free, }, - EventMessage::ChannelGroupExhausted { target_id: 1, group_vid: 10 }, - EventMessage::ChannelGroupMemberSwitched { + EventMessage::FailoverGroupExhausted { target_id: 1, group_vid: 10 }, + EventMessage::FailoverGroupMemberSwitched { target_id: 1, group_vid: 10, from_member_vid: 11, to_member_vid: 12, - reason: shared::model::channel_group::SwitchReason::UpstreamEof, + reason: shared::model::failover_group::SwitchReason::UpstreamEof, }, - EventMessage::ChannelGroupCircuitChanged { + EventMessage::FailoverGroupCircuitChanged { input_name: "test".into(), input_stream_id: "1".into(), - state: shared::model::channel_group::CircuitState::Closed, + state: shared::model::failover_group::CircuitState::Closed, }, + EventMessage::FailoverOverridesChanged { targets: vec!["main".to_string()] }, ]; 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 a09bb10b5..2626438a9 100644 --- a/backend/app/src/api/endpoints/xtream_api/mod.rs +++ b/backend/app/src/api/endpoints/xtream_api/mod.rs @@ -760,14 +760,14 @@ async fn xtream_player_api_stream( .map(|b| b.member_index); PlaybackSource::Group { group, - mode: tuliprox_session::channel_group::SelectionMode::Live, + mode: tuliprox_session::failover_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, + mode: tuliprox_session::failover_group::SelectionMode::Live, prefer: Some(index), } } diff --git a/backend/app/src/api/main_api.rs b/backend/app/src/api/main_api.rs index a81360c9e..400cff047 100644 --- a/backend/app/src/api/main_api.rs +++ b/backend/app/src/api/main_api.rs @@ -30,7 +30,7 @@ use crate::{ serve::serve, sys_usage::exec_system_usage, tasks::{ - exec_channel_group_member_keys, exec_config_watch, exec_interner_prune, exec_scheduler, + exec_config_watch, exec_failover_group_member_keys, exec_interner_prune, exec_scheduler, exec_xtream_expiry_sync, spawn_ssdp_discover_task, }, }, @@ -413,7 +413,7 @@ 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), + tuliprox_session::failover_group::CircuitPolicy::from(&config.failover_groups.circuit), )); event_manager.bind_member_health(&member_health); member_health.set_event_sink(Arc::clone(&event_manager) as Arc); @@ -500,7 +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(); + cancel_tokens.failover_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. @@ -756,7 +756,7 @@ pub async fn start_server(app_config: Arc, targets: Arc Self { @@ -23,7 +23,7 @@ impl Default for CancelTokens { qos_aggregation: CancellationToken::new(), recordings: CancellationToken::new(), hls_cache: CancellationToken::new(), - channel_groups: CancellationToken::new(), + failover_groups: CancellationToken::new(), } } } diff --git a/backend/app/src/api/model/app_state/change_detection.rs b/backend/app/src/api/model/app_state/change_detection.rs index 9d49227ee..9d9127927 100644 --- a/backend/app/src/api/model/app_state/change_detection.rs +++ b/backend/app/src/api/model/app_state/change_detection.rs @@ -79,7 +79,8 @@ impl AppState { change_detect!(hdhomerun_changed, old_config.hdhomerun.as_ref(), config.hdhomerun.as_ref()); let changed_file_watch = change_detect!(string_changed, old_config.mapping_path.as_ref(), config.mapping_path.as_ref()) - || change_detect!(string_changed, old_config.template_path.as_ref(), config.template_path.as_ref()); + || change_detect!(string_changed, old_config.template_path.as_ref(), config.template_path.as_ref()) + || change_detect!(string_changed, old_config.failover_path.as_ref(), config.failover_path.as_ref()); let geoip_enabled = config.is_geoip_enabled(); let geoip_enabled_old = old_config.is_geoip_enabled(); diff --git a/backend/app/src/api/model/app_state/mod.rs b/backend/app/src/api/model/app_state/mod.rs index 0fe2d4e0a..fd5a683f4 100644 --- a/backend/app/src/api/model/app_state/mod.rs +++ b/backend/app/src/api/model/app_state/mod.rs @@ -31,7 +31,9 @@ mod tests; mod view; #[cfg(test)] -pub use self::test_support::{create_test_app_state, create_test_maintenance}; +pub use self::test_support::{ + create_test_app_state, create_test_app_state_with_manual_update_sender, create_test_maintenance, +}; pub use self::{ auth_state::*, cancel_tokens::*, hls_state::*, http_clients::*, playlist_update_control::*, reload::*, resource_cache::*, view::*, diff --git a/backend/app/src/api/model/app_state/reload.rs b/backend/app/src/api/model/app_state/reload.rs index 478fe079b..ba7458e45 100644 --- a/backend/app/src/api/model/app_state/reload.rs +++ b/backend/app/src/api/model/app_state/reload.rs @@ -133,7 +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(), + failover_groups: cancel_tokens.failover_groups.clone(), }; app_state.cancel_tokens.store(Arc::new(tokens)); @@ -222,7 +222,7 @@ impl AppState { self.active_users.update_config(&config); self.member_health - .set_policy(tuliprox_session::channel_group::CircuitPolicy::from(&config.channel_groups.circuit)); + .set_policy(tuliprox_session::failover_group::CircuitPolicy::from(&config.failover_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 1b12a3205..54eb1128a 100644 --- a/backend/app/src/api/model/app_state/test_support.rs +++ b/backend/app/src/api/model/app_state/test_support.rs @@ -13,6 +13,33 @@ use tuliprox_hls::api::HlsProxyManager; use tuliprox_metadata::manager::MetadataUpdateManager; pub fn create_test_app_state(config: Config) -> Arc { + create_test_app_state_with_playlist_updates(config, PlaylistUpdateControl::for_tests()) +} + +/// Like [`create_test_app_state`], with a manual update channel whose receiver the test keeps. +pub fn create_test_app_state_with_manual_update_sender( + config: Config, + manual_update_sender: tokio::sync::mpsc::Sender, +) -> Arc { + create_test_app_state_with_playlist_updates( + config, + PlaylistUpdateControl::for_tests_with_sender(manual_update_sender), + ) +} + +/// `Config::update_runtime` points the process-wide temp dir at the storage dir of the loaded config. A test that +/// reloads the config would leave every later `tempfile` of the test process pointing into its own, already removed, +/// storage dir. Every test state is created through this module, so the override is switched off before the first one. +fn disable_temp_dir_override() { + static DISABLED: std::sync::Once = std::sync::Once::new(); + DISABLED.call_once(|| std::env::set_var("TULIPROX_DISABLE_TEMP_DIR_OVERRIDE", "1")); +} + +fn create_test_app_state_with_playlist_updates( + config: Config, + playlist_updates: PlaylistUpdateControl, +) -> Arc { + disable_temp_dir_override(); let app_config = Arc::new(AppConfig { config: Arc::new(ArcSwap::from_pointee(config)), sources: Arc::new(ArcSwap::from_pointee(SourcesConfig::default())), @@ -29,6 +56,7 @@ pub fn create_test_app_state(config: Config) -> Arc { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -36,6 +64,7 @@ pub fn create_test_app_state(config: Config) -> Arc { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(crate::model::MediaToolCapabilities::new()), + failover: Arc::default(), }); let event_manager = Arc::new(EventManager::new()); let active_provider = Arc::new(ActiveProviderManager::new(&app_config, &event_manager)); @@ -86,7 +115,7 @@ pub fn create_test_app_state(config: Config) -> Arc { geoip, metadata_manager, auth: AuthState::for_tests(), - playlist_updates: PlaylistUpdateControl::for_tests(), + playlist_updates, 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 e18becb77..5fa2af9aa 100644 --- a/backend/app/src/api/model/app_state/tests.rs +++ b/backend/app/src/api/model/app_state/tests.rs @@ -278,17 +278,20 @@ async fn contactable_input_hides_inputs_in_maintenance() { } #[tokio::test] -async fn config_reload_applies_channel_group_circuit_settings() { +async fn config_reload_applies_failover_group_circuit_settings() { let state = super::create_test_app_state(Config::default()); - let circuit = shared::model::ChannelGroupCircuitConfig { + let circuit = shared::model::FailoverGroupCircuitConfig { source_failures: 5, - ..shared::model::ChannelGroupCircuitConfig::default() + ..shared::model::FailoverGroupCircuitConfig::default() }; let config = Config { - channel_groups: shared::model::ChannelGroupsConfig { circuit, ..shared::model::ChannelGroupsConfig::default() }, + failover_groups: shared::model::FailoverGroupsConfig { + circuit, + ..shared::model::FailoverGroupsConfig::default() + }, ..Config::default() }; assert!(state.set_config(config).await.is_ok()); - assert_eq!(*state.member_health.policy(), tuliprox_session::channel_group::CircuitPolicy::from(&circuit)); + assert_eq!(*state.member_health.policy(), tuliprox_session::failover_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 80e9d31ae..6c555d2aa 100644 --- a/backend/app/src/api/model/streams/active_client_stream.rs +++ b/backend/app/src/api/model/streams/active_client_stream.rs @@ -25,7 +25,7 @@ pub(crate) struct ActiveClientStreamParams<'a> { 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 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 18369936c..90512d0bd 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 @@ -6,7 +6,7 @@ use super::{ }; use crate::{ api::{ - api_utils::channel_group_failover::GroupSwitcher, + api_utils::failover_group_runtime::GroupSwitcher, model::{ connection_manager::PROVIDER_END_NOT_SET, AppState, BoxedProviderStream, ConnectionManager, EventManager, MeteringStream, StreamDetails, StreamMeterHandle, 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 087479b4b..c593b2f27 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 @@ -3,7 +3,7 @@ use super::{ TimedStreamContext, }; use crate::api::{ - api_utils::channel_group_failover::{GroupEnd, GroupPoll, MemberHost, RuntimeMember}, + api_utils::failover_group_runtime::{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, 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 4d1fca848..caeb9c1ec 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 @@ -57,6 +57,7 @@ pub(in crate::api::model::streams::active_client_stream::tests) fn create_test_a mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -64,6 +65,7 @@ pub(in crate::api::model::streams::active_client_stream::tests) fn create_test_a access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), } } diff --git a/backend/app/src/api/model/streams/provider_stream.rs b/backend/app/src/api/model/streams/provider_stream.rs index 523705ff5..a4a5f3b23 100644 --- a/backend/app/src/api/model/streams/provider_stream.rs +++ b/backend/app/src/api/model/streams/provider_stream.rs @@ -316,6 +316,7 @@ mod tests { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -323,6 +324,7 @@ mod tests { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), }; let mut ts_packet = vec![0_u8; 188]; diff --git a/backend/app/src/api/model/streams/provider_stream_factory/tests.rs b/backend/app/src/api/model/streams/provider_stream_factory/tests.rs index 0d4b48649..0ad29999d 100644 --- a/backend/app/src/api/model/streams/provider_stream_factory/tests.rs +++ b/backend/app/src/api/model/streams/provider_stream_factory/tests.rs @@ -58,6 +58,7 @@ fn test_app_config() -> Arc { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -65,6 +66,7 @@ fn test_app_config() -> Arc { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), }) } diff --git a/backend/app/src/api/source_yml_patch.rs b/backend/app/src/api/source_yml_patch.rs index ccdbbe5b3..b306e0828 100644 --- a/backend/app/src/api/source_yml_patch.rs +++ b/backend/app/src/api/source_yml_patch.rs @@ -1370,6 +1370,7 @@ mod tests { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -1377,6 +1378,7 @@ mod tests { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), }) } diff --git a/backend/app/src/api/tasks/config_watch.rs b/backend/app/src/api/tasks/config_watch.rs index ad1215b46..e1d072ba9 100644 --- a/backend/app/src/api/tasks/config_watch.rs +++ b/backend/app/src/api/tasks/config_watch.rs @@ -8,7 +8,7 @@ use crate::{ utils::is_directory, }; use arc_swap::{access::Access, ArcSwap}; -use log::{error, info}; +use log::{error, info, warn}; use notify::{ event::{AccessKind, AccessMode}, recommended_watcher, EventKind, RecursiveMode, Watcher, @@ -24,6 +24,7 @@ use std::{ sync::Arc, }; use tokio_util::sync::CancellationToken; +use tuliprox_core::utils::FailoverSource; #[allow(clippy::too_many_lines)] fn start_config_watch(app_state: &Arc, cancel_token: &CancellationToken) -> Result<(), TuliproxError> { @@ -34,7 +35,14 @@ fn start_config_watch(app_state: &Arc, cancel_token: &CancellationToke let paths = > as Access>::load(&app_state.app_config.paths); let mapping_file_path = paths.mapping_file_path.as_ref().map_or_else(String::new, ToString::to_string); let template_file_path = paths.template_file_path.as_ref().map_or_else(String::new, ToString::to_string); - let files = get_watch_files(app_state, &paths, mapping_file_path.as_str(), template_file_path.as_str()); + let failover_source = app_state.app_config.failover.load_full().source.clone(); + let files = get_watch_files( + app_state, + &paths, + mapping_file_path.as_str(), + template_file_path.as_str(), + failover_source.as_ref(), + ); // // // Add a path to be watched. All files and directories at that path and // // below will be monitored for changes. @@ -44,6 +52,7 @@ fn start_config_watch(app_state: &Arc, cancel_token: &CancellationToke let settings_root = normalized_watch_path(path, &working_dir).join("user_settings"); let recursive_mode = if (!mapping_file_path.is_empty() && utils::is_directory(&mapping_file_path)) || (!template_file_path.is_empty() && utils::is_directory(&template_file_path)) + || failover_source.as_ref().is_some_and(FailoverSource::is_directory) { RecursiveMode::Recursive } else { @@ -66,6 +75,14 @@ fn start_config_watch(app_state: &Arc, cancel_token: &CancellationToke .watch(path, recursive_mode) .map_err(|err| TuliproxError::Io(format!("Failed to start config file watcher {err}")))?; info!("Watching config file changes {}", path.display()); + if let Some((extra_path, extra_mode)) = + failover_source.as_ref().and_then(|source| extra_failover_watch(source, path, recursive_mode, &working_dir)) + { + match watcher.watch(&extra_path, extra_mode) { + Ok(()) => info!("Watching failover file changes {}", extra_path.display()), + Err(err) => warn!("Failed to watch failover path {}: {err}", extra_path.display()), + } + } let event_manager = Arc::clone(&app_state.event_manager); let cancel = cancel_token.clone(); @@ -119,19 +136,7 @@ fn start_config_watch(app_state: &Arc, cancel_token: &CancellationToke let mut trigger = false; for path in event.paths { if is_settings_path(&path, &settings_root, &working_dir) { continue; } - let mut resolved = None; - if let Some((config_file, _is_dir)) = files.get(&path) { - resolved = Some(*config_file); - } else if recursive_mode == RecursiveMode::Recursive && path.extension().is_some_and(|ext| ext == "yml") { - for (key, (config_file, is_dir)) in &files { - if *is_dir && path.starts_with(key) { - resolved = Some(*config_file); - break; - } - } - } - - if let Some(config_file) = resolved { + if let Some(config_file) = resolve_watched_config_file(&path, &files) { // Consolidate updates let effective = if config_file == ConfigFile::SourceFile { ConfigFile::Sources } else { config_file }; pending_configs.entry(effective).or_default().insert(path); @@ -153,8 +158,8 @@ fn start_config_watch(app_state: &Arc, cancel_token: &CancellationToke timer_active = false; if !pending_configs.is_empty() { for (config_file, paths) in pending_configs.drain() { - if config_file == ConfigFile::Sources - && !has_external_source_write(&watcher_app_state.app_config.file_locks, &paths).await + if skips_internal_writes(config_file) + && !has_external_write(&watcher_app_state.app_config.file_locks, &paths).await { continue; } @@ -175,7 +180,53 @@ fn start_config_watch(app_state: &Arc, cancel_token: &CancellationToke Ok(()) } -async fn has_external_source_write(file_locks: &crate::utils::FileLockManager, paths: &HashSet) -> bool { +/// Files the server rewrites itself and records via `mark_internal_write_content`. +const fn skips_internal_writes(config_file: ConfigFile) -> bool { + matches!(config_file, ConfigFile::Sources | ConfigFile::Failover) +} + +/// Maps a changed path to the config file it belongs to: an exact match, or a `.yml` / `.yaml` file +/// below a watched directory (mapping, template or failover directory). +fn resolve_watched_config_file(path: &Path, files: &HashMap) -> Option { + if let Some((config_file, _is_dir)) = files.get(path) { + return Some(*config_file); + } + if !path.extension().is_some_and(|ext| ext == "yml" || ext == "yaml") { + return None; + } + files.iter().find(|(key, (_, is_dir))| *is_dir && path.starts_with(key)).map(|(_, (config_file, _))| *config_file) +} + +/// Where an additional watch is needed so the failover files are observed, if the config directory watch does not +/// already cover them. A file needs its parent directory, a directory is watched recursively. +fn extra_failover_watch( + source: &FailoverSource, + config_dir: &Path, + config_mode: RecursiveMode, + working_dir: &Path, +) -> Option<(PathBuf, RecursiveMode)> { + let path = source.path(); + let config_dir = normalized_watch_path(config_dir, working_dir); + let absolute = normalized_watch_path(path, working_dir); + if source.is_directory() { + // The config watch is recursive whenever the failover source is a directory. + return if absolute.starts_with(&config_dir) { + None + } else { + Some((path.to_path_buf(), RecursiveMode::Recursive)) + }; + } + let parent = absolute.parent(); + let covered = parent == Some(config_dir.as_ref()) + || (config_mode == RecursiveMode::Recursive && absolute.starts_with(&config_dir)); + if covered { + return None; + } + let watch_parent = path.parent().filter(|parent| !parent.as_os_str().is_empty()).unwrap_or_else(|| Path::new(".")); + Some((watch_parent.to_path_buf(), RecursiveMode::NonRecursive)) +} + +async fn has_external_write(file_locks: &crate::utils::FileLockManager, paths: &HashSet) -> bool { for path in paths { let _guard = file_locks.read_lock(path).await; if !file_locks.is_internal_write_revision(path).await { @@ -196,6 +247,7 @@ fn get_watch_files( paths: &ConfigPaths, mapping_file_path: &str, template_file_path: &str, + failover_source: Option<&FailoverSource>, ) -> HashMap { let sources = > as Access>::load(&app_state.app_config.sources); let input_files_paths = sources.get_input_files(); @@ -215,6 +267,9 @@ fn get_watch_files( for path in input_files_paths { files.insert(path, (ConfigFile::SourceFile, false)); } + if let Some(source) = failover_source { + files.insert(source.path().to_path_buf(), (ConfigFile::Failover, source.is_directory())); + } files } @@ -233,9 +288,97 @@ pub fn exec_config_watch(app_state: &Arc, cancel: &CancellationToken) #[cfg(test)] mod tests { - use super::{format_paths, has_external_source_write}; - use crate::utils::FileLockManager; - use std::{collections::HashSet, path::PathBuf}; + use super::{ + extra_failover_watch, format_paths, has_external_write, resolve_watched_config_file, skips_internal_writes, + }; + use crate::{api::config_file::ConfigFile, utils::FileLockManager}; + use notify::RecursiveMode; + use std::{ + collections::{HashMap, HashSet}, + path::{Path, PathBuf}, + }; + use tuliprox_core::utils::FailoverSource; + + fn watched_files() -> HashMap { + HashMap::from([ + (PathBuf::from("/c/config.yml"), (ConfigFile::Config, false)), + (PathBuf::from("/c/mapping.d"), (ConfigFile::Mapping, true)), + (PathBuf::from("/c/failover.d"), (ConfigFile::Failover, true)), + (PathBuf::from("/c/failover.yml"), (ConfigFile::Failover, false)), + ]) + } + + #[test] + fn yaml_extension_in_watched_dir_resolves_config_file() { + let files = watched_files(); + for (path, expected) in [ + ("/c/failover.d/a.yml", Some(ConfigFile::Failover)), + ("/c/failover.d/nested/b.yaml", Some(ConfigFile::Failover)), + ("/c/mapping.d/m.yaml", Some(ConfigFile::Mapping)), + ("/c/failover.d/notes.txt", None), + ("/c/failover.d/.a.yml.swp", None), + ("/c/elsewhere/a.yaml", None), + ("/c/config.yml", Some(ConfigFile::Config)), + ("/c/failover.yml", Some(ConfigFile::Failover)), + ] { + assert_eq!(resolve_watched_config_file(Path::new(path), &files), expected, "{path}"); + } + } + + #[test] + fn only_server_written_files_skip_internal_writes() { + assert!(skips_internal_writes(ConfigFile::Failover)); + assert!(skips_internal_writes(ConfigFile::Sources)); + assert!(!skips_internal_writes(ConfigFile::Mapping)); + assert!(!skips_internal_writes(ConfigFile::Config)); + } + + #[test] + fn failover_inside_the_config_dir_needs_no_extra_watch() { + let work = Path::new("/w"); + let config = Path::new("/c"); + let file = FailoverSource::File(PathBuf::from("/c/failover.yml")); + assert_eq!(extra_failover_watch(&file, config, RecursiveMode::NonRecursive, work), None); + let dir = FailoverSource::Directory(PathBuf::from("/c/failover.d")); + assert_eq!(extra_failover_watch(&dir, config, RecursiveMode::Recursive, work), None); + let nested = FailoverSource::File(PathBuf::from("/c/sub/failover.yml")); + assert_eq!(extra_failover_watch(&nested, config, RecursiveMode::Recursive, work), None); + } + + #[test] + fn failover_outside_the_config_dir_gets_its_own_watch() { + let work = Path::new("/w"); + let config = Path::new("/c"); + let file = FailoverSource::File(PathBuf::from("/data/failover.yml")); + assert_eq!( + extra_failover_watch(&file, config, RecursiveMode::NonRecursive, work), + Some((PathBuf::from("/data"), RecursiveMode::NonRecursive)) + ); + let dir = FailoverSource::Directory(PathBuf::from("/data/failover.d")); + assert_eq!( + extra_failover_watch(&dir, config, RecursiveMode::Recursive, work), + Some((PathBuf::from("/data/failover.d"), RecursiveMode::Recursive)) + ); + // A file in a subdirectory is not seen by a non-recursive config watch. + let nested = FailoverSource::File(PathBuf::from("/c/sub/failover.yml")); + assert_eq!( + extra_failover_watch(&nested, config, RecursiveMode::NonRecursive, work), + Some((PathBuf::from("/c/sub"), RecursiveMode::NonRecursive)) + ); + } + + #[test] + fn relative_failover_paths_are_compared_against_the_working_dir() { + let work = Path::new("/w"); + let config = Path::new("config"); + let file = FailoverSource::File(PathBuf::from("config/failover.yml")); + assert_eq!(extra_failover_watch(&file, config, RecursiveMode::NonRecursive, work), None); + let bare = FailoverSource::File(PathBuf::from("failover.yml")); + assert_eq!( + extra_failover_watch(&bare, config, RecursiveMode::NonRecursive, work), + Some((PathBuf::from("."), RecursiveMode::NonRecursive)) + ); + } #[test] fn pending_paths_are_formatted_completely_and_deterministically() { @@ -255,9 +398,9 @@ mod tests { locks.mark_internal_write_content(&internal, &tokio::fs::read(&internal).await?).await; let paths = HashSet::from([internal, external.clone()]); - assert!(has_external_source_write(&locks, &paths).await); + assert!(has_external_write(&locks, &paths).await); locks.mark_internal_write_content(&external, &tokio::fs::read(&external).await?).await; - assert!(!has_external_source_write(&locks, &paths).await); + assert!(!has_external_write(&locks, &paths).await); Ok(()) } } diff --git a/backend/app/src/api/tasks/channel_group_members.rs b/backend/app/src/api/tasks/failover_group_members.rs similarity index 83% rename from backend/app/src/api/tasks/channel_group_members.rs rename to backend/app/src/api/tasks/failover_group_members.rs index 4f4a249dd..b6a0f699e 100644 --- a/backend/app/src/api/tasks/channel_group_members.rs +++ b/backend/app/src/api/tasks/failover_group_members.rs @@ -4,9 +4,9 @@ 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; +use tuliprox_repository::target_stream_item::failover_groups_enabled; -/// Restricts the member health probe store to the members of all channel groups. +/// Restricts the member health probe store to the members of all failover 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 @@ -16,13 +16,13 @@ pub(in crate::api) async fn refresh_member_keys(app_state: &AppState) -> usize { .sources .iter() .flat_map(|source| source.targets.iter()) - .filter(|target| channel_groups_enabled(target)) + .filter(|target| failover_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; + app_state.playlists.failover_groups.get_or_load(&app_state.app_config, &app_state.playlists, &target).await; keys.extend(data.member_keys()); } let count = keys.len(); @@ -32,7 +32,7 @@ pub(in crate::api) async fn refresh_member_keys(app_state: &AppState) -> usize { /// 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( +pub(in crate::api) fn exec_failover_group_member_keys( app_state: &Arc, cancel: &CancellationToken, ) -> tokio::task::JoinHandle<()> { @@ -55,7 +55,7 @@ pub(in crate::api) fn exec_channel_group_member_keys( } Ok(_) => {} Err(RecvError::Lagged(skipped)) => { - warn!("Channel group member key refresh lagged by {skipped} events"); + warn!("Failover 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 9ab3d1230..bf2899bf2 100644 --- a/backend/app/src/api/tasks/mod.rs +++ b/backend/app/src/api/tasks/mod.rs @@ -15,16 +15,16 @@ //! 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 failover_group_members; mod hdhomerun_ssdp; mod library_scan; 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 failover_group_members::*; pub(in crate::api) use hdhomerun_ssdp::*; pub(in crate::api) use library_scan::*; pub(in crate::api) use notification_bridge::*; diff --git a/backend/app/src/api/tasks/notification_bridge.rs b/backend/app/src/api/tasks/notification_bridge.rs index 36159d0f2..9b73c1792 100644 --- a/backend/app/src/api/tasks/notification_bridge.rs +++ b/backend/app/src/api/tasks/notification_bridge.rs @@ -288,10 +288,11 @@ pub fn to_notification(message: &EventMessage) -> Option { EventMessage::PlaylistUpdateProgress(_) | EventMessage::SystemInfoUpdate(_) | EventMessage::RecordingProgress - | EventMessage::ChannelGroupMemberSelected { .. } - | EventMessage::ChannelGroupExhausted { .. } - | EventMessage::ChannelGroupCircuitChanged { .. } - | EventMessage::ChannelGroupMemberSwitched { .. } => None, + | EventMessage::FailoverGroupMemberSelected { .. } + | EventMessage::FailoverGroupExhausted { .. } + | EventMessage::FailoverGroupCircuitChanged { .. } + | EventMessage::FailoverGroupMemberSwitched { .. } + | EventMessage::FailoverOverridesChanged { .. } => None, } } @@ -635,25 +636,26 @@ mod tests { auth_audit(AuthAuditOutcome::SignInFailed), auth_audit(AuthAuditOutcome::SignInThrottled), auth_audit(AuthAuditOutcome::PermissionDenied), - EventMessage::ChannelGroupMemberSelected { + EventMessage::FailoverGroupMemberSelected { target_id: 1, group_vid: 10, member_vid: 11, - tier: shared::model::channel_group::CapacityTier::Free, + tier: shared::model::failover_group::CapacityTier::Free, }, - EventMessage::ChannelGroupExhausted { target_id: 1, group_vid: 10 }, - EventMessage::ChannelGroupMemberSwitched { + EventMessage::FailoverGroupExhausted { target_id: 1, group_vid: 10 }, + EventMessage::FailoverGroupMemberSwitched { target_id: 1, group_vid: 10, from_member_vid: 11, to_member_vid: 12, - reason: shared::model::channel_group::SwitchReason::UpstreamEof, + reason: shared::model::failover_group::SwitchReason::UpstreamEof, }, - EventMessage::ChannelGroupCircuitChanged { + EventMessage::FailoverGroupCircuitChanged { input_name: "test".into(), input_stream_id: "1".into(), - state: shared::model::channel_group::CircuitState::Closed, + state: shared::model::failover_group::CircuitState::Closed, }, + EventMessage::FailoverOverridesChanged { targets: vec!["main".to_string()] }, ]; assert_eq!(samples.len(), EventKind::ALL.len(), "add the new variant to this list"); samples diff --git a/backend/app/src/main.rs b/backend/app/src/main.rs index 7535392d7..f3456c067 100644 --- a/backend/app/src/main.rs +++ b/backend/app/src/main.rs @@ -103,6 +103,10 @@ struct Args { #[arg(short = 'T', long = "template")] template_file: Option, + /// The failover overrides file or directory + #[arg(long = "failover")] + failover_file: Option, + /// The target to process #[arg(short = 't', long)] target: Option>, @@ -497,6 +501,9 @@ fn get_file_paths(args: &Args) -> ConfigPaths { let path = resolve_env_var(p); utils::resolve_template_file_path(config_path.as_str(), Some(path.as_str())) }); + // kept as given (env resolved): `resolve_failover_path` scopes it to the config dir and needs the trailing + // separator to tell a directory from a file + let failover_file = args.failover_file.as_ref().map(|p| resolve_env_var(p)); let storage_path = if Path::new(&config_file).exists() { match crate::config_loader::read_config_file_with_options( &config_file, @@ -522,6 +529,7 @@ fn get_file_paths(args: &Args) -> ConfigPaths { mapping_files_used: None, template_file_path: template_file, // need to be set after config read template_files_used: None, + failover_file_path: failover_file, // CLI override; resolved after config read api_proxy_file_path: api_proxy_file, custom_stream_response_path: None, } diff --git a/backend/config-loader/src/lib.rs b/backend/config-loader/src/lib.rs index 41388a750..d86d8e441 100644 --- a/backend/config-loader/src/lib.rs +++ b/backend/config-loader/src/lib.rs @@ -25,16 +25,16 @@ use std::{ }; use tokio::fs; use tuliprox_core::{ - model::{ApiProxyConfig, AppConfig, Config, MediaToolCapabilities, SourcesConfig, UserPlan}, + model::{ApiProxyConfig, AppConfig, Config, FailoverOverrides, MediaToolCapabilities, SourcesConfig, UserPlan}, utils, utils::{ config_file_reader, exit, file_exists_async, open_file, read_mappings_file_unprepared, read_templates_file, - request::is_uri, resolve_env_var, FileLockManager, + request::is_uri, resolve_env_var, resolve_failover_path, FailoverSource, FileLockManager, }, }; use tuliprox_repository::{ backup_api_user_db_file, csv_read_inputs, csv_write_inputs, get_api_user_db_path, is_csv_file, load_api_user, - merge_api_user, + load_failover_overrides, merge_api_user, }; use url::Url; @@ -555,6 +555,41 @@ pub async fn prepare_users(app_config_dto: &mut AppConfigDto, app_config: &AppCo Ok(()) } +/// Where the failover override files live: the CLI path wins over `failover_path` of `config.yml`, +/// a relative path is resolved against the config directory. +pub fn resolve_failover_source(paths: &ConfigPaths, config: &Config, resolve_env: bool) -> FailoverSource { + let configured = paths.failover_file_path.as_deref().or(config.failover_path.as_deref()).map(|path| { + if resolve_env { + resolve_env_var(path) + } else { + path.to_string() + } + }); + resolve_failover_path(&paths.config_path, configured.as_deref()) +} + +/// Loads the failover override files. A load error does not stop the start: the snapshot stays empty, +/// carries the error and refuses write-back, while playback and automatic grouping keep working. +fn read_failover_overrides(paths: &ConfigPaths, config: &Config, resolve_env: bool) -> FailoverOverrides { + let source = resolve_failover_source(paths, config, resolve_env); + match load_failover_overrides(&source) { + Ok(overrides) => { + if !overrides.sections.is_empty() { + info!( + "Failover overrides: {} target section(s) from {} file(s)", + overrides.sections.len(), + overrides.files_used.len() + ); + } + overrides + } + Err(err) => { + error!("Failed to load failover overrides, failover group overrides are disabled: {err}"); + FailoverOverrides { source: Some(source), error: Some(err.to_string()), ..FailoverOverrides::default() } + } + } +} + pub async fn read_initial_app_config( paths: &mut ConfigPaths, resolve_env: bool, @@ -622,6 +657,7 @@ pub async fn read_initial_app_config( config.prepare(config_path, paths.home_path.as_str())?; paths.storage_path.clone_from(&config.storage_dir); config.update_runtime(); + let failover = read_failover_overrides(paths, &config, resolve_env); let mut app_config = AppConfig { config: Arc::new(ArcSwap::from_pointee(config)), @@ -634,6 +670,7 @@ pub async fn read_initial_app_config( access_token_secret: generate_default_access_secret()?, encrypt_secret: generate_default_encrypt_secret()?, media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::new(ArcSwap::from_pointee(failover)), }; app_config.prepare(include_computed)?; for (path, revision) in loaded_revisions { @@ -1383,11 +1420,15 @@ mod tests { write_config_text_file, }; use shared::{ - model::{ConfigInputAliasDto, ConfigInputDto, InputType, SourcesConfigDto}, + model::{ConfigInputAliasDto, ConfigInputDto, ConfigPaths, InputType, SourcesConfigDto}, utils::Internable, }; + use std::path::Path; use tempfile::tempdir; - use tuliprox_core::utils::resolve_env_var; + use tuliprox_core::{ + model::Config, + utils::{resolve_env_var, FailoverSource}, + }; #[test] fn a_config_with_the_old_download_block_says_what_to_rename() { @@ -1424,6 +1465,7 @@ mod tests { mapping_files_used: None, template_file_path: Some(template_path.to_string_lossy().into_owned()), template_files_used: None, + failover_file_path: None, api_proxy_file_path: proxy_path.to_string_lossy().into_owned(), custom_stream_response_path: None, }; @@ -1440,6 +1482,7 @@ mod tests { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), }; for resolve_env in [false, true] { assert!(read_api_proxy(&config, resolve_env).await.is_err()); @@ -1501,6 +1544,7 @@ templates: mapping_files_used: None, template_file_path: Some(template_path.to_string_lossy().into_owned()), template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, }; @@ -1830,4 +1874,55 @@ sources: assert_eq!(tokio::fs::read_to_string(&path).await?, "key: value"); Ok(()) } + + fn failover_test_paths(config_dir: &Path, cli_failover: Option<&str>) -> ConfigPaths { + ConfigPaths { + home_path: String::new(), + config_path: config_dir.to_string_lossy().into_owned(), + 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, + failover_file_path: cli_failover.map(str::to_string), + api_proxy_file_path: String::new(), + custom_stream_response_path: None, + } + } + + const FAILOVER_SECTION_YAML: &str = + "version: 1\ntargets:\n - target: Main\n excluded:\n - { input: a, stream_id: \"1\" }\n"; + + #[test] + fn failover_overrides_prefer_cli_path_over_config_path() -> Result<(), Box> { + let dir = tempdir()?; + std::fs::write(dir.path().join("cli.yml"), FAILOVER_SECTION_YAML)?; + std::fs::write(dir.path().join("configured.yml"), "version: 1\ntargets: []\n")?; + let config = Config { failover_path: Some("configured.yml".to_string()), ..Config::default() }; + + let from_config = super::read_failover_overrides(&failover_test_paths(dir.path(), None), &config, true); + assert!(from_config.sections.is_empty()); + assert_eq!(from_config.files_used, vec![dir.path().join("configured.yml")]); + + let from_cli = super::read_failover_overrides(&failover_test_paths(dir.path(), Some("cli.yml")), &config, true); + assert!(from_cli.error.is_none()); + assert!(from_cli.sections.contains_key("Main")); + Ok(()) + } + + #[test] + fn failover_overrides_load_error_is_fail_open() -> Result<(), Box> { + let dir = tempdir()?; + std::fs::write(dir.path().join("failover.yml"), "version: 9\ntargets: []\n")?; + + let overrides = + super::read_failover_overrides(&failover_test_paths(dir.path(), None), &Config::default(), true); + + assert!(overrides.sections.is_empty()); + assert!(overrides.error.as_deref().is_some_and(|error| error.contains("version"))); + assert_eq!(overrides.source, Some(FailoverSource::File(dir.path().join("failover.yml")))); + Ok(()) + } } diff --git a/backend/config-loader/src/runtime_config_report.rs b/backend/config-loader/src/runtime_config_report.rs index 066c9574d..4a1105b8e 100644 --- a/backend/config-loader/src/runtime_config_report.rs +++ b/backend/config-loader/src/runtime_config_report.rs @@ -75,6 +75,7 @@ fn runtime_config_to_dto(config: &Config) -> ConfigDto { user_config_dir: config.user_config_dir.clone(), mapping_path: config.mapping_path.clone(), template_path: config.template_path.clone(), + failover_path: config.failover_path.clone(), custom_stream_response_path: config.custom_stream_response_path.clone(), custom_stream_response_timeout_secs: config.custom_stream_response_timeout_secs, custom_stream_response_enabled: config.custom_stream_response_enabled, @@ -103,7 +104,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()), + failover_groups: (!config.failover_groups.is_default()).then(|| config.failover_groups.clone()), } } @@ -274,6 +275,7 @@ mod tests { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: config_dir.join("api-proxy.yml").to_string_lossy().to_string(), custom_stream_response_path: None, } diff --git a/backend/core/src/model/config/app.rs b/backend/core/src/model/config/app.rs index 3800ac31e..e00b24f30 100644 --- a/backend/core/src/model/config/app.rs +++ b/backend/core/src/model/config/app.rs @@ -1,8 +1,9 @@ use crate::{ model::{ ApiProxyConfig, ApiProxyServerInfo, CompiledMappings, CompiledTargetMappings, Config, ConfigInput, - ConfigInputOptions, ConfigTarget, CustomStreamResponse, GracePeriodOptions, HdHomeRunConfig, HdHomeRunFlags, - MediaToolCapabilities, ProxyUserCredentials, ReverseProxyDisabledHeaderConfig, SourcesConfig, TargetOutput, + ConfigInputOptions, ConfigTarget, CustomStreamResponse, FailoverOverrides, GracePeriodOptions, HdHomeRunConfig, + HdHomeRunFlags, MediaToolCapabilities, ProxyUserCredentials, ReverseProxyDisabledHeaderConfig, SourcesConfig, + TargetOutput, TargetOverridesEntry, }, utils, }; @@ -47,9 +48,15 @@ pub struct AppConfig { pub access_token_secret: [u8; 32], pub encrypt_secret: [u8; 16], pub media_tools: Arc, + pub failover: Arc>, } impl AppConfig { + /// Override section of `target` from the current failover snapshot. + pub fn failover_section(&self, target: &str) -> Option> { + self.failover.load().sections.get(target).map(Arc::clone) + } + pub fn set_config(&self, config: Config) -> Result<(), TuliproxError> { self.config.store(Arc::new(config)); self.prepare_paths(); @@ -597,6 +604,7 @@ mod tests { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, } @@ -617,6 +625,7 @@ mod tests { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), } } diff --git a/backend/core/src/model/config/base.rs b/backend/core/src/model/config/base.rs index e4b8fb17e..3fce8d6c5 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::{ChannelGroupsConfig, ConfigDto, GeoIpUnavailablePolicy, HdHomeRunDeviceOverview}, + model::{ConfigDto, FailoverGroupsConfig, GeoIpUnavailablePolicy, HdHomeRunDeviceOverview}, utils::set_sanitize_sensitive_info, }; use std::{ @@ -85,6 +85,7 @@ pub struct Config { pub user_config_dir: Option, pub mapping_path: Option, pub template_path: Option, + pub failover_path: Option, pub custom_stream_response_path: Option, pub custom_stream_response_timeout_secs: u32, pub custom_stream_response_enabled: bool, @@ -115,7 +116,7 @@ pub struct Config { pub proxy: Option, pub ipcheck: Option, pub library: Option, - pub channel_groups: ChannelGroupsConfig, + pub failover_groups: FailoverGroupsConfig, } /// Resolve a configured recording directory the way `Config::prepare` does: @@ -344,6 +345,7 @@ impl From<&ConfigDto> for Config { user_config_dir: dto.user_config_dir.clone(), mapping_path: dto.mapping_path.clone(), template_path: dto.template_path.clone(), + failover_path: dto.failover_path.clone(), custom_stream_response_path: dto.custom_stream_response_path.clone(), custom_stream_response_timeout_secs: dto.custom_stream_response_timeout_secs, custom_stream_response_enabled: dto.custom_stream_response_enabled, @@ -368,7 +370,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(), + failover_groups: dto.failover_groups.clone().unwrap_or_default(), } } } diff --git a/backend/core/src/model/config/failover.rs b/backend/core/src/model/config/failover.rs new file mode 100644 index 000000000..dd5ceba5e --- /dev/null +++ b/backend/core/src/model/config/failover.rs @@ -0,0 +1,23 @@ +use crate::utils::FailoverSource; +use shared::model::{OverrideValidationError, TargetFailoverOverridesDto}; +use std::{collections::HashMap, path::PathBuf, sync::Arc}; + +/// One target section of the failover files, canonicalized, with the file that owns it. +#[derive(Debug, Clone)] +pub struct TargetOverridesEntry { + pub overrides: TargetFailoverOverridesDto, + pub file: PathBuf, + pub etag: Arc, + /// Non-fatal validation findings computed when the section was loaded. + pub findings: Vec, +} + +/// Immutable snapshot of all failover override files; swapped atomically on load, reload and write-back. +#[derive(Debug, Clone, Default)] +pub struct FailoverOverrides { + pub source: Option, + pub sections: HashMap, Arc>, + pub files_used: Vec, + /// Fatal load error. While set, the snapshot is empty and write-back is refused. + pub error: Option, +} diff --git a/backend/core/src/model/config/mod.rs b/backend/core/src/model/config/mod.rs index 87b9dd380..a06b2c806 100644 --- a/backend/core/src/model/config/mod.rs +++ b/backend/core/src/model/config/mod.rs @@ -7,6 +7,7 @@ mod cache; mod curation; mod epg; mod epg_smart_match; +mod failover; mod favourites; mod geoip; mod hdhomerun; @@ -44,6 +45,7 @@ pub use base::*; pub use curation::*; pub use epg::*; pub use epg_smart_match::*; +pub use failover::*; pub use favourites::*; pub use geoip::*; pub use hdhomerun::*; diff --git a/backend/core/src/model/config/source.rs b/backend/core/src/model/config/source.rs index 74843210e..c1c8c4683 100644 --- a/backend/core/src/model/config/source.rs +++ b/backend/core/src/model/config/source.rs @@ -521,6 +521,11 @@ impl SourcesConfig { None } + /// The target called `name`; `SourcesConfigDto::prepare` rejects duplicate target names across sources. + pub fn get_target_by_name(&self, name: &str) -> Option<&Arc> { + self.sources.iter().flat_map(|source| &source.targets).find(|target| target.name == name) + } + pub fn get_source_inputs_by_target_by_name(&self, target_name: &str) -> Option>> { for source in &self.sources { for target in &source.targets { @@ -608,7 +613,8 @@ impl SourcesConfig { mod tests { use super::{ConfigProvider, SourcesConfig}; use shared::model::{ - ConfigInputAliasDto, ConfigInputDto, ConfigProviderDto, ProviderUrlSelectionPolicy, SourcesConfigDto, + ConfigInputAliasDto, ConfigInputDto, ConfigProviderDto, ConfigSourceDto, ConfigTargetDto, + ProviderUrlSelectionPolicy, SourcesConfigDto, }; use std::net::IpAddr; @@ -625,6 +631,25 @@ mod tests { } } + #[test] + fn get_target_by_name_finds_targets_of_every_source() { + let mut dto = sources_dto(); + let target = |id: u16, name: &str| ConfigTargetDto { id, name: name.to_string(), ..ConfigTargetDto::default() }; + dto.sources = vec![ + ConfigSourceDto { inputs: vec!["first-input".into()], targets: vec![target(1, "Main TV")] }, + ConfigSourceDto { + inputs: vec!["first-input".into()], + targets: vec![target(2, "Kids"), target(3, "Sport")], + }, + ]; + let sources = SourcesConfig::try_from(&dto).expect("sources load"); + + assert_eq!(sources.get_target_by_name("Main TV").map(|found| found.id), Some(1)); + assert_eq!(sources.get_target_by_name("Sport").map(|found| found.id), Some(3)); + assert!(sources.get_target_by_name("sport").is_none()); + assert!(sources.get_target_by_name("Unknown").is_none()); + } + /// A duplicate member name silently resolves to whichever entry was inserted last, so a request /// would run against another input. #[test] diff --git a/backend/core/src/model/config/target.rs b/backend/core/src/model/config/target.rs index 4f453b400..37116d6d6 100644 --- a/backend/core/src/model/config/target.rs +++ b/backend/core/src/model/config/target.rs @@ -9,7 +9,7 @@ use shared::{ apply_flags, create_bitset, foundation::{Filter, ValueProvider}, model::{ - ChannelGroupsTargetConfig, ConfigTargetDto, ConfigTargetOptions, DeduplicateConfig, HdHomeRunTargetOutputDto, + ConfigTargetDto, ConfigTargetOptions, DeduplicateConfig, FailoverGroupsTargetConfig, HdHomeRunTargetOutputDto, M3uTargetOutputDto, MappingStage, PlaylistItemType, ProcessingOrder, StrmExportStyle, StrmTargetOutputDto, TargetOutputDto, TargetType, TraktConfigDto, WatchContext, WatchContextSet, XtreamTargetOutputDto, }, @@ -31,8 +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, + /// The target's failover group switch and overrides; resolved against `config.yml` when used. + pub failover_groups: Option, } impl Default for TargetExecutionPlan { @@ -41,7 +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, + failover_groups: None, } } } @@ -60,7 +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), + failover_groups: dto.options.as_ref().and_then(|options| options.failover_groups).filter(|cfg| cfg.enabled), } } } diff --git a/backend/core/src/model/xtream.rs b/backend/core/src/model/xtream.rs index bb4d5eabd..5f5e6c9b4 100644 --- a/backend/core/src/model/xtream.rs +++ b/backend/core/src/model/xtream.rs @@ -172,6 +172,7 @@ mod tests { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -179,6 +180,7 @@ mod tests { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), } } diff --git a/backend/core/src/utils/file/file_utils.rs b/backend/core/src/utils/file/file_utils.rs index 2b6673c83..c98572b45 100644 --- a/backend/core/src/utils/file/file_utils.rs +++ b/backend/core/src/utils/file/file_utils.rs @@ -3,8 +3,8 @@ use log::{debug, error, trace, warn}; use path_clean::PathClean; use shared::{ defaults::{ - API_PROXY_FILE, CONFIG_FILE, CONFIG_PATH, DEFAULT_WEB_DIR, MAPPING_FILE, SOURCE_FILE, TEMPLATE_FILE, USER_FILE, - USER_GROUP_FILE, + API_PROXY_FILE, CONFIG_FILE, CONFIG_PATH, DEFAULT_WEB_DIR, FAILOVER_DIR, FAILOVER_FILE, MAPPING_FILE, + SOURCE_FILE, TEMPLATE_FILE, USER_FILE, USER_GROUP_FILE, }, error::str_to_io_error, utils::{DEFAULT_HOME_ENV_VAR, DEFAULT_WEB_ROOT_ENV_VAR}, @@ -107,6 +107,9 @@ pub fn get_default_mappings_path(config_path: &str) -> String { get_default_file #[inline] pub fn get_default_templates_path(config_path: &str) -> String { get_default_file_path(config_path, TEMPLATE_FILE) } +#[inline] +pub fn get_default_failover_path(config_path: &str) -> String { get_default_file_path(config_path, FAILOVER_FILE) } + fn resolve_config_scoped_path(config_path: &str, candidate: &str) -> String { let candidate_path = PathBuf::from(candidate.trim()).clean(); if candidate_path.is_absolute() { @@ -125,6 +128,55 @@ fn resolve_config_scoped_path(config_path: &str, candidate: &str) -> String { config_dir.join(relative).clean().to_string_lossy().to_string() } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FailoverSource { + File(PathBuf), + Directory(PathBuf), +} + +impl FailoverSource { + #[must_use] + pub fn path(&self) -> &Path { + match self { + Self::File(path) | Self::Directory(path) => path, + } + } + + #[must_use] + pub const fn is_directory(&self) -> bool { matches!(self, Self::Directory(_)) } +} + +/// Resolves where the failover overrides live. A configured path (CLI value first, then `failover_path`) wins and is +/// resolved against the config dir. Without one, `failover.yml` is preferred, then `failover.d/`, else `failover.yml`. +pub fn resolve_failover_path(config_path: &str, configured: Option<&str>) -> FailoverSource { + if let Some(configured) = configured.map(str::trim).filter(|path| !path.is_empty()) { + let resolved = PathBuf::from(resolve_config_scoped_path(config_path, configured)); + let looks_like_directory = resolved.is_dir() + || configured.ends_with('/') + || configured.ends_with('\\') + || resolved.extension().is_some_and(|ext| ext == "d"); + return if looks_like_directory { FailoverSource::Directory(resolved) } else { FailoverSource::File(resolved) }; + } + + let default_file = PathBuf::from(get_default_failover_path(config_path)); + let default_dir = PathBuf::from(get_default_file_path(config_path, FAILOVER_DIR)); + if default_file.is_file() { + if default_dir.is_dir() { + warn!( + "Both {} and {} exist, ignoring directory {}", + default_file.display(), + default_dir.display(), + default_dir.display() + ); + } + FailoverSource::File(default_file) + } else if default_dir.is_dir() { + FailoverSource::Directory(default_dir) + } else { + FailoverSource::File(default_file) + } +} + #[inline] pub fn resolve_mapping_file_path(config_path: &str, mapping_path: Option<&str>) -> String { let configured = mapping_path.map(str::trim).filter(|path| !path.is_empty()).map(ToString::to_string); @@ -414,17 +466,35 @@ where Ok(()) } -pub fn collect_yaml_files(path: &Path) -> std::io::Result> { - let mut files = vec![]; - let mut visit = |entry: &std::fs::DirEntry, metadata: &std::fs::Metadata| { - if metadata.is_file() { - let file_path = entry.path(); - if file_path.extension().is_some_and(|ext| ext == "yml" || ext == "yaml") { - files.push(file_path); - } +fn is_ignored_config_entry_name(name: &str) -> bool { + name.starts_with('.') + || name.ends_with('~') + || Path::new(name).extension().is_some_and(|ext| ext.eq_ignore_ascii_case("swp")) +} + +fn collect_config_yaml_files_into(path: &Path, files: &mut Vec) -> std::io::Result<()> { + for entry in fs::read_dir(path)? { + let entry = entry?; + let name = entry.file_name(); + if name.to_str().is_some_and(is_ignored_config_entry_name) { + continue; } - }; - traverse_dir(path, &mut visit)?; + let entry_path = entry.path(); + if entry.metadata()?.is_dir() { + collect_config_yaml_files_into(&entry_path, files)?; + } else if entry_path.extension().is_some_and(|ext| ext == "yml" || ext == "yaml") { + files.push(entry_path); + } + } + Ok(()) +} + +/// Collects all `.yml`/`.yaml` files below `path` (recursive), ignoring hidden entries and editor temp files. +/// The result is sorted by file name, ties are broken by the full path. +pub fn collect_config_yaml_files(path: &Path) -> std::io::Result> { + let mut files = vec![]; + collect_config_yaml_files_into(path, &mut files)?; + files.sort_by(|a, b| a.file_name().cmp(&b.file_name()).then_with(|| a.cmp(b))); Ok(files) } @@ -552,8 +622,8 @@ pub fn get_file_extension(path: &str) -> Option { #[cfg(test)] mod tests { use super::{ - collect_yaml_files, normalize_string_path, resolve_mapping_file_path, resolve_template_file_path, - resolve_template_persist_file_path, + collect_config_yaml_files, normalize_string_path, resolve_failover_path, resolve_mapping_file_path, + resolve_template_file_path, resolve_template_persist_file_path, FailoverSource, }; use std::path::PathBuf; @@ -626,13 +696,99 @@ mod tests { } #[test] - fn test_collect_yaml_files_accepts_both_extensions() { + fn collect_config_yaml_files_reads_yaml_and_yml_recursively() { let temp_dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(temp_dir.path().join("nested").join("deeper")).unwrap(); std::fs::write(temp_dir.path().join("one.yml"), "").unwrap(); std::fs::write(temp_dir.path().join("two.yaml"), "").unwrap(); std::fs::write(temp_dir.path().join("ignored.txt"), "").unwrap(); + std::fs::write(temp_dir.path().join("nested").join("three.yml"), "").unwrap(); + std::fs::write(temp_dir.path().join("nested").join("deeper").join("four.yaml"), "").unwrap(); - let files = collect_yaml_files(temp_dir.path()).unwrap(); - assert_eq!(files.len(), 2); + let files = collect_config_yaml_files(temp_dir.path()).unwrap(); + let names: Vec<_> = + files.iter().filter_map(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).collect(); + assert_eq!(names, ["four.yaml", "one.yml", "three.yml", "two.yaml"]); + } + + #[test] + fn collect_config_yaml_files_sorts_and_skips_hidden_and_temp() { + let temp_dir = tempfile::tempdir().unwrap(); + let root = temp_dir.path(); + std::fs::create_dir_all(root.join("b")).unwrap(); + std::fs::create_dir_all(root.join(".hidden_dir")).unwrap(); + std::fs::write(root.join("b").join("a.yml"), "").unwrap(); + std::fs::write(root.join("a.yml"), "").unwrap(); + std::fs::write(root.join("c.yml"), "").unwrap(); + std::fs::write(root.join(".dot.yml"), "").unwrap(); + std::fs::write(root.join("backup.yml~"), "").unwrap(); + std::fs::write(root.join("edit.yml.swp"), "").unwrap(); + std::fs::write(root.join(".hidden_dir").join("x.yml"), "").unwrap(); + + let files = collect_config_yaml_files(root).unwrap(); + // sorted by file name first, full path breaks the tie between the two `a.yml` + assert_eq!(files, vec![root.join("a.yml"), root.join("b").join("a.yml"), root.join("c.yml")]); + } + + #[test] + fn resolve_failover_prefers_file_then_dir() { + let temp_dir = tempfile::tempdir().unwrap(); + let config_path = temp_dir.path().to_string_lossy().to_string(); + std::fs::create_dir_all(temp_dir.path().join("failover.d")).unwrap(); + assert_eq!( + resolve_failover_path(&config_path, None), + FailoverSource::Directory(temp_dir.path().join("failover.d")) + ); + + std::fs::write(temp_dir.path().join("failover.yml"), "").unwrap(); + assert_eq!( + resolve_failover_path(&config_path, None), + FailoverSource::File(temp_dir.path().join("failover.yml")) + ); + } + + #[test] + fn resolve_failover_configured_dir_and_relative_path() { + let temp_dir = tempfile::tempdir().unwrap(); + let config_path = temp_dir.path().to_string_lossy().to_string(); + std::fs::create_dir_all(temp_dir.path().join("existing")).unwrap(); + std::fs::write(temp_dir.path().join("failover.yml"), "").unwrap(); + + // configured wins over the default file, relative paths are resolved against the config dir + assert_eq!( + resolve_failover_path(&config_path, Some("existing")), + FailoverSource::Directory(temp_dir.path().join("existing")) + ); + assert_eq!( + resolve_failover_path(&config_path, Some("overrides.d")), + FailoverSource::Directory(temp_dir.path().join("overrides.d")) + ); + assert_eq!( + resolve_failover_path(&config_path, Some("overrides/")), + FailoverSource::Directory(temp_dir.path().join("overrides")) + ); + assert_eq!( + resolve_failover_path(&config_path, Some("custom.yml")), + FailoverSource::File(temp_dir.path().join("custom.yml")) + ); + let absolute = temp_dir.path().join("abs.yml"); + assert_eq!( + resolve_failover_path(&config_path, Some(absolute.to_string_lossy().as_ref())), + FailoverSource::File(absolute) + ); + } + + #[test] + fn resolve_failover_defaults_to_file_when_nothing_exists() { + let temp_dir = tempfile::tempdir().unwrap(); + let config_path = temp_dir.path().to_string_lossy().to_string(); + assert_eq!( + resolve_failover_path(&config_path, None), + FailoverSource::File(temp_dir.path().join("failover.yml")) + ); + assert_eq!( + resolve_failover_path(&config_path, Some(" ")), + FailoverSource::File(temp_dir.path().join("failover.yml")) + ); } } diff --git a/backend/core/src/utils/file/mapping_reader.rs b/backend/core/src/utils/file/mapping_reader.rs index 262272142..c4279b9b6 100644 --- a/backend/core/src/utils/file/mapping_reader.rs +++ b/backend/core/src/utils/file/mapping_reader.rs @@ -105,11 +105,9 @@ fn read_mappings_from_directory( path: &Path, resolve_env: bool, ) -> Result, MappingsDto)>, TuliproxError> { - let mut files = crate::utils::collect_yaml_files(path) + let files = crate::utils::collect_config_yaml_files(path) .map_err(|err| TuliproxError::Io(format!("Failed to read mappings {err}")))?; - files.sort_by(|a, b| a.file_name().cmp(&b.file_name())); - let mut mappings = vec![]; let mut loaded_mapping_files = vec![]; for file_path in files { diff --git a/backend/core/src/utils/file/template_reader.rs b/backend/core/src/utils/file/template_reader.rs index 42b6f5c11..0e3795465 100644 --- a/backend/core/src/utils/file/template_reader.rs +++ b/backend/core/src/utils/file/template_reader.rs @@ -63,11 +63,9 @@ fn read_templates_from_directory( path: &Path, resolve_env: bool, ) -> Result, TemplateDefinitionDto)>, TuliproxError> { - let mut files = crate::utils::collect_yaml_files(path) + let files = crate::utils::collect_config_yaml_files(path) .map_err(|err| TuliproxError::Config(format!("Failed to read templates {err}")))?; - files.sort(); - let mut definitions = vec![]; let mut loaded_template_files = vec![]; for file_path in files { diff --git a/backend/core/src/utils/network/request/tests/support.rs b/backend/core/src/utils/network/request/tests/support.rs index f684ab6df..117736c75 100644 --- a/backend/core/src/utils/network/request/tests/support.rs +++ b/backend/core/src/utils/network/request/tests/support.rs @@ -44,6 +44,7 @@ pub(in crate::utils::network::request::tests) fn make_test_app_config(config: Co mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -51,6 +52,7 @@ pub(in crate::utils::network::request::tests) fn make_test_app_config(config: Co access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), }) } diff --git a/backend/dvr/src/recording/recording_retention.rs b/backend/dvr/src/recording/recording_retention.rs index 1049833fd..88ebc22b0 100644 --- a/backend/dvr/src/recording/recording_retention.rs +++ b/backend/dvr/src/recording/recording_retention.rs @@ -471,7 +471,7 @@ mod tests { } #[test] - fn channel_groups_by_id_when_present() { + fn failover_groups_by_id_when_present() { // Two recordings on the same stable channel id but // different display names still group together. let config = RetentionConfig { keep_last_per_channel: Some(0), delete_after_days: None }; diff --git a/backend/dvr/src/recording/recording_service/tests.rs b/backend/dvr/src/recording/recording_service/tests.rs index cb7898071..8a55e8d61 100644 --- a/backend/dvr/src/recording/recording_service/tests.rs +++ b/backend/dvr/src/recording/recording_service/tests.rs @@ -218,6 +218,7 @@ fn test_app_config() -> Arc { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -225,6 +226,7 @@ fn test_app_config() -> Arc { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(tuliprox_core::model::MediaToolCapabilities::default()), + failover: Arc::default(), }) } diff --git a/backend/dvr/src/recording/recording_service/tests/admission.rs b/backend/dvr/src/recording/recording_service/tests/admission.rs index f9551f265..983652c87 100644 --- a/backend/dvr/src/recording/recording_service/tests/admission.rs +++ b/backend/dvr/src/recording/recording_service/tests/admission.rs @@ -77,6 +77,7 @@ async fn edit_recording_re_validates_quota_against_new_duration_atomically() { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -84,6 +85,7 @@ async fn edit_recording_re_validates_quota_against_new_duration_atomically() { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(tuliprox_core::model::MediaToolCapabilities::default()), + failover: Arc::default(), }); let service = RecordingService::new(Arc::clone(&downloads), app_config); let claims = shared::model::Claims { diff --git a/backend/dvr/src/recording/recording_service/tests/transport.rs b/backend/dvr/src/recording/recording_service/tests/transport.rs index 469259b62..dad3c3ec9 100644 --- a/backend/dvr/src/recording/recording_service/tests/transport.rs +++ b/backend/dvr/src/recording/recording_service/tests/transport.rs @@ -207,6 +207,7 @@ async fn create_recording_rejects_absent_recording_config() { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -214,6 +215,7 @@ async fn create_recording_rejects_absent_recording_config() { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(tuliprox_core::model::MediaToolCapabilities::default()), + failover: Arc::default(), }); let downloads = Arc::new(RecordingQueue::new()); let service = RecordingService::new(Arc::clone(&downloads), app_config); diff --git a/backend/dvr/src/recording/recording_supervisor/mod.rs b/backend/dvr/src/recording/recording_supervisor/mod.rs index a4fd0e744..d4874f4f3 100644 --- a/backend/dvr/src/recording/recording_supervisor/mod.rs +++ b/backend/dvr/src/recording/recording_supervisor/mod.rs @@ -167,6 +167,7 @@ mod tests { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -174,6 +175,7 @@ mod tests { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(tuliprox_core::model::MediaToolCapabilities::new()), + failover: Arc::default(), } } diff --git a/backend/dvr/src/recording/recording_transfer/tests/support.rs b/backend/dvr/src/recording/recording_transfer/tests/support.rs index b29fb821a..6d8b733db 100644 --- a/backend/dvr/src/recording/recording_transfer/tests/support.rs +++ b/backend/dvr/src/recording/recording_transfer/tests/support.rs @@ -135,6 +135,7 @@ pub(in crate::recording::recording_transfer::tests) fn bare_app_config() -> tuli mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -142,6 +143,7 @@ pub(in crate::recording::recording_transfer::tests) fn bare_app_config() -> tuli access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(tuliprox_core::model::MediaToolCapabilities::new()), + failover: Arc::default(), } } diff --git a/backend/dvr/src/recording/recording_ws.rs b/backend/dvr/src/recording/recording_ws.rs index 862f59bec..8684f9920 100644 --- a/backend/dvr/src/recording/recording_ws.rs +++ b/backend/dvr/src/recording/recording_ws.rs @@ -259,6 +259,7 @@ mod tests { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -266,6 +267,7 @@ mod tests { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(tuliprox_core::model::MediaToolCapabilities::default()), + failover: Arc::default(), } } diff --git a/backend/hls/src/hls_ctx.rs b/backend/hls/src/hls_ctx.rs index 1da94670c..179298f8c 100644 --- a/backend/hls/src/hls_ctx.rs +++ b/backend/hls/src/hls_ctx.rs @@ -103,6 +103,7 @@ impl HlsCtx { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -110,6 +111,7 @@ impl HlsCtx { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(tuliprox_core::model::MediaToolCapabilities::new()), + failover: Arc::default(), }); let event_manager = Arc::new(tuliprox_session::EventManager::new()); diff --git a/backend/hls/src/manager/tests/support.rs b/backend/hls/src/manager/tests/support.rs index 2b082da8a..4fd3d0a23 100644 --- a/backend/hls/src/manager/tests/support.rs +++ b/backend/hls/src/manager/tests/support.rs @@ -58,6 +58,7 @@ pub(in crate::manager::tests) fn empty_paths() -> ConfigPaths { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, } @@ -75,6 +76,7 @@ pub(in crate::manager::tests) fn test_app_config(config: Config) -> AppConfig { access_token_secret: [0; 32], encrypt_secret: [7; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), } } diff --git a/backend/hls/src/refresh/tests/support.rs b/backend/hls/src/refresh/tests/support.rs index 07323d3cc..8a1dcaef4 100644 --- a/backend/hls/src/refresh/tests/support.rs +++ b/backend/hls/src/refresh/tests/support.rs @@ -104,6 +104,7 @@ pub(in crate::refresh::tests) fn test_app_config() -> Arc { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -111,6 +112,7 @@ pub(in crate::refresh::tests) fn test_app_config() -> Arc { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(tuliprox_core::model::MediaToolCapabilities::default()), + failover: Arc::default(), }) } diff --git a/backend/iptv/src/error.rs b/backend/iptv/src/error.rs index 9f84bec06..6c67272e9 100644 --- a/backend/iptv/src/error.rs +++ b/backend/iptv/src/error.rs @@ -103,7 +103,7 @@ impl ProviderErrorKind { | ErrorKind::ConfigTarget | ErrorKind::ConfigWebUi | ErrorKind::TargetBouquet - | ErrorKind::ChannelGroup + | ErrorKind::FailoverGroup | ErrorKind::UrlParse | ErrorKind::FilterParse | ErrorKind::RegexCompile => Self::Config, diff --git a/backend/messaging/src/lib.rs b/backend/messaging/src/lib.rs index 5a35b3179..89b1485cf 100644 --- a/backend/messaging/src/lib.rs +++ b/backend/messaging/src/lib.rs @@ -357,6 +357,7 @@ mod tests { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -367,6 +368,7 @@ mod tests { ], encrypt_secret: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), }) } diff --git a/backend/processing/src/processor/channel_group/assemble.rs b/backend/processing/src/processor/channel_group/assemble.rs deleted file mode 100644 index 026c48302..000000000 --- a/backend/processing/src/processor/channel_group/assemble.rs +++ /dev/null @@ -1,544 +0,0 @@ -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 deleted file mode 100644 index c98f27afa..000000000 --- a/backend/processing/src/processor/channel_group/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 5bf7827fb..000000000 --- a/backend/processing/src/processor/channel_group/tests.rs +++ /dev/null @@ -1,276 +0,0 @@ -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 59c48f414..3c8dd7deb 100644 --- a/backend/processing/src/processor/deduplicate.rs +++ b/backend/processing/src/processor/deduplicate.rs @@ -14,7 +14,7 @@ fn item_raw_match_value(config: DeduplicateConfig, item: &PlaylistItem) -> &str } fn should_skip(channel: &PlaylistItem, skip: &HashSet) -> bool { - skip.contains(&channel.header.uuid) || channel.header.channel_group_uuid.is_some() + skip.contains(&channel.header.uuid) || channel.header.failover_group_uuid.is_some() } /// Collapse duplicate channels across the whole target playlist (per cluster). diff --git a/backend/processing/src/processor/failover_group/assemble.rs b/backend/processing/src/processor/failover_group/assemble.rs new file mode 100644 index 000000000..dfa08c6b4 --- /dev/null +++ b/backend/processing/src/processor/failover_group/assemble.rs @@ -0,0 +1,437 @@ +use super::resolve::{ + group_uuid, resolve_failover_groups, CandidateOrigin, CatalogEntry, FailoverGroupResolution, ResolvedCandidate, +}; +use crate::processor::match_key::{extract_normalized_match_key, MatchKeyNormalizer}; +use log::{debug, info}; +use shared::{ + model::{ + ChannelMemberRefDto, FailoverGroupsConfig, FailoverOverrideDiagnosticsDto, LiveStreamProperties, PlaylistGroup, + PlaylistItem, PlaylistItemHeader, StreamProperties, TargetFailoverOverridesDto, UUIDType, XtreamCluster, + }, + utils::{token_quality, Internable}, +}; +use std::sync::Arc; +use tuliprox_repository::{CatalogRecord, FailoverGroupBatch, FailoverGroupOrigin, PendingFailoverGroup}; + +impl From for FailoverGroupOrigin { + fn from(origin: CandidateOrigin) -> Self { + match origin { + CandidateOrigin::Manual => Self::Manual, + CandidateOrigin::Auto => Self::Auto, + CandidateOrigin::AutoEdited => Self::AutoEdited, + } + } +} + +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() }) +} + +pub(in crate::processor) struct GroupCandidate { + resolved: ResolvedCandidate, + /// Playlist positions (group index, channel index) of `resolved.entries`. + positions: Vec<(usize, usize)>, +} + +struct GroupSlot { + id: u32, + title: Arc, + xtream_cluster: XtreamCluster, + had_channels: bool, + channels: Vec>, +} + +/// Natural auto key of a channel and whether the DSL forced it (`channel.header.failover_group`). +/// `None` means the channel opted out (`""`) or has no usable key. +pub(in crate::processor) fn natural_key_for( + header: &PlaylistItemHeader, + config: &FailoverGroupsConfig, + normalizer: &MatchKeyNormalizer<'_>, +) -> (Option, bool) { + let (key, dsl_forced) = match header.failover_group.as_deref() { + Some("") => return (None, false), + Some(forced) => (normalizer.key(forced), true), + None => (extract_normalized_match_key(header, config.match_by, config.match_as_ascii, Some(normalizer)), false), + }; + if key.trim().is_empty() { + (None, dsl_forced) + } else { + (Some(key), dsl_forced) + } +} + +/// The metadata of a group member that the group's own metadata can be derived from. +#[derive(Debug, Clone, Copy)] +pub struct MemberMetadata<'a> { + pub name: &'a str, + pub title: &'a str, + pub logo: &'a str, + pub epg_channel_id: Option<&'a str>, + pub playlist_group: &'a str, +} + +/// Name, logo, EPG id and playlist group of a group as shown to clients. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GroupMetadata { + pub name: String, + pub title: String, + pub logo: String, + pub epg_channel_id: Option, + /// The override, else the playlist group of the representative; `None` when neither is set. + pub playlist_group: Option, +} + +/// Single source of the group metadata rules, shared by processing and the REST preview: an override wins, +/// otherwise the first member (the representative) supplies the value, with the first non-empty logo and +/// non-blank EPG id of the remaining members as fallback. `members` is in member order. +pub fn derive_group_metadata<'a>( + candidate: &ResolvedCandidate, + members: impl IntoIterator>, + name_strip_quality: bool, +) -> GroupMetadata { + let members: Vec> = members.into_iter().collect(); + let representative = members.first().copied(); + + let (name, title) = match (&candidate.name, representative) { + (Some(name), _) => (name.clone(), name.clone()), + (None, Some(rep)) if name_strip_quality => (strip_quality_words(rep.name), strip_quality_words(rep.title)), + (None, Some(rep)) => (rep.name.to_string(), rep.title.to_string()), + (None, None) => (String::new(), String::new()), + }; + + let logo = candidate.logo.clone().unwrap_or_else(|| { + members.iter().map(|member| member.logo).find(|logo| !logo.is_empty()).unwrap_or_default().to_string() + }); + + let epg_channel_id = candidate.epg_channel_id.clone().or_else(|| { + let own = representative.and_then(|rep| rep.epg_channel_id); + let usable = own + .filter(|epg| !epg.trim().is_empty()) + .or_else(|| members.iter().filter_map(|member| member.epg_channel_id).find(|epg| !epg.trim().is_empty())); + usable.or(own).map(str::to_string) + }); + + let playlist_group = candidate + .playlist_group + .clone() + .or_else(|| representative.map(|rep| rep.playlist_group).filter(|group| !group.is_empty()).map(str::to_string)); + + GroupMetadata { name, title, logo, epg_channel_id, playlist_group } +} + +fn build_group_metadata( + candidate: &GroupCandidate, + rep_header: &PlaylistItemHeader, + slots: &[GroupSlot], + config: &FailoverGroupsConfig, + group_uuid: UUIDType, +) -> PlaylistItemHeader { + let mut group_header = rep_header.clone(); + group_header.uuid = group_uuid; + group_header.failover_group = None; + group_header.failover_group_uuid = Some(group_uuid); + + let member_headers = || { + candidate + .positions + .iter() + .filter_map(|&pos| slots.get(pos.0).and_then(|g| g.channels.get(pos.1)).and_then(Option::as_ref)) + .map(|item| &item.header) + }; + let derived = derive_group_metadata( + &candidate.resolved, + member_headers().map(|header| MemberMetadata { + name: &header.name, + title: &header.title, + logo: &header.logo, + epg_channel_id: header.epg_channel_id.as_deref(), + playlist_group: &header.group, + }), + config.name_strip_quality, + ); + group_header.name = derived.name.intern(); + group_header.title = derived.title.intern(); + group_header.logo = derived.logo.intern(); + group_header.epg_channel_id = derived.epg_channel_id.as_deref().map(Internable::intern); + + apply_archive_metadata(&mut group_header, candidate, slots); + group_header +} + +fn apply_archive_metadata(group_header: &mut 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() + }))); + } + } + } +} + +/// Catalog record of an eligible channel from the key the resolution used, so the persisted +/// catalog and the grouping can not diverge. +fn catalog_record( + header: &PlaylistItemHeader, + member_ref: ChannelMemberRefDto, + (natural_key, dsl_forced): (Option, bool), +) -> CatalogRecord { + CatalogRecord { + member_ref, + natural_key: natural_key.as_deref().map(Internable::intern), + dsl_forced, + epg_channel_id: header.epg_channel_id.clone(), + name: Arc::clone(&header.name), + title: header.get_name(), + logo: header.logo.clone(), + playlist_group: header.group.clone(), + provider_id: header.provider_id_or_default(), + has_archive: matches!( + &header.additional_properties, + Some(StreamProperties::Live(live)) if live.tv_archive.unwrap_or(0) > 0 + ), + } +} + +/// Group candidates, override diagnostics and the catalog of all eligible channels in playlist order. +pub(in crate::processor) fn collect_group_candidates( + config: &FailoverGroupsConfig, + overrides: Option<&TargetFailoverOverridesDto>, + playlist: &[PlaylistGroup], +) -> (Vec, FailoverOverrideDiagnosticsDto, Vec) { + let normalizer = MatchKeyNormalizer::new(&config.match_normalize, config.match_as_ascii); + let mut positions: Vec<(usize, usize)> = Vec::new(); + let mut member_refs: Vec = Vec::new(); + let mut natural_keys: Vec<(Option, bool)> = Vec::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 Some(member_ref) = item_member_ref(channel) else { + continue; + }; + positions.push((group_idx, channel_idx)); + member_refs.push(member_ref); + natural_keys.push(natural_key_for(&channel.header, config, &normalizer)); + } + } + + let catalog: Vec> = positions + .iter() + .zip(&member_refs) + .zip(&natural_keys) + .map(|((&(group_idx, channel_idx), member_ref), (natural_key, dsl_forced))| CatalogEntry { + member_ref, + natural_key: natural_key.as_deref(), + dsl_forced: *dsl_forced, + epg_channel_id: playlist[group_idx].channels[channel_idx].header.epg_channel_id.as_deref(), + }) + .collect(); + + let FailoverGroupResolution { candidates, diagnostics } = resolve_failover_groups(config, overrides, &catalog); + let candidates = candidates + .into_iter() + .map(|resolved| GroupCandidate { + positions: resolved.entries.iter().map(|&entry| positions[entry]).collect(), + resolved, + }) + .collect(); + let catalog_records = positions + .iter() + .zip(member_refs) + .zip(natural_keys) + .map(|((&(group_idx, channel_idx), member_ref), key)| { + catalog_record(&playlist[group_idx].channels[channel_idx].header, member_ref, key) + }) + .collect(); + (candidates, diagnostics, catalog_records) +} + +fn log_diagnostics(target_name: &str, diagnostics: &FailoverOverrideDiagnosticsDto) { + if diagnostics.is_empty() { + return; + } + info!( + "Failover group overrides of target '{target_name}': {} stale auto override(s), {} inactive manual group(s), {} conflicting ref(s), {} unresolved ref(s), {} dissolved auto group(s)", + diagnostics.stale_auto_overrides.len(), + diagnostics.inactive_manual_groups.len(), + diagnostics.conflicting_refs.len(), + diagnostics.unresolved_refs.len(), + diagnostics.dissolved.len() + ); + debug!("Failover group override diagnostics of target '{target_name}': {diagnostics:?}"); +} + +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: &FailoverGroupsConfig, + 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 = group_uuid(target_name, candidate.resolved.origin, &candidate.resolved.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.resolved.playlist_group { + 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(PendingFailoverGroup { + group_uuid, + key: candidate.resolved.key.as_str().intern(), + origin: candidate.resolved.origin.into(), + policy: candidate.resolved.policy, + members, + orphaned: candidate.resolved.unresolved.clone(), + epg_split: candidate.resolved.epg_split, + }); + } + + rebuild_playlist_from_slots(slots, deferred_placements, playlist); + pending_groups +} + +pub(in crate::processor) fn group_playlist( + config: &FailoverGroupsConfig, + overrides: Option<&TargetFailoverOverridesDto>, + target_name: &str, + playlist: &mut Vec, +) -> FailoverGroupBatch { + if playlist.is_empty() { + return FailoverGroupBatch { enabled: true, ..FailoverGroupBatch::default() }; + } + + let (candidates, diagnostics, catalog) = collect_group_candidates(config, overrides, playlist); + log_diagnostics(target_name, &diagnostics); + if candidates.is_empty() { + return FailoverGroupBatch { catalog, enabled: true, ..FailoverGroupBatch::default() }; + } + + 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(); + + let groups = materialize_groups(&candidates, config, target_name, slots, playlist); + FailoverGroupBatch { groups, catalog, enabled: true, ..FailoverGroupBatch::default() } +} diff --git a/backend/processing/src/processor/failover_group/mod.rs b/backend/processing/src/processor/failover_group/mod.rs new file mode 100644 index 000000000..8c95f7503 --- /dev/null +++ b/backend/processing/src/processor/failover_group/mod.rs @@ -0,0 +1,10 @@ +mod assemble; +mod resolve; +#[cfg(test)] +mod tests; + +pub(in crate::processor) use assemble::*; +pub use assemble::{derive_group_metadata, GroupMetadata, MemberMetadata}; +pub use resolve::{ + group_uuid, resolve_failover_groups, CandidateOrigin, CatalogEntry, FailoverGroupResolution, ResolvedCandidate, +}; diff --git a/backend/processing/src/processor/failover_group/resolve.rs b/backend/processing/src/processor/failover_group/resolve.rs new file mode 100644 index 000000000..1d15aa720 --- /dev/null +++ b/backend/processing/src/processor/failover_group/resolve.rs @@ -0,0 +1,407 @@ +//! Pure failover group resolution over catalog entries. +//! +//! Processing feeds it the playlist, the REST preview feeds it the persisted catalog; both get the same +//! groups because this is the only place that decides which channel ends up in which group. + +use indexmap::IndexMap; +use shared::{ + model::{ + AutoGroupOverrideDto, ChannelMemberRefDto, EpgConflictMode, FailoverGroupPolicy, FailoverGroupsConfig, + FailoverOverrideDiagnosticsDto, InactiveManualGroupDto, TargetFailoverOverridesDto, UUIDType, UnresolvedRefDto, + MIN_GROUP_MEMBERS, + }, + utils::hash_string, +}; +use std::collections::{HashMap, HashSet}; + +/// One eligible live channel as the resolution sees it. +#[derive(Debug, Clone, Copy)] +pub struct CatalogEntry<'a> { + pub member_ref: &'a ChannelMemberRefDto, + /// `None`: the channel opted out of automatic grouping (DSL `@failover_group = ""`) or has no usable key. + pub natural_key: Option<&'a str>, + pub dsl_forced: bool, + pub epg_channel_id: Option<&'a str>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CandidateOrigin { + Manual, + Auto, + AutoEdited, +} + +#[derive(Debug, Clone)] +pub struct ResolvedCandidate { + /// Manual group id or auto key (including the EPG suffix of split buckets). + pub key: String, + pub origin: CandidateOrigin, + pub policy: FailoverGroupPolicy, + /// Indices into the catalog in member order. + pub entries: Vec, + pub unresolved: Vec, + pub epg_split: bool, + pub name: Option, + pub logo: Option, + pub epg_channel_id: Option, + pub playlist_group: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct FailoverGroupResolution { + pub candidates: Vec, + pub diagnostics: FailoverOverrideDiagnosticsDto, +} + +/// Manual ids and auto keys live in separate namespaces so `zdf` can exist in both. +pub fn group_uuid(target: &str, origin: CandidateOrigin, key: &str) -> UUIDType { + let namespace = match origin { + CandidateOrigin::Manual => "manual", + CandidateOrigin::Auto | CandidateOrigin::AutoEdited => "auto", + }; + hash_string(&format!("failover-group|{target}|{namespace}|{key}")) +} + +struct Bucket { + entries: Vec, + has_dsl_forced: bool, + epg_split: bool, +} + +type MemberIndex<'a> = HashMap<&'a ChannelMemberRefDto, usize>; + +fn natural_buckets( + config: &FailoverGroupsConfig, + catalog: &[CatalogEntry<'_>], + include: impl Fn(usize) -> bool, +) -> IndexMap { + let mut buckets: IndexMap = IndexMap::new(); + for (idx, entry) in catalog.iter().enumerate() { + let Some(key) = entry.natural_key.filter(|key| !key.trim().is_empty()) else { + continue; + }; + if !include(idx) { + continue; + } + let slot = match buckets.get_index_of(key) { + Some(slot) => slot, + None => { + buckets + .insert_full( + key.to_string(), + Bucket { entries: Vec::new(), has_dsl_forced: false, epg_split: false }, + ) + .0 + } + }; + let bucket = &mut buckets[slot]; + bucket.entries.push(idx); + bucket.has_dsl_forced |= entry.dsl_forced; + } + + if config.epg_conflict == EpgConflictMode::Split { + split_epg_buckets(catalog, buckets) + } else { + buckets + } +} + +fn split_epg_buckets(catalog: &[CatalogEntry<'_>], buckets: IndexMap) -> IndexMap { + let mut split: IndexMap = IndexMap::with_capacity(buckets.len()); + for (key, bucket) in buckets { + if bucket.has_dsl_forced { + split.insert(key, bucket); + continue; + } + + let mut by_epg: IndexMap> = IndexMap::new(); + for &idx in &bucket.entries { + if let Some(epg) = catalog[idx].epg_channel_id { + let normalized = epg.trim().to_lowercase(); + if !normalized.is_empty() { + by_epg.entry(normalized).or_default().push(idx); + } + } + } + + if by_epg.len() >= 2 { + for (epg_id, entries) in by_epg { + split.insert(format!("{key}|{epg_id}"), Bucket { entries, has_dsl_forced: false, epg_split: true }); + } + } else { + split.insert(key, bucket); + } + } + split +} + +/// Rule 1: manual groups claim their refs, first claim wins. A group with too few resolvable refs is +/// inactive and releases its claims again. +fn resolve_manual_groups( + config: &FailoverGroupsConfig, + overrides: &TargetFailoverOverridesDto, + index: &MemberIndex<'_>, + taken: &mut [bool], + out: &mut FailoverGroupResolution, +) { + for group in &overrides.groups { + let mut matched = Vec::with_capacity(group.members.len()); + let mut unresolved = Vec::new(); + for member in &group.members { + match index.get(member) { + Some(&idx) if !taken[idx] => { + taken[idx] = true; + matched.push(idx); + } + _ => unresolved.push(member.clone()), + } + } + + out.diagnostics.unresolved_refs.extend( + unresolved.iter().map(|member| UnresolvedRefDto { group: group.id.clone(), member_ref: member.clone() }), + ); + + if matched.len() < MIN_GROUP_MEMBERS { + for &idx in &matched { + taken[idx] = false; + } + out.diagnostics + .inactive_manual_groups + .push(InactiveManualGroupDto { id: group.id.clone(), resolved: matched.len() }); + continue; + } + + out.candidates.push(ResolvedCandidate { + key: group.id.clone(), + origin: CandidateOrigin::Manual, + policy: group.policy.unwrap_or(config.policy), + entries: matched, + unresolved, + epg_split: false, + name: Some(group.name.clone()), + logo: group.logo.clone(), + epg_channel_id: group.epg_channel_id.clone(), + playlist_group: group.playlist_group.clone(), + }); + } +} + +/// Exclusive `add` claims of the auto overrides (rule 2). Claims are the entries an override pulled out of +/// their natural bucket, in `add` order, indexed by override. +struct AddClaims { + claimed: Vec, + per_override: Vec>, +} + +impl AddClaims { + fn release(&mut self, override_idx: usize) { + for idx in self.per_override[override_idx].drain(..) { + self.claimed[idx] = false; + } + } +} + +struct AutoContext<'a, 'b> { + config: &'a FailoverGroupsConfig, + catalog: &'a [CatalogEntry<'b>], + index: &'a MemberIndex<'b>, + overrides: &'a [AutoGroupOverrideDto], + /// First override per auto key; later duplicates are ignored (validation flags them). + by_key: HashMap<&'a str, usize>, +} + +impl AutoContext<'_, '_> { + fn is_effective(&self, override_idx: usize) -> bool { + self.by_key.get(self.overrides[override_idx].auto_key.as_str()) == Some(&override_idx) + } + + /// Claims every `add` ref of an override whose bucket exists, before buckets are built, so the result does + /// not depend on bucket iteration order. Overrides without a natural bucket are stale and claim nothing. + fn claim_adds( + &self, + taken: &[bool], + excluded: &HashSet<&ChannelMemberRefDto>, + diagnostics: &mut FailoverOverrideDiagnosticsDto, + ) -> AddClaims { + let mut claims = AddClaims { + claimed: vec![false; self.catalog.len()], + per_override: vec![Vec::new(); self.overrides.len()], + }; + if self.overrides.iter().all(|ao| ao.disabled || ao.add.is_empty()) { + return claims; + } + + let existing = natural_buckets(self.config, self.catalog, |idx| { + !taken[idx] && !excluded.contains(self.catalog[idx].member_ref) + }); + for (override_idx, ao) in self.overrides.iter().enumerate() { + if ao.disabled || !self.is_effective(override_idx) || !existing.contains_key(ao.auto_key.as_str()) { + continue; + } + for add_ref in &ao.add { + let Some(&idx) = self.index.get(add_ref) else { + continue; + }; + if taken[idx] || claims.claimed[idx] { + if !diagnostics.conflicting_refs.contains(add_ref) + && !claims.per_override[override_idx].contains(&idx) + { + diagnostics.conflicting_refs.push(add_ref.clone()); + } + continue; + } + claims.claimed[idx] = true; + claims.per_override[override_idx].push(idx); + } + } + claims + } + + /// Natural buckets without claimed entries. A claim is released, and its channels return to their natural + /// buckets, when its override can not materialize a group: the target bucket vanished (all its natural + /// members were claimed away) or the resulting group has fewer than `MIN_GROUP_MEMBERS` members. Releasing + /// can grow other buckets, so one override is released at a time (first in override order) until stable. + fn final_buckets( + &self, + taken: &[bool], + excluded: &HashSet<&ChannelMemberRefDto>, + claims: &mut AddClaims, + ) -> IndexMap { + loop { + let buckets = natural_buckets(self.config, self.catalog, |idx| { + !taken[idx] && !claims.claimed[idx] && !excluded.contains(self.catalog[idx].member_ref) + }); + let unproductive = self.overrides.iter().enumerate().find(|&(override_idx, ao)| { + let claimed = &claims.per_override[override_idx]; + !claimed.is_empty() + && buckets.get(ao.auto_key.as_str()).is_none_or(|bucket| { + self.apply_override(bucket.entries.clone(), ao, claimed).len() < MIN_GROUP_MEMBERS + }) + }); + let Some((override_idx, _)) = unproductive else { + return buckets; + }; + claims.release(override_idx); + } + } + + fn unresolved_refs(&self, ao: &AutoGroupOverrideDto) -> Vec { + let mut unresolved: Vec = Vec::new(); + for member in ao.order.iter().chain(&ao.add).chain(&ao.remove) { + if !self.index.contains_key(member) && !unresolved.contains(member) { + unresolved.push(member.clone()); + } + } + unresolved + } + + /// Rule 5: `remove`, claimed `add`s, then `order`; members missing from `order` follow in catalog order. + fn apply_override(&self, mut members: Vec, ao: &AutoGroupOverrideDto, claimed: &[usize]) -> Vec { + if !ao.remove.is_empty() { + let removed: HashSet<&ChannelMemberRefDto> = ao.remove.iter().collect(); + members.retain(|&idx| !removed.contains(self.catalog[idx].member_ref)); + } + members.extend_from_slice(claimed); + + if ao.order.is_empty() { + return members; + } + let present: HashSet = members.iter().copied().collect(); + let mut ordered = Vec::with_capacity(members.len()); + let mut used = HashSet::with_capacity(members.len()); + for ord_ref in &ao.order { + if let Some(&idx) = self.index.get(ord_ref) { + if present.contains(&idx) && used.insert(idx) { + ordered.push(idx); + } + } + } + ordered.extend(members.iter().copied().filter(|idx| !used.contains(idx))); + ordered + } +} + +fn resolve_auto_groups( + config: &FailoverGroupsConfig, + overrides: Option<&TargetFailoverOverridesDto>, + catalog: &[CatalogEntry<'_>], + index: &MemberIndex<'_>, + taken: &[bool], + out: &mut FailoverGroupResolution, +) { + let auto_overrides: &[AutoGroupOverrideDto] = overrides.map_or(&[], |o| &o.auto_overrides); + let mut by_key = HashMap::with_capacity(auto_overrides.len()); + for (idx, ao) in auto_overrides.iter().enumerate() { + by_key.entry(ao.auto_key.as_str()).or_insert(idx); + } + let ctx = AutoContext { config, catalog, index, overrides: auto_overrides, by_key }; + let excluded: HashSet<&ChannelMemberRefDto> = overrides.map_or_else(HashSet::new, |o| o.excluded.iter().collect()); + + let mut claims = ctx.claim_adds(taken, &excluded, &mut out.diagnostics); + let buckets = ctx.final_buckets(taken, &excluded, &mut claims); + + out.diagnostics.stale_auto_overrides.extend( + auto_overrides + .iter() + .enumerate() + .filter(|(idx, ao)| ctx.is_effective(*idx) && !buckets.contains_key(ao.auto_key.as_str())) + .map(|(_, ao)| ao.auto_key.clone()), + ); + + for (key, bucket) in buckets { + let override_idx = ctx.by_key.get(key.as_str()).copied(); + let ao = override_idx.map(|idx| &auto_overrides[idx]); + if ao.is_some_and(|ao| ao.disabled) { + out.diagnostics.dissolved.push(key); + continue; + } + + let (entries, unresolved) = match (override_idx, ao) { + (Some(override_idx), Some(ao)) => { + let unresolved = ctx.unresolved_refs(ao); + out.diagnostics.unresolved_refs.extend( + unresolved.iter().map(|member| UnresolvedRefDto { group: key.clone(), member_ref: member.clone() }), + ); + (ctx.apply_override(bucket.entries, ao, &claims.per_override[override_idx]), unresolved) + } + _ => (bucket.entries, Vec::new()), + }; + if entries.len() < MIN_GROUP_MEMBERS { + continue; + } + + out.candidates.push(ResolvedCandidate { + key, + origin: if ao.is_some() { CandidateOrigin::AutoEdited } else { CandidateOrigin::Auto }, + policy: ao.and_then(|ao| ao.policy).unwrap_or(config.policy), + entries, + unresolved, + epg_split: bucket.epg_split, + name: ao.and_then(|ao| ao.name.clone()), + logo: ao.and_then(|ao| ao.logo.clone()), + epg_channel_id: ao.and_then(|ao| ao.epg_channel_id.clone()), + playlist_group: ao.and_then(|ao| ao.playlist_group.clone()), + }); + } +} + +/// Resolves manual and automatic failover groups. Precedence for a channel: manual group, auto override `add`, +/// `excluded`, natural auto bucket; a channel is a member of at most one group. +pub fn resolve_failover_groups( + config: &FailoverGroupsConfig, + overrides: Option<&TargetFailoverOverridesDto>, + catalog: &[CatalogEntry<'_>], +) -> FailoverGroupResolution { + let mut out = FailoverGroupResolution::default(); + let mut index: MemberIndex<'_> = HashMap::with_capacity(catalog.len()); + for (idx, entry) in catalog.iter().enumerate() { + index.entry(entry.member_ref).or_insert(idx); + } + let mut taken = vec![false; catalog.len()]; + + if let Some(overrides) = overrides { + resolve_manual_groups(config, overrides, &index, &mut taken, &mut out); + } + resolve_auto_groups(config, overrides, catalog, &index, &taken, &mut out); + out +} diff --git a/backend/processing/src/processor/failover_group/tests.rs b/backend/processing/src/processor/failover_group/tests.rs new file mode 100644 index 000000000..5f4259238 --- /dev/null +++ b/backend/processing/src/processor/failover_group/tests.rs @@ -0,0 +1,674 @@ +use super::*; +use shared::{ + model::{ + AutoGroupOverrideDto, ChannelMemberRefDto, EpgConflictMode, FailoverGroupsConfig, + FailoverOverrideDiagnosticsDto, InactiveManualGroupDto, LiveStreamProperties, ManualFailoverGroupDto, + PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, StreamProperties, + TargetFailoverOverridesDto, XtreamCluster, + }, + utils::Internable, +}; +use tuliprox_repository::{FailoverGroupOrigin, PendingFailoverGroup}; + +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() -> FailoverGroupsConfig { FailoverGroupsConfig::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)]) -> TargetFailoverOverridesDto { + let mut overrides = TargetFailoverOverridesDto::new("T"); + overrides.groups.push(ManualFailoverGroupDto { + 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).groups; + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].members.len(), 2); + assert_eq!(pending[0].origin, FailoverGroupOrigin::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).groups.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.failover_group = Some("".intern()); + let mut b = live("korath", "7", "Zweites"); + b.header.failover_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).groups; + 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).groups; + let manual = pending.iter().find(|p| p.origin == FailoverGroupOrigin::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 = TargetFailoverOverridesDto::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).groups.is_empty()); +} + +#[test] +fn auto_override_orders_and_marks_edited() { + let mut overrides = TargetFailoverOverridesDto::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).groups; + assert_eq!(pending[0].origin, FailoverGroupOrigin::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).groups; + 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).groups.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).groups.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).groups; + 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).groups; + 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.failover_group = Some("eins".intern()); + let mut b = with_epg(live("korath", "7", "One"), "b.uk"); + b.header.failover_group = Some("eins".intern()); + let mut playlist = vec![group("News", vec![a, b])]; + let pending = group_playlist(&cfg(), None, "T", &mut playlist).groups; + 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).groups; + 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).groups; + let mut second = build(); + let p2 = group_playlist(&cfg(), None, "T", &mut second).groups; + 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).groups; + assert_eq!(pending[0].members.iter().map(|m| m.header.uuid).collect::>(), vec![uuid_a, uuid_b]); + assert_eq!(playlist[0].channels[0].header.failover_group_uuid, Some(pending[0].group_uuid)); +} + +fn rf(input: &str, stream_id: &str) -> ChannelMemberRefDto { + ChannelMemberRefDto { input: input.into(), stream_id: stream_id.into() } +} + +fn auto_override(key: &str) -> AutoGroupOverrideDto { + AutoGroupOverrideDto { auto_key: key.into(), ..Default::default() } +} + +fn diagnostics_of( + config: &FailoverGroupsConfig, + overrides: Option<&TargetFailoverOverridesDto>, + playlist: &[PlaylistGroup], +) -> FailoverOverrideDiagnosticsDto { + collect_group_candidates(config, overrides, playlist).1 +} + +fn refs_of(group: &PendingFailoverGroup) -> Vec<(String, String)> { + group.members.iter().map(|m| (m.header.input_name.to_string(), m.header.id.to_string())).collect() +} + +fn pair(input: &str, stream_id: &str) -> (String, String) { (input.to_string(), stream_id.to_string()) } + +fn find<'a>(pending: &'a [PendingFailoverGroup], origin: FailoverGroupOrigin, key: &str) -> &'a PendingFailoverGroup { + pending.iter().find(|p| p.origin == origin && p.key.as_ref() == key).expect("group present") +} + +#[test] +fn manual_id_equal_to_auto_key_yields_two_groups() { + let overrides = overrides_with_manual("zdf", &[("veyra", "3"), ("orun", "4")]); + let mut playlist = vec![group( + "News", + vec![ + live("solis", "1", "ZDF HD"), + live("korath", "7", "ZDF FHD"), + live("veyra", "3", "RTL"), + live("orun", "4", "Sat"), + ], + )]; + let pending = group_playlist(&cfg(), Some(&overrides), "T", &mut playlist).groups; + assert_eq!(pending.len(), 2); + let manual = find(&pending, FailoverGroupOrigin::Manual, "zdf"); + let auto = find(&pending, FailoverGroupOrigin::Auto, "zdf"); + assert_ne!(manual.group_uuid, auto.group_uuid); + assert_eq!(manual.group_uuid, group_uuid("T", CandidateOrigin::Manual, "zdf")); + assert_eq!(auto.group_uuid, group_uuid("T", CandidateOrigin::Auto, "zdf")); + assert_eq!(group_uuid("T", CandidateOrigin::AutoEdited, "zdf"), auto.group_uuid); +} + +#[test] +fn auto_add_is_exclusive_regardless_of_bucket_order() { + let build_playlist = || { + vec![group( + "All", + vec![ + live("p", "a1", "Alpha"), + live("p", "a2", "Alpha"), + live("p", "a3", "Alpha"), + live("p", "b1", "Beta"), + live("p", "b2", "Beta"), + live("p", "b3", "Beta"), + live("p", "z1", "Zulu"), + live("p", "z2", "Zulu"), + ], + )] + }; + let mut to_alpha = auto_override("alpha"); + to_alpha.add.push(rf("p", "b1")); + let mut to_zulu = auto_override("zulu"); + to_zulu.add.push(rf("p", "a1")); + + for reversed in [false, true] { + let mut overrides = TargetFailoverOverridesDto::new("T"); + if reversed { + overrides.auto_overrides.extend([to_zulu.clone(), to_alpha.clone()]); + } else { + overrides.auto_overrides.extend([to_alpha.clone(), to_zulu.clone()]); + } + let mut playlist = build_playlist(); + let pending = group_playlist(&cfg(), Some(&overrides), "T", &mut playlist).groups; + assert_eq!(pending.len(), 3); + + let mut all: Vec<_> = pending.iter().flat_map(refs_of).collect(); + let total = all.len(); + all.sort(); + all.dedup(); + assert_eq!(all.len(), total, "no channel may be in two groups"); + assert_eq!(total, 8, "no member lost"); + + assert!(refs_of(find(&pending, FailoverGroupOrigin::AutoEdited, "alpha")).contains(&pair("p", "b1"))); + assert!(refs_of(find(&pending, FailoverGroupOrigin::AutoEdited, "zulu")).contains(&pair("p", "a1"))); + assert!(!refs_of(find(&pending, FailoverGroupOrigin::Auto, "beta")).contains(&pair("p", "b1"))); + assert!(!refs_of(find(&pending, FailoverGroupOrigin::AutoEdited, "alpha")).contains(&pair("p", "a1"))); + } +} + +#[test] +fn too_small_override_group_releases_its_claims() { + let mut to_alpha = auto_override("alpha"); + to_alpha.remove.push(rf("p", "a1")); + to_alpha.add.push(rf("p", "b1")); + let mut overrides = TargetFailoverOverridesDto::new("T"); + overrides.auto_overrides.push(to_alpha); + let mut playlist = vec![group( + "All", + vec![live("p", "a1", "Alpha"), live("p", "b1", "Beta"), live("p", "b2", "Beta"), live("p", "b3", "Beta")], + )]; + let pending = group_playlist(&cfg(), Some(&overrides), "T", &mut playlist).groups; + assert_eq!(pending.len(), 1, "the one-member override group is not materialized"); + let beta = find(&pending, FailoverGroupOrigin::Auto, "beta"); + assert_eq!(refs_of(beta), vec![pair("p", "b1"), pair("p", "b2"), pair("p", "b3")]); + assert!(playlist[0].channels.iter().any(|c| c.header.id.as_ref() == "a1"), "a1 stays a normal channel"); +} + +#[test] +fn stale_auto_override_is_reported() { + let mut stale = auto_override("gone"); + stale.add.push(rf("solis", "2")); + let mut overrides = TargetFailoverOverridesDto::new("T"); + overrides.auto_overrides.push(stale); + let mut playlist = vec![group( + "News", + vec![ + live("solis", "1", "ZDF HD"), + live("korath", "7", "ZDF FHD"), + live("solis", "2", "ARD"), + live("veyra", "5", "ARD"), + ], + )]; + assert_eq!(diagnostics_of(&cfg(), Some(&overrides), &playlist).stale_auto_overrides, vec!["gone".to_string()]); + let pending = group_playlist(&cfg(), Some(&overrides), "T", &mut playlist).groups; + // the stale add is not claimed: solis/2 stays in its natural bucket + assert!(refs_of(find(&pending, FailoverGroupOrigin::Auto, "ard")).contains(&pair("solis", "2"))); +} + +#[test] +fn unresolved_refs_reported_for_auto_and_manual_groups() { + let mut zdf = auto_override("zdf"); + zdf.order = vec![rf("gone", "9"), rf("korath", "7")]; + let mut overrides = overrides_with_manual("ard", &[("solis", "2"), ("veyra", "5"), ("gone", "99")]); + overrides.auto_overrides.push(zdf); + let mut playlist = vec![group( + "News", + vec![ + live("solis", "1", "ZDF HD"), + live("korath", "7", "ZDF FHD"), + live("solis", "2", "ARD"), + live("veyra", "5", "ARD"), + ], + )]; + let diagnostics = diagnostics_of(&cfg(), Some(&overrides), &playlist); + assert_eq!(diagnostics.unresolved_refs.len(), 2); + assert!(diagnostics.unresolved_refs.iter().any(|u| u.group == "zdf" && u.member_ref == rf("gone", "9"))); + assert!(diagnostics.unresolved_refs.iter().any(|u| u.group == "ard" && u.member_ref == rf("gone", "99"))); + + let pending = group_playlist(&cfg(), Some(&overrides), "T", &mut playlist).groups; + assert_eq!(find(&pending, FailoverGroupOrigin::AutoEdited, "zdf").orphaned, vec![rf("gone", "9")]); + assert_eq!(find(&pending, FailoverGroupOrigin::Manual, "ard").orphaned, vec![rf("gone", "99")]); +} + +#[test] +fn disabled_auto_group_is_dissolved_and_restorable() { + let build_playlist = || { + vec![group( + "News", + vec![live("solis", "1", "ZDF HD"), live("korath", "7", "ZDF FHD"), live("veyra", "3", "ARD")], + )] + }; + let mut disabled = auto_override("zdf"); + disabled.disabled = true; + let mut overrides = TargetFailoverOverridesDto::new("T"); + overrides.auto_overrides.push(disabled); + + let mut playlist = build_playlist(); + assert_eq!(diagnostics_of(&cfg(), Some(&overrides), &playlist).dissolved, vec!["zdf".to_string()]); + assert!(group_playlist(&cfg(), Some(&overrides), "T", &mut playlist).groups.is_empty()); + assert_eq!(playlist[0].channels.len(), 3, "members stay ungrouped"); + + overrides.auto_overrides.clear(); + let mut playlist = build_playlist(); + let pending = group_playlist(&cfg(), Some(&overrides), "T", &mut playlist).groups; + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].members.len(), 2); +} + +#[test] +fn inactive_manual_group_is_reported() { + let overrides = overrides_with_manual("m", &[("solis", "1"), ("gone", "99")]); + let mut playlist = vec![group("News", vec![live("solis", "1", "ZDF HD"), live("korath", "7", "ZDF FHD")])]; + let diagnostics = diagnostics_of(&cfg(), Some(&overrides), &playlist); + assert_eq!(diagnostics.inactive_manual_groups, vec![InactiveManualGroupDto { id: "m".into(), resolved: 1 }]); + assert_eq!(diagnostics.unresolved_refs.len(), 1); + // an inactive group does not hold its members: they regroup naturally + let pending = group_playlist(&cfg(), Some(&overrides), "T", &mut playlist).groups; + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].origin, FailoverGroupOrigin::Auto); + assert_eq!(pending[0].members.len(), 2); +} + +#[test] +fn new_channel_joins_edited_auto_group_after_ordered_members() { + let mut zdf = auto_override("zdf"); + zdf.order = vec![rf("korath", "7")]; + let mut overrides = TargetFailoverOverridesDto::new("T"); + overrides.auto_overrides.push(zdf); + 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).groups; + assert_eq!(refs_of(&pending[0]), vec![pair("korath", "7"), pair("solis", "1"), pair("veyra", "3")]); +} + +#[test] +fn manual_beats_auto_add_and_reports_conflict() { + let mut overrides = overrides_with_manual("m", &[("p", "a1"), ("p", "a2")]); + let mut beta = auto_override("beta"); + beta.add.push(rf("p", "a1")); + overrides.auto_overrides.push(beta); + let mut playlist = vec![group( + "All", + vec![live("p", "a1", "Alpha"), live("p", "a2", "Alpha"), live("p", "b1", "Beta"), live("p", "b2", "Beta")], + )]; + assert_eq!(diagnostics_of(&cfg(), Some(&overrides), &playlist).conflicting_refs, vec![rf("p", "a1")]); + let pending = group_playlist(&cfg(), Some(&overrides), "T", &mut playlist).groups; + assert_eq!(refs_of(find(&pending, FailoverGroupOrigin::Manual, "m")), vec![pair("p", "a1"), pair("p", "a2")]); + assert_eq!( + refs_of(find(&pending, FailoverGroupOrigin::AutoEdited, "beta")), + vec![pair("p", "b1"), pair("p", "b2")] + ); +} + +#[test] +fn excluded_channel_usable_in_manual_group() { + let mut overrides = overrides_with_manual("m", &[("solis", "1"), ("korath", "7")]); + overrides.excluded.push(rf("solis", "1")); + 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).groups; + assert_eq!(pending.len(), 1); + assert_eq!(refs_of(&pending[0]), vec![pair("solis", "1"), pair("korath", "7")]); +} + +#[test] +fn resolution_works_on_borrowed_catalog_entries() { + let refs = [rf("p", "1"), rf("p", "2"), rf("p", "3")]; + let catalog: Vec> = refs + .iter() + .zip(["news", "news", "other"]) + .map(|(member_ref, key)| CatalogEntry { + member_ref, + natural_key: Some(key), + dsl_forced: false, + epg_channel_id: None, + }) + .collect(); + let resolution = resolve_failover_groups(&cfg(), None, &catalog); + assert_eq!(resolution.candidates.len(), 1); + assert_eq!(resolution.candidates[0].key, "news"); + assert_eq!(resolution.candidates[0].entries, vec![0, 1]); + assert!(resolution.diagnostics.is_empty()); +} + +#[test] +fn catalog_lists_eligible_channels_in_playlist_order_with_natural_keys() { + let mut forced = live("solis", "2", "ARD"); + forced.header.failover_group = Some("Das Erste".intern()); + let mut optout = live("solis", "3", "Local News"); + optout.header.failover_group = Some("".intern()); + let mut archived = with_epg(live("korath", "7", "ZDF FHD"), "zdf.de"); + set_archive(&mut archived, 3); + archived.header.logo = "http://logo/zdf.png".intern(); + archived.header.group = "News".intern(); + let mut vod = live("solis", "9", "Movie"); + vod.header.xtream_cluster = XtreamCluster::Video; + vod.header.item_type = PlaylistItemType::Video; + let mut playlist = vec![group("News", vec![live("solis", "1", "ZDF HD"), forced, optout, archived, vod])]; + + let batch = group_playlist(&cfg(), None, "T", &mut playlist); + + assert!(batch.enabled); + let catalog = &batch.catalog; + let titles: Vec<&str> = catalog.iter().map(|record| record.title.as_ref()).collect(); + assert_eq!(titles, ["ZDF HD", "ARD", "Local News", "ZDF FHD"], "playlist order, no VOD"); + let keys: Vec<(Option<&str>, bool)> = + catalog.iter().map(|record| (record.natural_key.as_deref(), record.dsl_forced)).collect(); + assert!(keys[1].1, "dsl forced"); + assert_eq!(keys[2], (None, false), "opted out channel has no key"); + assert_eq!(keys[0].0, keys[3].0, "same natural key as the group built from both"); + assert!(keys[0].0.is_some()); + + let zdf = &catalog[3]; + assert_eq!(zdf.member_ref, rf("korath", "7")); + assert_eq!(zdf.provider_id, 7); + assert!(zdf.has_archive); + assert_eq!(zdf.epg_channel_id.as_deref(), Some("zdf.de")); + assert_eq!(zdf.logo.as_ref(), "http://logo/zdf.png"); + assert_eq!(zdf.playlist_group.as_ref(), "News"); + assert!(!catalog[0].has_archive); +} + +#[test] +fn catalog_keeps_header_name_next_to_display_title() { + let mut item = live("solis", "1", "ZDF HD"); + item.header.name = "zdf_hd_raw".intern(); + item.header.title = "ZDF HD Display".intern(); + let mut playlist = vec![group("News", vec![item])]; + + let batch = group_playlist(&cfg(), None, "T", &mut playlist); + + assert_eq!(batch.catalog[0].name.as_ref(), "zdf_hd_raw"); + assert_eq!(batch.catalog[0].title.as_ref(), "ZDF HD Display"); +} + +#[test] +fn catalog_is_complete_without_candidates_and_for_empty_playlist() { + let mut playlist = vec![group("News", vec![live("solis", "1", "ZDF HD"), live("solis", "2", "ARD")])]; + let batch = group_playlist(&cfg(), None, "T", &mut playlist); + assert!(batch.groups.is_empty()); + assert!(batch.enabled); + assert_eq!(batch.catalog.len(), 2, "the catalog does not depend on groups being built"); + + let mut empty = Vec::new(); + let batch = group_playlist(&cfg(), None, "T", &mut empty); + assert!(batch.enabled && batch.groups.is_empty() && batch.catalog.is_empty()); +} + +#[test] +fn catalog_contains_channels_that_were_grouped_and_excluded() { + let mut overrides = TargetFailoverOverridesDto::new("T"); + overrides.excluded.push(rf("solis", "1")); + let mut playlist = vec![group("News", vec![live("solis", "1", "ZDF HD"), live("korath", "7", "ZDF FHD")])]; + let batch = group_playlist(&cfg(), Some(&overrides), "T", &mut playlist); + assert!(batch.groups.is_empty()); + assert_eq!(batch.catalog.len(), 2, "excluded channels stay selectable in the editor"); +} + +fn resolved_candidate() -> ResolvedCandidate { + ResolvedCandidate { + key: "zdf".to_string(), + origin: CandidateOrigin::Auto, + policy: shared::model::FailoverGroupPolicy::Auto, + entries: vec![0, 1], + unresolved: Vec::new(), + epg_split: false, + name: None, + logo: None, + epg_channel_id: None, + playlist_group: None, + } +} + +fn member<'a>( + title: &'a str, + logo: &'a str, + epg_channel_id: Option<&'a str>, + playlist_group: &'a str, +) -> MemberMetadata<'a> { + MemberMetadata { name: title, title, logo, epg_channel_id, playlist_group } +} + +#[test] +fn derive_group_metadata_falls_back_to_first_non_empty_member_values() { + let members = + [member("ZDF HD", "", Some(" "), "News"), member("ZDF", "http://logo/b.png", Some("zdf.de"), "Misc")]; + + let derived = derive_group_metadata(&resolved_candidate(), members, true); + + assert_eq!((derived.name.as_str(), derived.title.as_str()), ("ZDF", "ZDF")); + assert_eq!(derived.logo, "http://logo/b.png"); + assert_eq!(derived.epg_channel_id.as_deref(), Some("zdf.de")); + assert_eq!(derived.playlist_group.as_deref(), Some("News")); +} + +#[test] +fn derive_group_metadata_prefers_representative_and_overrides() { + let members = + [member("ZDF HD", "http://logo/a.png", Some("a.de"), ""), member("ZDF", "http://logo/b.png", None, "Misc")]; + + let plain = derive_group_metadata(&resolved_candidate(), members, false); + assert_eq!(plain.name, "ZDF HD"); + assert_eq!(plain.logo, "http://logo/a.png"); + assert_eq!(plain.epg_channel_id.as_deref(), Some("a.de")); + assert_eq!(plain.playlist_group, None); + + let overridden = ResolvedCandidate { + name: Some("Mine".to_string()), + logo: Some("http://logo/own.png".to_string()), + epg_channel_id: Some("own.de".to_string()), + playlist_group: Some("Sport".to_string()), + ..resolved_candidate() + }; + let derived = derive_group_metadata(&overridden, members, true); + assert_eq!((derived.name.as_str(), derived.title.as_str()), ("Mine", "Mine")); + assert_eq!(derived.logo, "http://logo/own.png"); + assert_eq!(derived.epg_channel_id.as_deref(), Some("own.de")); + assert_eq!(derived.playlist_group.as_deref(), Some("Sport")); +} diff --git a/backend/processing/src/processor/match_key.rs b/backend/processing/src/processor/match_key.rs index 8e2340668..a949ee14d 100644 --- a/backend/processing/src/processor/match_key.rs +++ b/backend/processing/src/processor/match_key.rs @@ -116,7 +116,7 @@ impl<'a> MatchKeyNormalizer<'a> { mod tests { use super::*; use shared::{ - model::{ChannelGroupsConfig, ChannelMatchNormalize, PlaylistItemHeader}, + model::{ChannelMatchNormalize, FailoverGroupsConfig, PlaylistItemHeader}, utils::Internable, }; @@ -159,14 +159,14 @@ mod tests { } fn normalize(strip: Option<&str>, ignore: &[&str], aliases: &[(&str, &str)]) -> ChannelMatchNormalize { - let mut cfg = ChannelGroupsConfig { + let mut cfg = FailoverGroupsConfig { 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() + ..FailoverGroupsConfig::default() }; cfg.prepare().expect("valid"); cfg.match_normalize diff --git a/backend/processing/src/processor/mod.rs b/backend/processing/src/processor/mod.rs index 6d1f60cdc..0a7bfa907 100644 --- a/backend/processing/src/processor/mod.rs +++ b/backend/processing/src/processor/mod.rs @@ -3,9 +3,9 @@ mod stalker; mod stalker_refresh; mod xtream; // mod affix; -pub(in crate::processor) mod channel_group; mod deduplicate; mod epg; +pub mod failover_group; mod library; pub(in crate::processor) mod match_key; mod probe_handle_guard; diff --git a/backend/processing/src/processor/playlist/mod.rs b/backend/processing/src/processor/playlist/mod.rs index 05ad0b53d..6a5322140 100644 --- a/backend/processing/src/processor/playlist/mod.rs +++ b/backend/processing/src/processor/playlist/mod.rs @@ -59,7 +59,7 @@ use tuliprox_curation::{ SelectorOutcome, }; use tuliprox_repository::{ - persist_playlist_views_with_groups, CategoryKey, MemoryPlaylistSource, PendingChannelGroup, + persist_playlist_views_with_groups, CategoryKey, FailoverGroupBatch, FailoverGroupMeta, MemoryPlaylistSource, 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 b07f404f0..23fec17b4 100644 --- a/backend/processing/src/processor/playlist/target.rs +++ b/backend/processing/src/processor/playlist/target.rs @@ -507,27 +507,43 @@ 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(); +) -> FailoverGroupBatch { + let Some(target_cfg) = target.execution_plan.failover_groups.as_ref() else { + return FailoverGroupBatch::default(); }; - 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 + let cg_cfg = config.config.load().failover_groups.for_target(target_cfg); + // One snapshot for error and section, so the meta never mixes two failover generations. + let failover = config.failover.load(); + if let Some(error) = failover.error.as_deref() { + errors.push(TuliproxError::FailoverGroup(format!( + "Failover overrides are not applied to target '{}': {error}", + target.name + ))); + } + let overrides = failover.sections.get(target.name.as_str()); + step.broadcast("Processing failover groups for '{}' playlist", &target.name); + let mut batch = crate::processor::failover_group::group_playlist( + &cg_cfg, + overrides.map(|entry| &entry.overrides), + &target.name, + playlist, + ); + batch.meta = FailoverGroupMeta { + overrides_etag: overrides.map(|entry| Arc::clone(&entry.etag)), + overrides_error: failover.error.clone(), + applied_overrides: overrides.map(|entry| entry.overrides.clone()), + // A publication shows the current representatives, so nothing is stale after it. + display_stale_groups: Vec::new(), + }; + step.tick("failover grouping"); + log_memory_snapshot(format!("target '{}' after_failover_grouping", target.name).as_str()); + batch } pub(super) async fn finalize_prepared_target_with_tmdb( @@ -542,8 +558,8 @@ pub(super) async fn finalize_prepared_target_with_tmdb = pending_channel_groups.iter().map(|p| p.group_uuid).collect(); + let group_uuids: HashSet = pending_failover_groups.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; + views.failover_groups = pending_failover_groups; if views.base.is_empty() && views.xtream.is_none() @@ -620,7 +636,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) failover_groups: FailoverGroupBatch, } pub(crate) fn build_curated_playlist_views( @@ -959,7 +975,7 @@ pub(crate) fn build_curated_playlist_views( has_xtream_output && !config.include_xtream_base_categories, appearance_filter_configured, ), - channel_groups: Vec::new(), + failover_groups: FailoverGroupBatch::default(), } } @@ -974,7 +990,7 @@ pub(crate) async fn prepare_target_playlist_views( base: playlist, xtream: None, publication_plan: PlaylistPublicationPlan::Ordinary, - channel_groups: Vec::new(), + failover_groups: FailoverGroupBatch::default(), }; }; @@ -993,7 +1009,7 @@ pub(super) fn curation_playlist_views( base: playlist, xtream: None, publication_plan: PlaylistPublicationPlan::Ordinary, - channel_groups: Vec::new(), + failover_groups: FailoverGroupBatch::default(), }, CurationRunOutcome::Failed(failure) => { warn_curation_failure(&target.name, &failure); @@ -1002,7 +1018,7 @@ pub(super) fn curation_playlist_views( base: playlist, xtream: None, publication_plan: PlaylistPublicationPlan::Ordinary, - channel_groups: Vec::new(), + failover_groups: FailoverGroupBatch::default(), } } CurationRunOutcome::Complete(evaluation) => { @@ -1218,11 +1234,11 @@ pub(crate) async fn process_watch( pub(crate) async fn run_finalize_until_persist( target: &ConfigTarget, mut playlist: Vec, -) -> (Vec, 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) + let pending = if let Some(target_cfg) = target.execution_plan.failover_groups.as_ref() { + let cg_cfg = shared::model::FailoverGroupsConfig::default().for_target(target_cfg); + crate::processor::failover_group::group_playlist(&cg_cfg, None, &target.name, &mut playlist).groups } else { Vec::new() }; diff --git a/backend/processing/src/processor/playlist/tests.rs b/backend/processing/src/processor/playlist/tests.rs index 3c2452e3d..a3486d0b0 100644 --- a/backend/processing/src/processor/playlist/tests.rs +++ b/backend/processing/src/processor/playlist/tests.rs @@ -24,7 +24,7 @@ mod curation_effect_gate; mod watch_contexts; -mod channel_groups; +mod failover_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 deleted file mode 100644 index b15f2cb27..000000000 --- a/backend/processing/src/processor/playlist/tests/channel_groups.rs +++ /dev/null @@ -1,122 +0,0 @@ -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 d86727c1b..fb5aa7240 100644 --- a/backend/processing/src/processor/playlist/tests/curation_effect_gate.rs +++ b/backend/processing/src/processor/playlist/tests/curation_effect_gate.rs @@ -37,6 +37,7 @@ pub(super) fn app_config(storage_dir: &Path) -> Arc { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -44,6 +45,7 @@ pub(super) fn app_config(storage_dir: &Path) -> Arc { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), }) } diff --git a/backend/processing/src/processor/playlist/tests/failover_groups.rs b/backend/processing/src/processor/playlist/tests/failover_groups.rs new file mode 100644 index 000000000..026a02a00 --- /dev/null +++ b/backend/processing/src/processor/playlist/tests/failover_groups.rs @@ -0,0 +1,279 @@ +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#"{"failover_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.failover_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#"{"failover_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::FailoverGroupsConfig = + 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(), + failover_groups: global, + ..tuliprox_core::model::Config::default() + })); + let target = target_with_options(r#"{"failover_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_failover_groups( + &app_config, + &target, + &mut playlist, + &mut step, + &mut errors, + ); + + assert!(errors.is_empty()); + assert_eq!(pending.groups.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"); +} + +fn snapshot_with_excluded(target: &str, input: &str, stream_id: &str) -> tuliprox_core::model::FailoverOverrides { + let mut overrides = shared::model::TargetFailoverOverridesDto::new(target); + overrides + .excluded + .push(shared::model::ChannelMemberRefDto { input: input.to_string(), stream_id: stream_id.to_string() }); + let entry = tuliprox_core::model::TargetOverridesEntry { + etag: overrides.etag().into(), + overrides, + file: std::path::PathBuf::from("not-on-disk.yml"), + findings: Vec::new(), + }; + tuliprox_core::model::FailoverOverrides { + sections: std::collections::HashMap::from([(target.into(), std::sync::Arc::new(entry))]), + ..Default::default() + } +} + +#[tokio::test] +async fn processing_reads_snapshot_not_disk() { + let dir = tempfile::tempdir().expect("tempdir"); + let app_config = super::curation_effect_gate::app_config(dir.path()); + let target = target_with_options(r#"{"failover_groups":{"enabled":true}}"#); + let mut step = tuliprox_core::utils::StepMeasure::new("test", |_, _| {}); + let channels = || vec![group("News", vec![live("solis", "1", "ZDF HD"), live("korath", "7", "ZDF FHD")])]; + + let mut playlist = channels(); + let mut errors = Vec::new(); + let without = crate::processor::playlist::target::process_target_failover_groups( + &app_config, + &target, + &mut playlist, + &mut step, + &mut errors, + ); + assert_eq!(without.groups.len(), 1, "without a section both channels are grouped"); + + app_config.failover.store(std::sync::Arc::new(snapshot_with_excluded("T", "solis", "1"))); + let mut playlist = channels(); + let with = crate::processor::playlist::target::process_target_failover_groups( + &app_config, + &target, + &mut playlist, + &mut step, + &mut errors, + ); + + assert!(errors.is_empty()); + assert!(with.groups.is_empty(), "the excluded channel from the snapshot prevents grouping"); + assert_eq!(playlist[0].channels.len(), 2); +} + +#[tokio::test] +async fn processing_reports_failover_load_error_and_groups_without_overrides() { + let dir = tempfile::tempdir().expect("tempdir"); + let app_config = super::curation_effect_gate::app_config(dir.path()); + app_config.failover.store(std::sync::Arc::new(tuliprox_core::model::FailoverOverrides { + error: Some("bad failover file".to_string()), + ..Default::default() + })); + let target = target_with_options(r#"{"failover_groups":{"enabled":true}}"#); + let mut playlist = vec![group("News", vec![live("solis", "1", "ZDF HD"), live("korath", "7", "ZDF FHD")])]; + let mut step = tuliprox_core::utils::StepMeasure::new("test", |_, _| {}); + let mut errors = Vec::new(); + + let pending = crate::processor::playlist::target::process_target_failover_groups( + &app_config, + &target, + &mut playlist, + &mut step, + &mut errors, + ); + + assert_eq!(pending.groups.len(), 1, "auto grouping keeps working"); + assert_eq!(errors.len(), 1); + assert!(errors[0].to_string().contains("bad failover file"), "{}", errors[0]); +} + +#[tokio::test] +async fn meta_records_applied_section_and_etag() { + let dir = tempfile::tempdir().expect("tempdir"); + let app_config = super::curation_effect_gate::app_config(dir.path()); + let target = target_with_options(r#"{"failover_groups":{"enabled":true}}"#); + let mut step = tuliprox_core::utils::StepMeasure::new("test", |_, _| {}); + let channels = || vec![group("News", vec![live("solis", "1", "ZDF HD"), live("korath", "7", "ZDF FHD")])]; + + let mut errors = Vec::new(); + let mut playlist = channels(); + let without = crate::processor::playlist::target::process_target_failover_groups( + &app_config, + &target, + &mut playlist, + &mut step, + &mut errors, + ); + assert!(without.enabled); + assert_eq!(without.catalog.len(), 2); + assert_eq!(without.meta, tuliprox_repository::FailoverGroupMeta::default()); + + let snapshot = snapshot_with_excluded("T", "solis", "1"); + let entry = std::sync::Arc::clone(&snapshot.sections["T"]); + app_config.failover.store(std::sync::Arc::new(snapshot)); + let mut playlist = channels(); + let with = crate::processor::playlist::target::process_target_failover_groups( + &app_config, + &target, + &mut playlist, + &mut step, + &mut errors, + ); + assert!(errors.is_empty()); + assert_eq!(with.meta.overrides_etag.as_deref(), Some(entry.etag.as_ref())); + assert_eq!(with.meta.applied_overrides.as_ref(), Some(&entry.overrides)); + assert_eq!(with.meta.overrides_error, None); + assert!(with.meta.display_stale_groups.is_empty(), "a publication shows the current representatives"); +} + +#[tokio::test] +async fn meta_records_failover_load_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let app_config = super::curation_effect_gate::app_config(dir.path()); + app_config.failover.store(std::sync::Arc::new(tuliprox_core::model::FailoverOverrides { + error: Some("bad failover file".to_string()), + ..Default::default() + })); + let target = target_with_options(r#"{"failover_groups":{"enabled":true}}"#); + let mut playlist = vec![group("News", vec![live("solis", "1", "ZDF HD")])]; + let mut step = tuliprox_core::utils::StepMeasure::new("test", |_, _| {}); + let mut errors = Vec::new(); + let batch = crate::processor::playlist::target::process_target_failover_groups( + &app_config, + &target, + &mut playlist, + &mut step, + &mut errors, + ); + assert_eq!(batch.meta.overrides_error.as_deref(), Some("bad failover file")); + assert_eq!(batch.meta.overrides_etag, None); + assert_eq!(batch.meta.applied_overrides, None); +} + +#[tokio::test] +async fn target_without_failover_groups_yields_disabled_batch() { + let dir = tempfile::tempdir().expect("tempdir"); + let app_config = super::curation_effect_gate::app_config(dir.path()); + let target = target_with_options("{}"); + let mut playlist = vec![group("News", vec![live("solis", "1", "ZDF HD")])]; + let mut step = tuliprox_core::utils::StepMeasure::new("test", |_, _| {}); + let mut errors = Vec::new(); + let batch = crate::processor::playlist::target::process_target_failover_groups( + &app_config, + &target, + &mut playlist, + &mut step, + &mut errors, + ); + assert!(!batch.enabled && batch.catalog.is_empty() && batch.groups.is_empty()); +} diff --git a/backend/processing/src/processor/playlist/tests/mapping_stage.rs b/backend/processing/src/processor/playlist/tests/mapping_stage.rs index 395391c8b..95aa81519 100644 --- a/backend/processing/src/processor/playlist/tests/mapping_stage.rs +++ b/backend/processing/src/processor/playlist/tests/mapping_stage.rs @@ -57,6 +57,7 @@ fn processing_context() -> PlaylistProcessingContext { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, }; @@ -71,6 +72,7 @@ fn processing_context() -> PlaylistProcessingContext { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), }; PlaylistProcessingContext { client: reqwest::Client::new(), diff --git a/backend/processing/src/processor/playlist/tests/quality_rejection_fallback.rs b/backend/processing/src/processor/playlist/tests/quality_rejection_fallback.rs index 47c302c4b..16c8d9c3a 100644 --- a/backend/processing/src/processor/playlist/tests/quality_rejection_fallback.rs +++ b/backend/processing/src/processor/playlist/tests/quality_rejection_fallback.rs @@ -196,6 +196,7 @@ fn processing_context_with_events(storage_dir: &Path, events: E) - mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, }; @@ -214,6 +215,7 @@ fn processing_context_with_events(storage_dir: &Path, events: E) - access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), }; PlaylistProcessingContext { client: reqwest::Client::new(), diff --git a/backend/processing/src/processor/probe_handle_guard.rs b/backend/processing/src/processor/probe_handle_guard.rs index 7c3053692..9694b2023 100644 --- a/backend/processing/src/processor/probe_handle_guard.rs +++ b/backend/processing/src/processor/probe_handle_guard.rs @@ -70,6 +70,7 @@ mod tests { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -77,6 +78,7 @@ mod tests { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), } } diff --git a/backend/processing/src/processor/sort.rs b/backend/processing/src/processor/sort.rs index 6ccec0864..6ff3a8e9d 100644 --- a/backend/processing/src/processor/sort.rs +++ b/backend/processing/src/processor/sort.rs @@ -303,7 +303,7 @@ impl SortPlan { )); } for (group_index, (group, indices)) in groups.iter().zip(&self.channel_indices).enumerate() { - validate_permutation(indices, group.channels.len(), &format!("channel group {group_index}"))?; + validate_permutation(indices, group.channels.len(), &format!("failover group {group_index}"))?; } Ok(()) } diff --git a/backend/processing/src/processor/stalker_refresh.rs b/backend/processing/src/processor/stalker_refresh.rs index 5009cc3f3..a8c2a4b08 100644 --- a/backend/processing/src/processor/stalker_refresh.rs +++ b/backend/processing/src/processor/stalker_refresh.rs @@ -939,6 +939,7 @@ mod tests { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -946,6 +947,7 @@ mod tests { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), }) } diff --git a/backend/repository/src/channel_group_repository.rs b/backend/repository/src/channel_group_repository.rs deleted file mode 100644 index 56ef17b6a..000000000 --- a/backend/repository/src/channel_group_repository.rs +++ /dev/null @@ -1,645 +0,0 @@ -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/failover_group_repository.rs b/backend/repository/src/failover_group_repository.rs new file mode 100644 index 000000000..269198696 --- /dev/null +++ b/backend/repository/src/failover_group_repository.rs @@ -0,0 +1,660 @@ +use crate::{ + bplustree::{BPlusTree, BPlusTreeError, BPlusTreeQuery}, + storage_const::{ + FILE_FAILOVER_GROUPS, FILE_FAILOVER_GROUPS_ALIAS, FILE_FAILOVER_GROUPS_CATALOG, FILE_FAILOVER_GROUPS_MEMBER, + FILE_FAILOVER_GROUPS_META, + }, +}; +pub use shared::model::FailoverGroupOrigin; +use shared::model::{ + ChannelMemberRefDto, FailoverGroupPolicy, PlaylistItem, PlaylistItemType, StreamProperties, + TargetFailoverOverridesDto, UUIDType, VirtualId, XtreamCluster, +}; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::Arc, +}; +use tuliprox_core::{ + model::{AppConfig, ConfigTarget}, + utils::remove_file_if_exists, +}; + +#[derive(Debug, Clone)] +pub struct PendingFailoverGroup { + pub group_uuid: UUIDType, + pub key: Arc, + pub origin: FailoverGroupOrigin, + pub policy: FailoverGroupPolicy, + 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 FailoverGroupRecord { + pub group_vid: VirtualId, + pub key: Arc, + pub origin: FailoverGroupOrigin, + pub policy: FailoverGroupPolicy, + pub members: Vec, + pub orphaned: Vec, +} + +impl FailoverGroupRecord { + /// Virtual id of the stored member that `member` refers to. + #[must_use] + pub fn member_vid(&self, member: &ChannelMemberRefDto) -> Option { + self.members + .iter() + .find(|stored| { + stored.input_name.as_ref() == member.input.as_str() + && stored.input_stream_id.as_ref() == member.stream_id.as_str() + }) + .map(|stored| stored.member_vid) + } +} + +/// Eligible live channel of the pre-grouping catalog, in playlist order. The quality of a channel +/// is not stored: it comes from the member health index at read time. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct CatalogRecord { + pub member_ref: ChannelMemberRefDto, + pub natural_key: Option>, + pub dsl_forced: bool, + pub epg_channel_id: Option>, + /// Raw `header.name`, the source of the group name (processing derives it from here). + pub name: Arc, + /// Display title, `header.get_name()`. + pub title: Arc, + pub logo: Arc, + pub playlist_group: Arc, + pub provider_id: u32, + pub has_archive: bool, +} + +/// What the run that published the failover groups applied from the failover files. +#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct FailoverGroupMeta { + /// Etag of the override section used by the run. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub overrides_etag: Option>, + /// Load error of the failover files, in which case no overrides were applied. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub overrides_error: Option, + /// Canonicalized override section the run applied, `None` without a section. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub applied_overrides: Option, + /// Keys of groups whose representative changed by a hot apply after the run: the published playlist item + /// still shows the old one. A playlist update writes a fresh meta, which clears the list. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub display_stale_groups: Vec, +} + +/// New member order and policy of one persisted group, applied without a playlist update. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HotApplyGroupChange { + pub group_vid: VirtualId, + /// Every member of the group, in the new order. + pub member_order: Vec, + pub policy: FailoverGroupPolicy, +} + +/// Everything a playlist publication hands to the failover group store. +/// `Default` is a target without failover groups: nothing is written, stale files are removed. +#[derive(Debug, Clone, Default)] +pub struct FailoverGroupBatch { + pub groups: Vec, + pub catalog: Vec, + pub meta: FailoverGroupMeta, + pub enabled: bool, +} + +impl FailoverGroupBatch { + #[must_use] + pub fn with_groups(groups: Vec) -> Self { Self { groups, enabled: true, ..Self::default() } } + + /// Catalog and metadata to publish, `None` for a target without failover groups. + #[must_use] + pub fn side_files(&self) -> Option> { + self.enabled.then_some(FailoverGroupSideFiles { catalog: &self.catalog, meta: &self.meta }) + } +} + +/// Catalog and metadata of a publication, written whenever failover groups are enabled for the target. +#[derive(Clone, Copy)] +pub struct FailoverGroupSideFiles<'a> { + pub catalog: &'a [CatalogRecord], + pub meta: &'a FailoverGroupMeta, +} + +const PUBLISHED_FILES: [&str; 5] = [ + FILE_FAILOVER_GROUPS, + FILE_FAILOVER_GROUPS_MEMBER, + FILE_FAILOVER_GROUPS_ALIAS, + FILE_FAILOVER_GROUPS_CATALOG, + FILE_FAILOVER_GROUPS_META, +]; + +/// Staged files of a publication, one slot per entry of [`PUBLISHED_FILES`]. A slot without a staged +/// file removes the published file on `publish`. Staged files that are not published are removed on drop. +pub struct FailoverGroupStagedPublication { + target_path: PathBuf, + staged: [Option; PUBLISHED_FILES.len()], +} + +impl FailoverGroupStagedPublication { + const GROUPS: usize = 0; + const MEMBERS: usize = 1; + const ALIASES: usize = 2; + const CATALOG: usize = 3; + const META: usize = 4; + + fn new(target_path: &Path) -> Self { Self { target_path: target_path.to_path_buf(), staged: Default::default() } } + + /// Registers the staged path of `slot` before it is written, so a failed write is cleaned up on drop. + fn stage(&mut self, slot: usize, stage_suffix: &str) -> PathBuf { + let path = self.target_path.join(format!("{}{stage_suffix}", PUBLISHED_FILES[slot])); + self.staged[slot] = Some(path.clone()); + path + } + + pub fn discard(self) { drop(self); } + + pub fn publish(mut self) -> std::io::Result<()> { + for (slot, name) in self.staged.iter_mut().zip(PUBLISHED_FILES) { + let published = self.target_path.join(name); + match slot.take() { + Some(staged) => std::fs::rename(&staged, published).inspect_err(|_| { + let _ = remove_file_if_exists(&staged); + })?, + None => { + let _ = remove_file_if_exists(&published); + } + } + } + Ok(()) + } +} + +impl Drop for FailoverGroupStagedPublication { + fn drop(&mut self) { + for staged in self.staged.iter().flatten() { + let _ = remove_file_if_exists(staged); + } + } +} + +pub struct FailoverGroupStore; + +impl FailoverGroupStore { + pub fn write_staged( + target_path: &Path, + records: &[FailoverGroupRecord], + aliases: &[(VirtualId, VirtualId)], + side_files: Option>, + ) -> std::io::Result { + let mut publication = FailoverGroupStagedPublication::new(target_path); + let stage_suffix = format!(".stage-{}", uuid::Uuid::new_v4()); + + if !records.is_empty() { + let groups_path = publication.stage(FailoverGroupStagedPublication::GROUPS, &stage_suffix); + let mut groups_tree = BPlusTree::::new(); + for record in records { + groups_tree.insert(record.group_vid, record.clone()); + } + groups_tree.store(&groups_path)?; + + let members_path = publication.stage(FailoverGroupStagedPublication::MEMBERS, &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); + } + } + members_tree.store(&members_path)?; + + if !aliases.is_empty() { + let aliases_path = publication.stage(FailoverGroupStagedPublication::ALIASES, &stage_suffix); + let mut aliases_tree = BPlusTree::::new(); + for (alias_vid, group_vid) in aliases { + aliases_tree.insert(*alias_vid, *group_vid); + } + aliases_tree.store(&aliases_path)?; + } + } + + if let Some(side_files) = side_files { + let catalog_path = publication.stage(FailoverGroupStagedPublication::CATALOG, &stage_suffix); + let mut catalog_tree = BPlusTree::::new(); + for (order, record) in (0u32..).zip(side_files.catalog) { + catalog_tree.insert(order, record.clone()); + } + catalog_tree.store(&catalog_path)?; + + let meta_path = publication.stage(FailoverGroupStagedPublication::META, &stage_suffix); + std::fs::write(&meta_path, serde_json::to_vec(side_files.meta).map_err(std::io::Error::other)?)?; + } + + Ok(publication) + } + + pub fn write( + target_path: &Path, + records: &[FailoverGroupRecord], + aliases: &[(VirtualId, VirtualId)], + side_files: Option>, + ) -> std::io::Result<()> { + Self::write_staged(target_path, records, aliases, side_files)?.publish() + } + + /// Rewrites member order and policy of the given groups in place. The member and alias indexes stay + /// valid because the member set of a group does not change. The caller holds the target lock. + pub fn update_order_and_policy(target_path: &Path, changes: &[HotApplyGroupChange]) -> std::io::Result<()> { + if changes.is_empty() { + return Ok(()); + } + let invalid = |msg: String| std::io::Error::new(std::io::ErrorKind::InvalidData, msg); + let mut records = Self::read_all_groups(target_path)?; + let positions: HashMap = + records.iter().enumerate().map(|(pos, record)| (record.group_vid, pos)).collect(); + + for change in changes { + let record = positions + .get(&change.group_vid) + .and_then(|&pos| records.get_mut(pos)) + .ok_or_else(|| invalid(format!("failover group {} is not stored", change.group_vid)))?; + let mut remaining: HashMap = + record.members.drain(..).map(|member| (member.member_vid, member)).collect(); + let reordered = change + .member_order + .iter() + .map(|member_vid| { + remaining.remove(member_vid).ok_or_else(|| { + invalid(format!("member {member_vid} is not part of failover group {}", change.group_vid)) + }) + }) + .collect::>>()?; + if !remaining.is_empty() { + return Err(invalid(format!("member order of failover group {} is incomplete", change.group_vid))); + } + record.members = reordered; + record.policy = change.policy; + } + + let mut groups_tree = BPlusTree::::new(); + for record in records { + groups_tree.insert(record.group_vid, record); + } + Self::replace_file(&target_path.join(FILE_FAILOVER_GROUPS), |staged| groups_tree.store(staged).map(drop)) + } + + /// Applies `changes` and records `meta`, so that a failure in between never leaves groups that are + /// newer than the metadata describing them: the metadata is invalidated (no etag, no applied section) + /// before the groups are rewritten and replaced by `meta` afterwards. The caller holds the target lock. + pub fn apply_hot_change( + target_path: &Path, + changes: &[HotApplyGroupChange], + meta: &FailoverGroupMeta, + ) -> std::io::Result<()> { + Self::apply_hot_change_with(target_path, changes, meta, Self::write_meta) + } + + fn apply_hot_change_with( + target_path: &Path, + changes: &[HotApplyGroupChange], + meta: &FailoverGroupMeta, + write_final_meta: impl FnOnce(&Path, &FailoverGroupMeta) -> std::io::Result<()>, + ) -> std::io::Result<()> { + if !changes.is_empty() { + let invalidated = FailoverGroupMeta { + overrides_etag: None, + overrides_error: meta.overrides_error.clone(), + applied_overrides: None, + display_stale_groups: meta.display_stale_groups.clone(), + }; + Self::write_meta(target_path, &invalidated)?; + Self::update_order_and_policy(target_path, changes)?; + } + write_final_meta(target_path, meta) + } + + /// Replaces the publication metadata. The caller holds the target lock. + pub fn write_meta(target_path: &Path, meta: &FailoverGroupMeta) -> std::io::Result<()> { + let bytes = serde_json::to_vec(meta).map_err(std::io::Error::other)?; + Self::replace_file(&target_path.join(FILE_FAILOVER_GROUPS_META), |staged| std::fs::write(staged, &bytes)) + } + + /// Writes `path` through a staged sibling and renames it over, so readers never see a partial file. + fn replace_file(path: &Path, write: impl FnOnce(&Path) -> std::io::Result<()>) -> std::io::Result<()> { + let mut staged_name = path.as_os_str().to_owned(); + staged_name.push(format!(".stage-{}", uuid::Uuid::new_v4())); + let staged = PathBuf::from(staged_name); + write(&staged).and_then(|()| std::fs::rename(&staged, path)).inspect_err(|_| { + let _ = remove_file_if_exists(&staged); + }) + } + + pub fn delete(target_path: &Path) -> std::io::Result<()> { + for name in PUBLISHED_FILES { + let _ = remove_file_if_exists(&target_path.join(name)); + } + Ok(()) + } + + pub fn read_alias(target_path: &Path, vid: VirtualId) -> std::io::Result> { + let db_path = target_path.join(FILE_FAILOVER_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_FAILOVER_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_FAILOVER_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_FAILOVER_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_FAILOVER_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) + } + + /// All catalog records in playlist order; empty when the target has no catalog. + pub fn read_catalog(target_path: &Path) -> std::io::Result> { + let db_path = target_path.join(FILE_FAILOVER_GROUPS_CATALOG); + 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 (_order, record) = entry?; + records.push(record); + } + Ok(records) + } + + /// Publication metadata; default when the target has none. + pub fn read_meta(target_path: &Path) -> std::io::Result { + match std::fs::read(target_path.join(FILE_FAILOVER_GROUPS_META)) { + Ok(bytes) => serde_json::from_slice(&bytes).map_err(std::io::Error::other), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(FailoverGroupMeta::default()), + Err(err) => Err(err), + } + } + + 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 meta = Self::read_meta(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(FailoverGroupTargetData { generation, groups, members, aliases, meta }) + } +} + +#[derive(Debug, Clone, Default)] +pub struct FailoverGroupTargetData { + pub generation: u64, + pub groups: HashMap>, + pub members: HashMap, + pub aliases: HashMap, + pub meta: FailoverGroupMeta, +} + +impl FailoverGroupTargetData { + /// `(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))) + }) + } +} + +/// Catalog together with the generation it was loaded for. +type GenerationCatalog = (u64, Arc<[CatalogRecord]>); + +pub struct FailoverGroupCache { + targets: tokio::sync::RwLock>>, + /// Catalog per target and generation. Kept apart from the playback data: only the editor API reads it. + catalogs: 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, + #[cfg(any(test, feature = "test-support"))] + catalog_loads: std::sync::atomic::AtomicUsize, +} + +impl Default for FailoverGroupCache { + fn default() -> Self { Self::new() } +} + +impl FailoverGroupCache { + #[must_use] + pub fn new() -> Self { + Self { + targets: tokio::sync::RwLock::new(HashMap::new()), + catalogs: 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), + #[cfg(any(test, feature = "test-support"))] + catalog_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 + } + + /// Runs a blocking store read under the target's file lock; `None` without a storage path or when the + /// read task did not finish. + async fn read_store( + app_config: &Arc, + target_name: &str, + read: impl FnOnce(&Path) -> std::io::Result + Send + 'static, + ) -> Option> { + let target_path = crate::get_target_storage_path(&app_config.config.load(), target_name)?; + let file_lock = app_config.file_locks.read_lock(&crate::get_target_id_mapping_file(&target_path)).await; + tokio::task::spawn_blocking(move || { + let _guard = file_lock; + read(&target_path) + }) + .await + .ok() + } + + 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.failover_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 = || FailoverGroupTargetData { generation: current_generation, ..FailoverGroupTargetData::default() }; + + #[cfg(any(test, feature = "test-support"))] + self.loads.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let loaded = Self::read_store(app_config, &target.name, move |target_path| { + FailoverGroupStore::load_target_data(target_path, current_generation) + }) + .await + .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 + } + + async fn cached_catalog(&self, target_name: &str, generation: u64) -> Option> { + let guard = self.catalogs.read().await; + guard.get(target_name).filter(|(cached, _)| *cached == generation).map(|(_, catalog)| Arc::clone(catalog)) + } + + /// The catalog of the pre-grouping playlist of `target`, loaded once per generation. Empty when the + /// target has no catalog yet. A failed read is reported and not cached. + pub async fn get_or_load_catalog( + &self, + app_config: &Arc, + playlists: &crate::PlaylistStorageState, + target: &ConfigTarget, + ) -> std::io::Result> { + let current_generation = playlists.failover_group_generation(&target.name); + if let Some(cached) = self.cached_catalog(&target.name, current_generation).await { + return Ok(cached); + } + + let _load_guard = self.load_lock(&target.name).lock_owned().await; + if let Some(cached) = self.cached_catalog(&target.name, current_generation).await { + return Ok(cached); + } + + #[cfg(any(test, feature = "test-support"))] + self.catalog_loads.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let records = match Self::read_store(app_config, &target.name, FailoverGroupStore::read_catalog).await { + Some(result) => result?, + None => Vec::new(), + }; + let catalog: Arc<[CatalogRecord]> = Arc::from(records); + self.catalogs.write().await.insert(target.name.clone(), (current_generation, Arc::clone(&catalog))); + Ok(catalog) + } + + #[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) } + + #[cfg(any(test, feature = "test-support"))] + pub fn catalog_load_count(&self) -> usize { self.catalog_loads.load(std::sync::atomic::Ordering::Relaxed) } +} + +#[cfg(test)] +mod tests; diff --git a/backend/repository/src/failover_group_repository/tests.rs b/backend/repository/src/failover_group_repository/tests.rs new file mode 100644 index 000000000..fb8342fce --- /dev/null +++ b/backend/repository/src/failover_group_repository/tests.rs @@ -0,0 +1,129 @@ +use super::*; +use shared::model::{PlaylistItemType, XtreamCluster}; + +const GROUP_VID: VirtualId = VirtualId(100); + +fn member(vid: u32, stream_id: &str) -> GroupMember { + GroupMember { + member_vid: VirtualId(vid), + input_name: Arc::from("a"), + provider_id: 0, + input_stream_id: Arc::from(stream_id), + url: Arc::from("http://provider.test/live"), + item_type: PlaylistItemType::Live, + cluster: XtreamCluster::Live, + title: Arc::from("ZDF"), + archive: None, + epg_channel_id: None, + additional_properties: None, + } +} + +fn record() -> FailoverGroupRecord { + FailoverGroupRecord { + group_vid: GROUP_VID, + key: Arc::from("zdf"), + origin: FailoverGroupOrigin::Auto, + policy: FailoverGroupPolicy::Auto, + members: vec![member(1, "1"), member(2, "2"), member(3, "3")], + orphaned: Vec::new(), + } +} + +fn applied_meta(etag: &str) -> FailoverGroupMeta { + FailoverGroupMeta { + overrides_etag: Some(Arc::from(etag)), + applied_overrides: Some(TargetFailoverOverridesDto::new("t")), + ..FailoverGroupMeta::default() + } +} + +fn store_with(meta: &FailoverGroupMeta) -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("tempdir"); + FailoverGroupStore::write(dir.path(), &[record()], &[], Some(FailoverGroupSideFiles { catalog: &[], meta })) + .expect("store written"); + dir +} + +fn reorder() -> Vec { + vec![HotApplyGroupChange { + group_vid: GROUP_VID, + member_order: vec![VirtualId(3), VirtualId(1), VirtualId(2)], + policy: FailoverGroupPolicy::Priority, + }] +} + +fn stored_order(dir: &Path) -> Vec { + let stored = FailoverGroupStore::read_group(dir, GROUP_VID).expect("read").expect("group"); + stored.members.iter().map(|member| member.member_vid).collect() +} + +#[test] +fn hot_change_writes_groups_then_final_meta() { + let dir = store_with(&applied_meta("etag-1")); + let next = FailoverGroupMeta { display_stale_groups: vec!["zdf".to_string()], ..applied_meta("etag-2") }; + + FailoverGroupStore::apply_hot_change(dir.path(), &reorder(), &next).expect("applies"); + + assert_eq!(stored_order(dir.path()), [VirtualId(3), VirtualId(1), VirtualId(2)]); + assert_eq!(FailoverGroupStore::read_meta(dir.path()).expect("meta"), next); +} + +#[test] +fn failure_after_the_groups_write_leaves_the_meta_invalidated() { + let dir = store_with(&applied_meta("etag-1")); + let next = FailoverGroupMeta { display_stale_groups: vec!["zdf".to_string()], ..applied_meta("etag-2") }; + + let result = FailoverGroupStore::apply_hot_change_with(dir.path(), &reorder(), &next, |_, _| { + Err(std::io::Error::other("injected meta write failure")) + }); + + assert!(result.is_err()); + // The groups are already reordered, so the meta must not claim they still match the old section. + assert_eq!(stored_order(dir.path()), [VirtualId(3), VirtualId(1), VirtualId(2)]); + let meta = FailoverGroupStore::read_meta(dir.path()).expect("meta"); + assert_eq!(meta.overrides_etag, None); + assert_eq!(meta.applied_overrides, None); + assert_eq!(meta.display_stale_groups, ["zdf"]); +} + +#[test] +fn failed_groups_write_leaves_the_meta_invalidated_and_groups_untouched() { + let dir = store_with(&applied_meta("etag-1")); + let unknown = vec![HotApplyGroupChange { + group_vid: GROUP_VID, + member_order: vec![VirtualId(1), VirtualId(2), VirtualId(9)], + policy: FailoverGroupPolicy::Auto, + }]; + + assert!(FailoverGroupStore::apply_hot_change(dir.path(), &unknown, &applied_meta("etag-2")).is_err()); + + assert_eq!(stored_order(dir.path()), [VirtualId(1), VirtualId(2), VirtualId(3)]); + let meta = FailoverGroupStore::read_meta(dir.path()).expect("meta"); + assert_eq!((meta.overrides_etag, meta.applied_overrides), (None, None)); +} + +#[test] +fn etag_only_change_writes_just_the_meta() { + let dir = store_with(&applied_meta("etag-1")); + + FailoverGroupStore::apply_hot_change_with(dir.path(), &[], &applied_meta("etag-2"), |path, meta| { + // No invalidation step: the meta on disk is still the old one when the final write runs. + assert_eq!(FailoverGroupStore::read_meta(path).expect("meta").overrides_etag.as_deref(), Some("etag-1")); + FailoverGroupStore::write_meta(path, meta) + }) + .expect("applies"); + + assert_eq!(FailoverGroupStore::read_meta(dir.path()).expect("meta").overrides_etag.as_deref(), Some("etag-2")); +} + +#[test] +fn member_vid_resolves_by_input_and_stream_id() { + let record = record(); + let find = |input: &str, stream_id: &str| { + record.member_vid(&ChannelMemberRefDto { input: input.to_string(), stream_id: stream_id.to_string() }) + }; + assert_eq!(find("a", "2"), Some(VirtualId(2))); + assert_eq!(find("b", "2"), None); + assert_eq!(find("a", "9"), None); +} diff --git a/backend/repository/src/failover_repository.rs b/backend/repository/src/failover_repository.rs new file mode 100644 index 000000000..11115ace1 --- /dev/null +++ b/backend/repository/src/failover_repository.rs @@ -0,0 +1,348 @@ +use crate::target_bouquet_repository::sanitize_target_prefix; +use log::debug; +use shared::{ + error::TuliproxError, + model::{FailoverFileDto, OverrideValidationError, TargetFailoverOverridesDto, FAILOVER_FILE_VERSION}, +}; +use std::{ + collections::{hash_map::Entry, HashMap}, + path::{Path, PathBuf}, + sync::Arc, +}; +use tuliprox_core::{ + model::{AppConfig, FailoverOverrides, TargetOverridesEntry}, + utils::{ + collect_config_yaml_files, remove_file_if_exists, resolve_failover_path, write_text_file_atomic, FailoverSource, + }, +}; + +/// Serializes all failover write-backs; the per-file lock only covers the file being rewritten. +const FAILOVER_MUTATION_LOCK: &str = "failover_overrides_mutation"; + +#[derive(Debug)] +pub enum FailoverWriteError { + /// The section on disk differs from what the caller based its change on. + Conflict { + current_etag: Option, + }, + /// The section has validation findings and was not written. + Invalid(Vec), + /// The failover files cannot be edited, because they failed to load. + Locked(String), + Io(TuliproxError), +} + +impl From for FailoverWriteError { + fn from(err: TuliproxError) -> Self { Self::Io(err) } +} + +fn failover_error(message: String) -> TuliproxError { TuliproxError::FailoverGroup(message) } + +fn failover_files(source: &FailoverSource) -> Result, TuliproxError> { + match source { + FailoverSource::File(path) => Ok(if path.is_file() { vec![path.clone()] } else { Vec::new() }), + FailoverSource::Directory(dir) if dir.is_dir() => collect_config_yaml_files(dir) + .map_err(|err| failover_error(format!("Failed to read failover directory {}: {err}", dir.display()))), + FailoverSource::Directory(_) => Ok(Vec::new()), + } +} + +/// True for content without any YAML document: empty, whitespace only or comments only. +fn is_blank_yaml(content: &str) -> bool { + content.lines().map(str::trim).all(|line| line.is_empty() || line.starts_with('#')) +} + +/// Reads one failover file. The content is never env-resolved, so placeholders survive a write-back. +fn read_failover_file(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|err| failover_error(format!("Failed to read failover file {}: {err}", path.display())))?; + if is_blank_yaml(&content) { + return Ok(FailoverFileDto { version: FAILOVER_FILE_VERSION, targets: Vec::new() }); + } + let dto: FailoverFileDto = serde_saphyr::from_str(&content) + .map_err(|err| failover_error(format!("Failed to parse failover file {}: {err}", path.display())))?; + if dto.version != FAILOVER_FILE_VERSION { + return Err(failover_error(format!( + "Unsupported failover file version {} in {}, expected {FAILOVER_FILE_VERSION}", + dto.version, + path.display() + ))); + } + Ok(dto) +} + +/// Adds `section` of `file` to `sections`; a target must be defined in one place only. +fn insert_section( + sections: &mut HashMap, Arc>, + mut section: TargetFailoverOverridesDto, + file: &Path, +) -> Result<(), TuliproxError> { + section.canonicalize(); + match sections.entry(Arc::from(section.target.as_str())) { + Entry::Occupied(existing) => Err(failover_error(format!( + "Target '{}' is defined in {} and {}", + existing.key(), + existing.get().file.display(), + file.display() + ))), + Entry::Vacant(slot) => { + let findings = section.validate(); + let etag = Arc::from(section.etag()); + slot.insert(Arc::new(TargetOverridesEntry { + overrides: section, + file: file.to_path_buf(), + etag, + findings, + })); + Ok(()) + } + } +} + +/// Reads all failover files of `source` into a snapshot. A missing file or directory yields an empty snapshot. +pub fn load_failover_overrides(source: &FailoverSource) -> Result { + let files = failover_files(source)?; + let mut sections: HashMap, Arc> = HashMap::new(); + for file in &files { + for section in read_failover_file(file)?.targets { + insert_section(&mut sections, section, file)?; + } + } + Ok(FailoverOverrides { source: Some(source.clone()), sections, files_used: files, error: None }) +} + +/// Runs blocking work off the async runtime; a task that did not finish is a failover error. +pub async fn run_blocking(job: impl FnOnce() -> T + Send + 'static) -> Result { + tokio::task::spawn_blocking(job).await.map_err(|err| failover_error(format!("Failover task failed: {err}"))) +} + +async fn load_from_disk(source: &FailoverSource) -> Result { + let source = source.clone(); + run_blocking(move || load_failover_overrides(&source)).await? +} + +/// Reads the failover files of `source` under the failover mutation lock, so the result never mixes in a +/// half finished Web UI write. Nothing is published. +pub async fn load_failover_snapshot( + app_config: &AppConfig, + source: &FailoverSource, +) -> Result { + let _mutation_lock = app_config.file_locks.write_lock_str(FAILOVER_MUTATION_LOCK).await; + load_from_disk(source).await +} + +/// Re-reads the failover files and swaps the snapshot, both under the failover mutation lock, so a reload +/// triggered by the file watcher cannot publish content older than a concurrent Web UI save. +/// Returns the previous and the new snapshot; a failing read keeps the previous one active. +pub async fn reload_failover_snapshot( + app_config: &AppConfig, + source: &FailoverSource, +) -> Result<(Arc, Arc), TuliproxError> { + let _mutation_lock = app_config.file_locks.write_lock_str(FAILOVER_MUTATION_LOCK).await; + let next = Arc::new(load_from_disk(source).await?); + let previous = app_config.failover.swap(Arc::clone(&next)); + Ok((previous, next)) +} + +/// Source of the current snapshot; resolved from the paths when no snapshot was ever loaded. +fn editable_source(app_config: &AppConfig) -> Result { + let snapshot = app_config.failover.load(); + if let Some(error) = &snapshot.error { + return Err(FailoverWriteError::Locked(error.clone())); + } + Ok(snapshot.source.clone().unwrap_or_else(|| { + let paths = app_config.paths.load(); + let config = app_config.config.load(); + resolve_failover_path( + &paths.config_path, + paths.failover_file_path.as_deref().or(config.failover_path.as_deref()), + ) + })) +} + +fn prepare_section( + target: &str, + section: Option, +) -> Result, FailoverWriteError> { + let Some(mut section) = section else { + return Ok(None); + }; + section.canonicalize(); + section.target = target.to_string(); + if section.is_empty() { + return Ok(None); + } + let findings = section.validate(); + if findings.is_empty() { + Ok(Some(section)) + } else { + Err(FailoverWriteError::Invalid(findings)) + } +} + +fn section_index(file: &FailoverFileDto, target: &str) -> Option { + file.targets.iter().position(|section| section.target.trim() == target) +} + +fn canonical_etag(section: &TargetFailoverOverridesDto) -> String { + let mut section = section.clone(); + section.canonicalize(); + section.etag() +} + +fn check_etag(current_etag: Option, expected_etag: Option<&str>) -> Result<(), FailoverWriteError> { + if current_etag.as_deref() == expected_etag { + Ok(()) + } else { + Err(FailoverWriteError::Conflict { current_etag }) + } +} + +/// File that receives a section no file defines yet. +fn new_section_file(source: &FailoverSource, target: &str) -> PathBuf { + match source { + FailoverSource::File(path) => path.clone(), + FailoverSource::Directory(dir) => { + let base = sanitize_target_prefix(target); + let mut candidate = dir.join(format!("{base}.yml")); + let mut suffix = 2; + while candidate.exists() { + candidate = dir.join(format!("{base}-{suffix}.yml")); + suffix += 1; + } + candidate + } + } +} + +/// Writes `file` back, or deletes it when it holds no sections and lives in a failover directory. +async fn persist_file( + app_config: &AppConfig, + source: &FailoverSource, + path: &Path, + file: &FailoverFileDto, +) -> Result<(), TuliproxError> { + if file.targets.is_empty() && matches!(source, FailoverSource::Directory(_)) { + return remove_file_if_exists(path) + .map_err(|err| failover_error(format!("Failed to delete failover file {}: {err}", path.display()))); + } + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await.map_err(|err| { + failover_error(format!("Failed to create failover directory {}: {err}", parent.display())) + })?; + } + let yaml = serde_saphyr::to_string(file) + .map_err(|err| failover_error(format!("Failed to serialize failover file {}: {err}", path.display())))?; + write_text_file_atomic(path, &yaml) + .await + .map_err(|err| failover_error(format!("Failed to write failover file {}: {err}", path.display())))?; + app_config.file_locks.mark_internal_write_content(path, yaml.as_bytes()).await; + debug!("Saved failover overrides to {}", path.display()); + Ok(()) +} + +/// Snapshot after `path` was rewritten with `file`: the `disk` snapshot read under the same lock, with the +/// sections of that file replaced. Only the directory listing is read again, no failover file is parsed twice. +fn snapshot_after_write( + source: &FailoverSource, + disk: FailoverOverrides, + path: &Path, + file: FailoverFileDto, +) -> Result { + let mut sections = disk.sections; + sections.retain(|_, entry| entry.file != path); + for section in file.targets { + insert_section(&mut sections, section, path)?; + } + Ok(FailoverOverrides { source: Some(source.clone()), sections, files_used: failover_files(source)?, error: None }) +} + +/// Publishes the snapshot that matches the disk after a write. +fn publish_snapshot(app_config: &AppConfig, snapshot: FailoverOverrides) -> Arc { + let snapshot = Arc::new(snapshot); + app_config.failover.store(Arc::clone(&snapshot)); + snapshot +} + +/// Replaces (`Some`) or removes (`None`) the section of `target`. +/// +/// `expected_etag` is the etag the caller based its change on, `None` meaning the section must not exist yet. +/// The check runs under the locks against the file content on disk, so hand edits that were not +/// reloaded yet are detected. Returns the stored entry, `None` when the section was removed. +pub async fn write_failover_section( + app_config: &AppConfig, + target: &str, + expected_etag: Option<&str>, + section: Option, +) -> Result>, FailoverWriteError> { + let section = prepare_section(target, section)?; + let source = editable_source(app_config)?; + + let _mutation_lock = app_config.file_locks.write_lock_str(FAILOVER_MUTATION_LOCK).await; + let disk = load_from_disk(&source).await.map_err(|err| FailoverWriteError::Locked(err.to_string()))?; + let path = disk.sections.get(target).map_or_else(|| new_section_file(&source, target), |entry| entry.file.clone()); + + let _file_lock = app_config.file_locks.write_lock(&path).await; + let mut file = if path.is_file() { + let file_path = path.clone(); + run_blocking(move || read_failover_file(&file_path)).await?? + } else { + FailoverFileDto { version: FAILOVER_FILE_VERSION, targets: Vec::new() } + }; + + let index = section_index(&file, target); + check_etag(index.map(|idx| canonical_etag(&file.targets[idx])), expected_etag)?; + match (index, section) { + (Some(idx), Some(section)) => file.targets[idx] = section, + (Some(idx), None) => { + file.targets.remove(idx); + } + (None, Some(section)) => file.targets.push(section), + (None, None) => return Ok(None), + } + + persist_file(app_config, &source, &path, &file).await?; + let snapshot = publish_snapshot(app_config, snapshot_after_write(&source, disk, &path, file)?); + Ok(snapshot.sections.get(target).map(Arc::clone)) +} + +/// Moves the section of `from` to the target `to` by rewriting its `target:` field in place. +/// Conflicts when `from` is missing or changed on disk, or when `to` already has a section. +pub async fn reassign_failover_section( + app_config: &AppConfig, + from: &str, + to: &str, + expected_etag: &str, +) -> Result, FailoverWriteError> { + let source = editable_source(app_config)?; + + let _mutation_lock = app_config.file_locks.write_lock_str(FAILOVER_MUTATION_LOCK).await; + let disk = load_from_disk(&source).await.map_err(|err| FailoverWriteError::Locked(err.to_string()))?; + if let Some(taken) = disk.sections.get(to) { + return Err(FailoverWriteError::Conflict { current_etag: Some(taken.etag.to_string()) }); + } + let Some(owner) = disk.sections.get(from) else { + return Err(FailoverWriteError::Conflict { current_etag: None }); + }; + let path = owner.file.clone(); + + let _file_lock = app_config.file_locks.write_lock(&path).await; + let mut file = { + let file_path = path.clone(); + run_blocking(move || read_failover_file(&file_path)).await?? + }; + let Some(index) = section_index(&file, from) else { + return Err(FailoverWriteError::Conflict { current_etag: None }); + }; + check_etag(Some(canonical_etag(&file.targets[index])), Some(expected_etag))?; + file.targets[index].target = to.to_string(); + + persist_file(app_config, &source, &path, &file).await?; + let snapshot = publish_snapshot(app_config, snapshot_after_write(&source, disk, &path, file)?); + snapshot.sections.get(to).map(Arc::clone).ok_or_else(|| { + FailoverWriteError::Io(failover_error(format!("Section '{to}' is missing after writing {}", path.display()))) + }) +} + +#[cfg(test)] +mod tests; diff --git a/backend/repository/src/failover_repository/tests.rs b/backend/repository/src/failover_repository/tests.rs new file mode 100644 index 000000000..b88862c7f --- /dev/null +++ b/backend/repository/src/failover_repository/tests.rs @@ -0,0 +1,583 @@ +use super::*; +use arc_swap::{ArcSwap, ArcSwapOption}; +use shared::model::{ChannelMemberRefDto, ConfigPaths, FailoverFileDto, ManualFailoverGroupDto}; +use std::path::Path; +use tuliprox_core::{ + model::{Config, MediaToolCapabilities, SourcesConfig}, + utils::FileLockManager, +}; + +fn test_app_config(config_path: &Path) -> 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_lossy().into_owned(), + 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, + failover_file_path: 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()), + failover: Arc::default(), + } +} + +/// App config whose snapshot was loaded from `source`, like the startup loader does. +fn loaded_app_config(config_path: &Path, source: &FailoverSource) -> AppConfig { + let app_config = test_app_config(config_path); + app_config.failover.store(Arc::new(load_failover_overrides(source).expect("loads"))); + app_config +} + +fn member(input: &str, stream_id: &str) -> ChannelMemberRefDto { + ChannelMemberRefDto { input: input.to_string(), stream_id: stream_id.to_string() } +} + +fn section(target: &str, group_id: &str) -> TargetFailoverOverridesDto { + let mut dto = TargetFailoverOverridesDto::new(target); + dto.groups.push(ManualFailoverGroupDto { + id: group_id.to_string(), + name: format!("Group {group_id}"), + members: vec![member("prov-a", "1")], + ..ManualFailoverGroupDto::default() + }); + dto +} + +fn write_file(path: &Path, targets: Vec) { + let dto = FailoverFileDto { version: 1, targets }; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("create dir"); + } + std::fs::write(path, serde_saphyr::to_string(&dto).expect("serialize")).expect("write file"); +} + +fn read_file(path: &Path) -> FailoverFileDto { + serde_saphyr::from_str(&std::fs::read_to_string(path).expect("read file")).expect("parse file") +} + +fn file_names(dir: &Path) -> Vec { + let mut names: Vec = std::fs::read_dir(dir) + .expect("read dir") + .map(|entry| entry.expect("entry").file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + names +} + +#[test] +fn load_merges_files_sorted_by_name() { + let dir = tempfile::tempdir().expect("tempdir"); + let failover_dir = dir.path().join("failover.d"); + write_file(&failover_dir.join("b.yml"), vec![section("Kids", "kids-1")]); + write_file(&failover_dir.join("a.yml"), vec![section("Main", "main-1"), section("News", "news-1")]); + + let loaded = load_failover_overrides(&FailoverSource::Directory(failover_dir.clone())).expect("loads"); + + assert!(loaded.error.is_none()); + assert_eq!(loaded.files_used, vec![failover_dir.join("a.yml"), failover_dir.join("b.yml")]); + let mut targets: Vec<&str> = loaded.sections.keys().map(AsRef::as_ref).collect(); + targets.sort_unstable(); + assert_eq!(targets, ["Kids", "Main", "News"]); + let main = loaded.sections.get("Main").expect("main"); + assert_eq!(main.file, failover_dir.join("a.yml")); + assert_eq!(main.etag.as_ref(), main.overrides.etag()); + assert!(main.findings.is_empty()); +} + +#[test] +fn load_reports_validation_findings_without_failing() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("failover.yml"); + write_file(&path, vec![section("Main", "Bad Id")]); + + let loaded = load_failover_overrides(&FailoverSource::File(path)).expect("loads"); + + let main = loaded.sections.get("Main").expect("main"); + assert_eq!(main.findings, vec![OverrideValidationError::InvalidManualId("Bad Id".to_string())]); +} + +#[test] +fn load_rejects_duplicate_target_across_files() { + let dir = tempfile::tempdir().expect("tempdir"); + let failover_dir = dir.path().join("failover.d"); + write_file(&failover_dir.join("a.yml"), vec![section("Main", "g1")]); + write_file(&failover_dir.join("b.yml"), vec![section("Main", "g2")]); + + let err = load_failover_overrides(&FailoverSource::Directory(failover_dir.clone())).expect_err("duplicate"); + + let message = err.to_string(); + assert!(message.contains("Target 'Main' is defined in"), "{message}"); + assert!(message.contains("a.yml") && message.contains("b.yml"), "{message}"); +} + +#[test] +fn load_rejects_duplicate_target_in_one_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("failover.yml"); + write_file(&path, vec![section("Main", "g1"), section("Main", "g2")]); + + let err = load_failover_overrides(&FailoverSource::File(path)).expect_err("duplicate"); + + assert!(err.to_string().contains("Target 'Main' is defined in"), "{err}"); +} + +#[test] +fn load_rejects_unsupported_version() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("failover.yml"); + std::fs::write(&path, "version: 2\ntargets: []\n").expect("write"); + + let err = load_failover_overrides(&FailoverSource::File(path)).expect_err("version"); + + assert!(err.to_string().contains("version"), "{err}"); +} + +#[test] +fn load_rejects_invalid_yaml() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("failover.yml"); + std::fs::write(&path, "version: [1\n").expect("write"); + + assert!(load_failover_overrides(&FailoverSource::File(path)).is_err()); +} + +#[test] +fn load_missing_source_is_empty() { + let dir = tempfile::tempdir().expect("tempdir"); + for source in [ + FailoverSource::File(dir.path().join("failover.yml")), + FailoverSource::Directory(dir.path().join("failover.d")), + ] { + let loaded = load_failover_overrides(&source).expect("loads"); + assert!(loaded.sections.is_empty()); + assert!(loaded.files_used.is_empty()); + assert!(loaded.error.is_none()); + assert_eq!(loaded.source, Some(source)); + } +} + +#[tokio::test] +async fn write_updates_section_in_owning_file_and_keeps_other_targets() { + let dir = tempfile::tempdir().expect("tempdir"); + let failover_dir = dir.path().join("failover.d"); + write_file(&failover_dir.join("a.yml"), vec![section("Main", "main-1"), section("News", "news-1")]); + write_file(&failover_dir.join("b.yml"), vec![section("Kids", "kids-1")]); + let source = FailoverSource::Directory(failover_dir.clone()); + let app_config = loaded_app_config(dir.path(), &source); + let old_etag = app_config.failover_section("Main").expect("main").etag.to_string(); + + let entry = write_failover_section(&app_config, "Main", Some(&old_etag), Some(section("ignored", "main-2"))) + .await + .expect("writes") + .expect("entry"); + + assert_eq!(entry.overrides.target, "Main"); + assert_eq!(entry.overrides.groups[0].id, "main-2"); + assert_ne!(entry.etag.as_ref(), old_etag); + let file = read_file(&failover_dir.join("a.yml")); + let targets: Vec<&str> = file.targets.iter().map(|t| t.target.as_str()).collect(); + assert_eq!(targets, ["Main", "News"]); + assert_eq!(file.targets[0].groups[0].id, "main-2"); + assert_eq!(file.targets[1].groups[0].id, "news-1"); + assert_eq!(read_file(&failover_dir.join("b.yml")).targets[0].groups[0].id, "kids-1"); + assert_eq!(app_config.failover_section("Main").expect("main").etag, entry.etag); + assert_eq!(app_config.failover.load().files_used.len(), 2); +} + +#[tokio::test] +async fn write_new_section_creates_target_file_in_dir_mode() { + let dir = tempfile::tempdir().expect("tempdir"); + let failover_dir = dir.path().join("failover.d"); + let source = FailoverSource::Directory(failover_dir.clone()); + let app_config = loaded_app_config(dir.path(), &source); + + let entry = write_failover_section(&app_config, "Main TV", None, Some(section("Main TV", "g1"))) + .await + .expect("writes") + .expect("entry"); + assert_eq!(entry.file, failover_dir.join("Main_TV.yml")); + assert_eq!(read_file(&entry.file).targets[0].target, "Main TV"); + + // a name collision with a file that defines other targets gets a numeric suffix + write_file(&failover_dir.join("Kids.yml"), vec![section("Other", "o1")]); + let reloaded = loaded_app_config(dir.path(), &source); + let kids = write_failover_section(&reloaded, "Kids", None, Some(section("Kids", "k1"))) + .await + .expect("writes") + .expect("entry"); + assert_eq!(kids.file, failover_dir.join("Kids-2.yml")); + assert_eq!(file_names(&failover_dir), ["Kids-2.yml", "Kids.yml", "Main_TV.yml"]); +} + +#[tokio::test] +async fn write_new_section_appends_in_file_mode() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("failover.yml"); + let source = FailoverSource::File(path.clone()); + let app_config = loaded_app_config(dir.path(), &source); + + // the file does not exist yet: it is created with version 1 + write_failover_section(&app_config, "Main", None, Some(section("Main", "g1"))).await.expect("creates"); + let created = read_file(&path); + assert_eq!(created.version, 1); + assert_eq!(created.targets.len(), 1); + + // an existing file gets the new section at the end + write_failover_section(&app_config, "Kids", None, Some(section("Kids", "g2"))).await.expect("appends"); + let appended = read_file(&path); + let targets: Vec<&str> = appended.targets.iter().map(|t| t.target.as_str()).collect(); + assert_eq!(targets, ["Main", "Kids"]); + assert_eq!(app_config.failover.load().files_used, vec![path]); +} + +#[tokio::test] +async fn write_removes_empty_section_and_deletes_empty_dir_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let failover_dir = dir.path().join("failover.d"); + write_file(&failover_dir.join("main.yml"), vec![section("Main", "g1")]); + write_file(&failover_dir.join("shared.yml"), vec![section("Kids", "k1"), section("News", "n1")]); + let source = FailoverSource::Directory(failover_dir.clone()); + let app_config = loaded_app_config(dir.path(), &source); + + // an empty section counts as removal; the file without sections is deleted + let main_etag = app_config.failover_section("Main").expect("main").etag.to_string(); + let result = + write_failover_section(&app_config, "Main", Some(&main_etag), Some(TargetFailoverOverridesDto::new("Main"))) + .await + .expect("removes"); + assert!(result.is_none()); + assert_eq!(file_names(&failover_dir), ["shared.yml"]); + assert!(app_config.failover_section("Main").is_none()); + + // removing one of two sections keeps the file with the remaining one + let kids_etag = app_config.failover_section("Kids").expect("kids").etag.to_string(); + write_failover_section(&app_config, "Kids", Some(&kids_etag), None).await.expect("removes"); + let remaining = read_file(&failover_dir.join("shared.yml")); + assert_eq!(remaining.targets.len(), 1); + assert_eq!(remaining.targets[0].target, "News"); +} + +#[tokio::test] +async fn write_removal_keeps_empty_targets_list_in_file_mode() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("failover.yml"); + write_file(&path, vec![section("Main", "g1")]); + let source = FailoverSource::File(path.clone()); + let app_config = loaded_app_config(dir.path(), &source); + let etag = app_config.failover_section("Main").expect("main").etag.to_string(); + + write_failover_section(&app_config, "Main", Some(&etag), None).await.expect("removes"); + + let file = read_file(&path); + assert_eq!(file.version, 1); + assert!(file.targets.is_empty()); + assert!(app_config.failover.load().sections.is_empty()); +} + +#[tokio::test] +async fn write_rejects_stale_etag_and_unexpected_existing_section() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("failover.yml"); + write_file(&path, vec![section("Main", "g1")]); + let app_config = loaded_app_config(dir.path(), &FailoverSource::File(path.clone())); + let current = app_config.failover_section("Main").expect("main").etag.to_string(); + + let stale = + write_failover_section(&app_config, "Main", Some("0000000000000000"), Some(section("Main", "g2"))).await; + assert!(matches!(stale, Err(FailoverWriteError::Conflict { current_etag: Some(ref etag) }) if *etag == current)); + + let existing = write_failover_section(&app_config, "Main", None, Some(section("Main", "g2"))).await; + assert!(matches!(existing, Err(FailoverWriteError::Conflict { current_etag: Some(ref etag) }) if *etag == current)); + + let missing = + write_failover_section(&app_config, "Kids", Some("0000000000000000"), Some(section("Kids", "g3"))).await; + assert!(matches!(missing, Err(FailoverWriteError::Conflict { current_etag: None }))); + assert_eq!(read_file(&path).targets[0].groups[0].id, "g1"); +} + +#[tokio::test] +async fn write_rejects_invalid_section() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("failover.yml"); + let app_config = loaded_app_config(dir.path(), &FailoverSource::File(path.clone())); + + let result = write_failover_section(&app_config, "Main", None, Some(section("Main", "Bad Id"))).await; + + match result { + Err(FailoverWriteError::Invalid(findings)) => { + assert_eq!(findings, vec![OverrideValidationError::InvalidManualId("Bad Id".to_string())]); + } + other => panic!("expected Invalid, got {other:?}"), + } + assert!(!path.exists()); +} + +#[tokio::test] +async fn write_detects_disk_change_not_yet_reloaded() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("failover.yml"); + write_file(&path, vec![section("Main", "g1")]); + let app_config = loaded_app_config(dir.path(), &FailoverSource::File(path.clone())); + let loaded_etag = app_config.failover_section("Main").expect("main").etag.to_string(); + + // hand edit after load, before any reload + write_file(&path, vec![section("Main", "hand-edit")]); + + let result = write_failover_section(&app_config, "Main", Some(&loaded_etag), Some(section("Main", "g2"))).await; + + match result { + Err(FailoverWriteError::Conflict { current_etag: Some(current) }) => assert_ne!(current, loaded_etag), + other => panic!("expected Conflict, got {other:?}"), + } + assert_eq!(read_file(&path).targets[0].groups[0].id, "hand-edit"); +} + +#[tokio::test] +async fn write_detects_new_section_added_on_disk_not_yet_reloaded() { + let dir = tempfile::tempdir().expect("tempdir"); + let failover_dir = dir.path().join("failover.d"); + let app_config = loaded_app_config(dir.path(), &FailoverSource::Directory(failover_dir.clone())); + + write_file(&failover_dir.join("hand.yml"), vec![section("Main", "hand-edit")]); + + let result = write_failover_section(&app_config, "Main", None, Some(section("Main", "g1"))).await; + + assert!(matches!(result, Err(FailoverWriteError::Conflict { current_etag: Some(_) }))); + assert_eq!(file_names(&failover_dir), ["hand.yml"]); +} + +#[tokio::test] +async fn write_refused_when_snapshot_has_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("failover.yml"); + let app_config = test_app_config(dir.path()); + app_config.failover.store(Arc::new(FailoverOverrides { + source: Some(FailoverSource::File(path.clone())), + error: Some("boom".to_string()), + ..FailoverOverrides::default() + })); + + let result = write_failover_section(&app_config, "Main", None, Some(section("Main", "g1"))).await; + + assert!(matches!(result, Err(FailoverWriteError::Locked(ref message)) if message == "boom")); + assert!(!path.exists()); +} + +#[tokio::test] +async fn reassign_rewrites_target_field() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("failover.yml"); + write_file(&path, vec![section("Old", "g1"), section("Other", "g2")]); + let app_config = loaded_app_config(dir.path(), &FailoverSource::File(path.clone())); + let etag = app_config.failover_section("Old").expect("old").etag.to_string(); + + let entry = reassign_failover_section(&app_config, "Old", "New", &etag).await.expect("reassigns"); + + assert_eq!(entry.overrides.target, "New"); + assert_eq!(entry.file, path); + let file = read_file(&path); + let targets: Vec<&str> = file.targets.iter().map(|t| t.target.as_str()).collect(); + assert_eq!(targets, ["New", "Other"]); + assert!(app_config.failover_section("Old").is_none()); + assert!(app_config.failover_section("New").is_some()); +} + +#[tokio::test] +async fn reassign_conflicts_when_target_has_section_or_etag_is_stale() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("failover.yml"); + write_file(&path, vec![section("Old", "g1"), section("New", "g2")]); + let app_config = loaded_app_config(dir.path(), &FailoverSource::File(path.clone())); + let etag = app_config.failover_section("Old").expect("old").etag.to_string(); + + let taken = reassign_failover_section(&app_config, "Old", "New", &etag).await; + assert!(matches!(taken, Err(FailoverWriteError::Conflict { .. }))); + + let stale = reassign_failover_section(&app_config, "Old", "Free", "0000000000000000").await; + assert!(matches!(stale, Err(FailoverWriteError::Conflict { current_etag: Some(ref current) }) if *current == etag)); + + let missing = reassign_failover_section(&app_config, "Nope", "Free", &etag).await; + assert!(matches!(missing, Err(FailoverWriteError::Conflict { current_etag: None }))); + assert_eq!(read_file(&path).targets[0].target, "Old"); +} + +#[test] +fn empty_and_comment_only_files_are_valid() { + let dir = tempfile::tempdir().expect("tempdir"); + let failover_dir = dir.path().join("failover.d"); + std::fs::create_dir_all(&failover_dir).expect("create dir"); + std::fs::write(failover_dir.join("a-empty.yml"), "").expect("write"); + std::fs::write(failover_dir.join("b-blank.yml"), " \n\n").expect("write"); + std::fs::write(failover_dir.join("c-comments.yml"), "# nothing here yet\n # still nothing\n").expect("write"); + write_file(&failover_dir.join("d-real.yml"), vec![section("Main", "g1")]); + + let loaded = load_failover_overrides(&FailoverSource::Directory(failover_dir.clone())).expect("loads"); + + assert!(loaded.error.is_none()); + assert_eq!(loaded.files_used.len(), 4); + assert_eq!(loaded.sections.len(), 1); + assert_eq!(loaded.sections.get("Main").expect("main").file, failover_dir.join("d-real.yml")); +} + +#[tokio::test] +async fn write_into_empty_file_keeps_it_as_owner_file_in_file_mode() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("failover.yml"); + std::fs::write(&path, "# placeholder\n").expect("write"); + let app_config = loaded_app_config(dir.path(), &FailoverSource::File(path.clone())); + + write_failover_section(&app_config, "Main", None, Some(section("Main", "g1"))).await.expect("writes"); + + let file = read_file(&path); + assert_eq!(file.version, 1); + assert_eq!(file.targets[0].target, "Main"); +} + +#[tokio::test] +async fn env_placeholders_survive_write_back() { + // SAFETY: the variable is only read by this test through the (absent) resolver; no other test touches FOO_FAILOVER_ENV. + unsafe { std::env::set_var("FOO_FAILOVER_ENV", "resolved-secret") }; + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("failover.yml"); + let mut main = section("Main", "g1"); + main.groups[0].name = "${env:FOO_FAILOVER_ENV}".to_string(); + write_file(&path, vec![main]); + let app_config = loaded_app_config(dir.path(), &FailoverSource::File(path.clone())); + assert_eq!(app_config.failover_section("Main").expect("main").overrides.groups[0].name, "${env:FOO_FAILOVER_ENV}"); + + write_failover_section(&app_config, "Kids", None, Some(section("Kids", "g2"))).await.expect("writes"); + + let content = std::fs::read_to_string(&path).expect("read"); + assert!(content.contains("${env:FOO_FAILOVER_ENV}"), "{content}"); + assert!(!content.contains("resolved-secret"), "{content}"); +} + +/// Sections (target, file, etag) and the file list of a snapshot, comparable with a fresh load. +fn describe(snapshot: &FailoverOverrides) -> (Vec<(String, PathBuf, String)>, Vec) { + let mut sections: Vec<(String, PathBuf, String)> = snapshot + .sections + .iter() + .map(|(target, entry)| (target.to_string(), entry.file.clone(), entry.etag.to_string())) + .collect(); + sections.sort(); + (sections, snapshot.files_used.clone()) +} + +fn assert_snapshot_matches_disk(app_config: &AppConfig, source: &FailoverSource) { + let fresh = load_failover_overrides(source).expect("loads"); + assert_eq!(describe(&app_config.failover.load()), describe(&fresh)); +} + +#[tokio::test] +async fn snapshot_after_writes_matches_a_fresh_load() { + let dir = tempfile::tempdir().expect("tempdir"); + let failover_dir = dir.path().join("failover.d"); + write_file(&failover_dir.join("a.yml"), vec![section("Main", "main-1"), section("News", "news-1")]); + write_file(&failover_dir.join("b.yml"), vec![section("Kids", "kids-1")]); + let source = FailoverSource::Directory(failover_dir.clone()); + let app_config = loaded_app_config(dir.path(), &source); + + // new section in a new file + write_failover_section(&app_config, "Sport", None, Some(section("Sport", "s1"))).await.expect("adds"); + assert_snapshot_matches_disk(&app_config, &source); + + // edit in an existing file + let main_etag = app_config.failover_section("Main").expect("main").etag.to_string(); + write_failover_section(&app_config, "Main", Some(&main_etag), Some(section("Main", "main-2"))) + .await + .expect("edits"); + assert_snapshot_matches_disk(&app_config, &source); + + // removing the only section deletes the file + let kids_etag = app_config.failover_section("Kids").expect("kids").etag.to_string(); + write_failover_section(&app_config, "Kids", Some(&kids_etag), None).await.expect("removes"); + assert!(!failover_dir.join("b.yml").exists()); + assert_snapshot_matches_disk(&app_config, &source); + + // reassign rewrites the target field in place + let news_etag = app_config.failover_section("News").expect("news").etag.to_string(); + reassign_failover_section(&app_config, "News", "Weather", &news_etag).await.expect("reassigns"); + assert!(app_config.failover_section("News").is_none()); + assert!(app_config.failover_section("Weather").is_some()); + assert_snapshot_matches_disk(&app_config, &source); +} + +#[tokio::test] +async fn reload_waits_for_a_running_write() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("failover.yml"); + write_file(&path, vec![section("Main", "main-1")]); + let source = FailoverSource::File(path.clone()); + let app_config = Arc::new(loaded_app_config(dir.path(), &source)); + + let mutation_lock = app_config.file_locks.write_lock_str(FAILOVER_MUTATION_LOCK).await; + let reload = tokio::spawn({ + let app_config = Arc::clone(&app_config); + let source = source.clone(); + async move { reload_failover_snapshot(&app_config, &source).await } + }); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + assert!(!reload.is_finished(), "a reload must not run while a failover write holds the mutation lock"); + + // The write finished meanwhile; the reload reads the new disk state once it gets the lock. + write_file(&path, vec![section("Main", "main-2")]); + drop(mutation_lock); + let (previous, next) = reload.await.expect("joins").expect("reloads"); + assert_eq!(previous.sections["Main"].overrides.groups[0].id, "main-1"); + assert_eq!(next.sections["Main"].overrides.groups[0].id, "main-2"); + assert_snapshot_matches_disk(&app_config, &source); +} + +#[tokio::test] +async fn concurrent_saves_and_reloads_end_with_the_disk_state() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("failover.yml"); + write_file(&path, vec![section("Main", "g0")]); + let source = FailoverSource::File(path.clone()); + let app_config = Arc::new(loaded_app_config(dir.path(), &source)); + + let saver = tokio::spawn({ + let app_config = Arc::clone(&app_config); + async move { + for round in 1..=15 { + let etag = app_config.failover_section("Main").map(|entry| entry.etag.to_string()); + let _ = write_failover_section( + &app_config, + "Main", + etag.as_deref(), + Some(section("Main", &format!("g{round}"))), + ) + .await; + } + } + }); + let reloader = tokio::spawn({ + let app_config = Arc::clone(&app_config); + let source = source.clone(); + async move { + for _ in 0..15 { + reload_failover_snapshot(&app_config, &source).await.expect("reloads"); + tokio::task::yield_now().await; + } + } + }); + saver.await.expect("saver"); + reloader.await.expect("reloader"); + + assert_snapshot_matches_disk(&app_config, &source); +} diff --git a/backend/repository/src/lib.rs b/backend/repository/src/lib.rs index d409af671..8d43604c0 100644 --- a/backend/repository/src/lib.rs +++ b/backend/repository/src/lib.rs @@ -6,10 +6,11 @@ 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; +pub mod failover_group_repository; +mod failover_repository; mod geoip; mod library_repository; mod lineup_guide_index; @@ -44,10 +45,11 @@ 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::*; +pub use failover_group_repository::*; +pub use failover_repository::*; pub use geoip::*; pub use library_repository::*; pub use lineup_guide_index::*; diff --git a/backend/repository/src/live_stream_metadata_repository.rs b/backend/repository/src/live_stream_metadata_repository.rs index 5a9a7c32b..56a99a4b3 100644 --- a/backend/repository/src/live_stream_metadata_repository.rs +++ b/backend/repository/src/live_stream_metadata_repository.rs @@ -285,6 +285,7 @@ mod tests { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -292,6 +293,7 @@ mod tests { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), }) } diff --git a/backend/repository/src/m3u_repository.rs b/backend/repository/src/m3u_repository.rs index 129159090..c1fa54b6f 100644 --- a/backend/repository/src/m3u_repository.rs +++ b/backend/repository/src/m3u_repository.rs @@ -632,6 +632,7 @@ mod tests { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -639,6 +640,7 @@ mod tests { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), }) } diff --git a/backend/repository/src/playlist_mem_cache.rs b/backend/repository/src/playlist_mem_cache.rs index 0b9a50951..efd9f5390 100644 --- a/backend/repository/src/playlist_mem_cache.rs +++ b/backend/repository/src/playlist_mem_cache.rs @@ -27,9 +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, + /// Generation of the persisted failover groups per target name; a missing entry is generation 0. + failover_group_generations: Mutex>, + pub failover_groups: crate::FailoverGroupCache, } impl Default for PlaylistStorageState { @@ -40,21 +40,21 @@ impl PlaylistStorageState { pub fn new() -> Self { Self { data: RwLock::new(HashMap::new()), - channel_group_generations: Mutex::new(HashMap::new()), - channel_groups: crate::ChannelGroupCache::new(), + failover_group_generations: Mutex::new(HashMap::new()), + failover_groups: crate::FailoverGroupCache::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); + pub fn bump_failover_group_generation(&self, target_name: &str) -> u64 { + let mut generations = self.failover_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); + pub fn failover_group_generation(&self, target_name: &str) -> u64 { + let generations = self.failover_group_generations.lock().unwrap_or_else(std::sync::PoisonError::into_inner); generations.get(target_name).copied().unwrap_or(0) } diff --git a/backend/repository/src/playlist_repository/persist.rs b/backend/repository/src/playlist_repository/persist.rs index aa1b0504f..350a208a1 100644 --- a/backend/repository/src/playlist_repository/persist.rs +++ b/backend/repository/src/playlist_repository/persist.rs @@ -12,8 +12,9 @@ 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, ChannelGroupRecord, ChannelGroupStore, GroupMember, - LineupGuideCarryOver, LineupGuideView, MemberArchive, PendingChannelGroup, TargetIdMapping, + xtream_get_storage_path, xtream_lineup_guide_index_path, FailoverGroupBatch, FailoverGroupRecord, + FailoverGroupStore, GroupMember, LineupGuideCarryOver, LineupGuideView, MemberArchive, PendingFailoverGroup, + TargetIdMapping, }; use log::warn; use shared::{ @@ -63,7 +64,7 @@ pub async fn persist_playlist_views( target, playlist_state, options, - &mut [], + &mut FailoverGroupBatch::default(), ) .await } @@ -77,7 +78,7 @@ pub async fn persist_playlist_views_with_groups( target: &ConfigTarget, playlist_state: Option<&Arc>, options: TargetPlaylistPersistOptions, - channel_groups: &mut [PendingChannelGroup], + failover_groups: &mut FailoverGroupBatch, ) -> Result<(), Vec> { persist_playlist_views_with_mode( app_config, @@ -88,7 +89,7 @@ pub async fn persist_playlist_views_with_groups( playlist_state, options, TargetPersistenceMode::Persist, - channel_groups, + failover_groups, ) .await } @@ -111,19 +112,19 @@ pub(super) async fn persist_playlist_with_mode( playlist_state, options, persistence_mode, - &mut [], + &mut FailoverGroupBatch::default(), ) .await } -fn collect_channel_group_aliases( +fn collect_failover_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 let Some(cg_uuid) = channel.header.failover_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)); @@ -134,11 +135,11 @@ fn collect_channel_group_aliases( } } -fn build_channel_group_records( - channel_groups: &[PendingChannelGroup], +fn build_failover_group_records( + failover_groups: &[PendingFailoverGroup], mapping: &TargetIdMapping, -) -> Vec { - channel_groups +) -> Vec { + failover_groups .iter() .map(|pending| { let group_vid = mapping.get_virtual_id_by_uuid(&pending.group_uuid).unwrap_or_default(); @@ -156,8 +157,7 @@ fn build_channel_group_records( 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(), + provider_id: member.header.provider_id_or_default(), input_stream_id: Arc::clone(&member.header.input_stream_id), url: Arc::clone(&member.header.url), item_type: member.header.item_type, @@ -169,7 +169,7 @@ fn build_channel_group_records( } }) .collect(); - ChannelGroupRecord { + FailoverGroupRecord { group_vid, key: Arc::clone(&pending.key), origin: pending.origin, @@ -191,7 +191,7 @@ async fn persist_playlist_views_with_mode( playlist_state: Option<&Arc>, options: TargetPlaylistPersistOptions, persistence_mode: TargetPersistenceMode, - channel_groups: &mut [PendingChannelGroup], + failover_groups: &mut FailoverGroupBatch, ) -> Result<(), Vec> { let playlist_is_empty = !playlist_has_items(base_playlist) && xtream_playlist.as_deref().is_none_or(|playlist| !playlist_has_items(playlist)); @@ -217,7 +217,7 @@ async fn persist_playlist_views_with_mode( prepare_target_playlist_for_persistence(xtream_view, target, &mut target_id_mapping, &mut source_ordinal); } - for pending in channel_groups.iter_mut() { + for pending in &mut failover_groups.groups { for member in &mut pending.members { assign_item_identity(&mut member.header, &mut source_ordinal, &mut target_id_mapping); } @@ -231,25 +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 records = build_failover_group_records(&failover_groups.groups, &target_id_mapping); let mut aliases = Vec::new(); - collect_channel_group_aliases(base_playlist, &target_id_mapping, &mut aliases); + collect_failover_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); + collect_failover_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 - ))]); - } - }; + let staged_groups = + match FailoverGroupStore::write_staged(&target_path, &records, &aliases, failover_groups.side_files()) { + 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 failover 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 @@ -383,11 +384,11 @@ async fn persist_playlist_views_with_mode( if errors.is_empty() { if let Err(err) = staged_groups.publish() { errors.push(TuliproxError::RepositoryPlaylist(format!( - "Failed to publish channel groups for target '{}': {err}", + "Failed to publish failover groups for target '{}': {err}", target.name ))); } else if let Some(playlist_storage) = playlist_state { - playlist_storage.bump_channel_group_generation(&target.name); + playlist_storage.bump_failover_group_generation(&target.name); } } else { staged_groups.discard(); diff --git a/backend/repository/src/playlist_repository/tests.rs b/backend/repository/src/playlist_repository/tests.rs index 232039e4f..b9157d6d2 100644 --- a/backend/repository/src/playlist_repository/tests.rs +++ b/backend/repository/src/playlist_repository/tests.rs @@ -80,6 +80,7 @@ fn test_app_config(storage_dir: &Path) -> Arc { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -87,6 +88,7 @@ fn test_app_config(storage_dir: &Path) -> Arc { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), }) } @@ -192,6 +194,7 @@ fn target_test_app_config(storage_dir: &Path) -> Arc { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -199,6 +202,7 @@ fn target_test_app_config(storage_dir: &Path) -> Arc { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), }) } @@ -388,7 +392,7 @@ fn make_media_server_episode( } } -mod channel_groups; +mod failover_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 deleted file mode 100644 index 4f87bd667..000000000 --- a/backend/repository/src/playlist_repository/tests/channel_groups.rs +++ /dev/null @@ -1,422 +0,0 @@ -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/playlist_repository/tests/failover_groups.rs b/backend/repository/src/playlist_repository/tests/failover_groups.rs new file mode 100644 index 000000000..b443c57dd --- /dev/null +++ b/backend/repository/src/playlist_repository/tests/failover_groups.rs @@ -0,0 +1,719 @@ +use super::*; +use crate::{ + persist_playlist_views_with_groups, CatalogRecord, FailoverGroupBatch, FailoverGroupMeta, FailoverGroupOrigin, + FailoverGroupStore, PendingFailoverGroup, +}; +use shared::model::FailoverGroupPolicy; +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, FailoverGroupBatch) { + 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, + failover_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 = FailoverGroupBatch::with_groups(vec![PendingFailoverGroup { + group_uuid, + key: Arc::from("bbc one"), + origin: FailoverGroupOrigin::Auto, + policy: FailoverGroupPolicy::Priority, + members: vec![member1, member2], + orphaned: vec![], + epg_split: false, + }]); + (playlist, pending) +} + +fn grouped_fixture_ungrouped() -> (Vec, FailoverGroupBatch) { + 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, FailoverGroupBatch::default()) +} + +fn grouped_fixture_with_favourite_copy() -> (Vec, FailoverGroupBatch) { + 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"), + failover_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#"{"failover_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 = FailoverGroupStore::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) = + FailoverGroupStore::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 = FailoverGroupStore::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#"{"failover_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 FailoverGroupBatch::default(), + ) + .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 = + FailoverGroupStore::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#"{"failover_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!(FailoverGroupStore::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("failover_groups.db").exists()); +} + +#[tokio::test] +async fn target_without_groups_removes_stale_group_store() { + let (app_config, target, _dir) = persist_test_env(r#"{"failover_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 FailoverGroupBatch::default(), + ) + .await + .unwrap(); + assert!(!target_path(&app_config, &target).join("failover_groups.db").exists()); +} + +fn enable_groups(target: &mut ConfigTarget) { + target.options = Some(shared::model::ConfigTargetOptions { + failover_groups: Some(shared::model::FailoverGroupsTargetConfig { 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_failover_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.failover_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.failover_groups.get_or_load(&app_config, &state, &target_a).await; + let b_before = state.failover_groups.get_or_load(&app_config, &state, &target_b).await; + state.bump_failover_group_generation(&target_a.name); + let a_after = state.failover_groups.get_or_load(&app_config, &state, &target_a).await; + let b_after = state.failover_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.failover_groups.load_count(); + let (first, second) = tokio::join!( + state.failover_groups.get_or_load(&app_config, &state, &target), + state.failover_groups.get_or_load(&app_config, &state, &target) + ); + assert!(Arc::ptr_eq(&first, &second)); + assert_eq!(state.failover_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.failover_groups.hold_load_lock_for_test(&target_a.name).await; + let loaded_b = tokio::time::timeout( + std::time::Duration::from_secs(1), + state.failover_groups.get_or_load(&app_config, &state, &target_b), + ) + .await; + assert!(loaded_b.is_ok(), "target B loads while target A is loading"); +} + +#[tokio::test] +async fn cache_loads_wait_for_the_lock_persistence_and_hot_apply_write() { + 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; + state.bump_failover_group_generation(&target.name); + let mapping_file = crate::get_target_id_mapping_file(&target_path(&app_config, &target)); + + let write_lock = app_config.file_locks.write_lock(&mapping_file).await; + let groups = tokio::spawn({ + let (app_config, state, target) = (Arc::clone(&app_config), Arc::clone(&state), target.clone()); + async move { state.failover_groups.get_or_load(&app_config, &state, &target).await } + }); + let catalog = tokio::spawn({ + let (app_config, state, target) = (Arc::clone(&app_config), Arc::clone(&state), target.clone()); + async move { state.failover_groups.get_or_load_catalog(&app_config, &state, &target).await } + }); + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + assert!(!groups.is_finished(), "the group load must wait for the target write lock"); + assert!(!catalog.is_finished(), "the catalog load must wait for the target write lock"); + + drop(write_lock); + assert_eq!(groups.await.expect("joins").groups.len(), 1); + assert!(catalog.await.expect("joins").is_ok()); +} + +fn catalog_record(stream_id: &str, title: &str) -> CatalogRecord { + CatalogRecord { + member_ref: shared::model::ChannelMemberRefDto { + input: "korath".to_string(), + stream_id: stream_id.to_string(), + }, + natural_key: Some("bbc one".intern()), + dsl_forced: false, + epg_channel_id: Some("bbc1.uk".intern()), + name: title.intern(), + title: title.intern(), + logo: "http://logo/bbc.png".intern(), + playlist_group: "Entertainment".intern(), + provider_id: 101, + has_archive: true, + } +} + +fn sample_meta() -> FailoverGroupMeta { + FailoverGroupMeta { + overrides_etag: Some("etag-1".intern()), + overrides_error: Some("broken file".to_string()), + applied_overrides: Some(shared::model::TargetFailoverOverridesDto { + target: "target-groups-test".to_string(), + groups: Vec::new(), + auto_overrides: Vec::new(), + excluded: vec![shared::model::ChannelMemberRefDto { + input: "korath".to_string(), + stream_id: "101".to_string(), + }], + }), + display_stale_groups: vec!["bbc one".to_string()], + } +} + +fn enabled_batch(mut batch: FailoverGroupBatch) -> FailoverGroupBatch { + batch.enabled = true; + batch.catalog = vec![catalog_record("101", "BBC One FHD"), catalog_record("201", "BBC One HD")]; + batch.meta = sample_meta(); + batch +} + +async fn persist_batch( + app_config: &Arc, + target: &ConfigTarget, + playlist: &mut [PlaylistGroup], + batch: &mut FailoverGroupBatch, +) -> Result<(), Vec> { + persist_playlist_views_with_groups( + app_config, + playlist, + None, + None, + target, + None, + TargetPlaylistPersistOptions::default(), + batch, + ) + .await +} + +fn stage_leftovers(path: &std::path::Path) -> Vec { + std::fs::read_dir(path) + .map(|entries| { + entries + .filter_map(Result::ok) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .filter(|name| name.contains(".stage-")) + .collect() + }) + .unwrap_or_default() +} + +#[tokio::test] +async fn catalog_cache_loads_once_per_generation() { + let (app_config, mut target, _dir) = persist_test_env(""); + enable_groups(&mut target); + let state = Arc::new(PlaylistStorageState::new()); + let (mut playlist, batch) = grouped_fixture(); + let mut batch = enabled_batch(batch); + persist_batch(&app_config, &target, &mut playlist, &mut batch).await.expect("persists"); + + let loads_before = state.failover_groups.catalog_load_count(); + let first = state.failover_groups.get_or_load_catalog(&app_config, &state, &target).await.expect("loads"); + let second = state.failover_groups.get_or_load_catalog(&app_config, &state, &target).await.expect("cached"); + assert_eq!(first.as_ref(), batch.catalog.as_slice()); + assert!(Arc::ptr_eq(&first, &second)); + assert_eq!(state.failover_groups.catalog_load_count() - loads_before, 1); + + state.bump_failover_group_generation(&target.name); + let third = state.failover_groups.get_or_load_catalog(&app_config, &state, &target).await.expect("reloads"); + assert!(!Arc::ptr_eq(&first, &third)); + assert_eq!(state.failover_groups.catalog_load_count() - loads_before, 2); +} + +#[tokio::test] +async fn catalog_cache_is_empty_without_catalog_file() { + let (app_config, mut target, _dir) = persist_test_env(""); + enable_groups(&mut target); + let state = Arc::new(PlaylistStorageState::new()); + let catalog = state.failover_groups.get_or_load_catalog(&app_config, &state, &target).await.expect("loads"); + assert!(catalog.is_empty()); +} + +#[tokio::test] +async fn catalog_and_meta_published_with_groups() { + let (app_config, target, _dir) = persist_test_env(""); + let (mut playlist, batch) = grouped_fixture(); + let mut batch = enabled_batch(batch); + persist_batch(&app_config, &target, &mut playlist, &mut batch).await.expect("persists"); + + let path = target_path(&app_config, &target); + assert!(path.join("failover_groups.db").exists()); + assert!(path.join("failover_groups.catalog.db").exists()); + assert!(path.join("failover_groups.meta.json").exists()); + assert!(stage_leftovers(&path).is_empty()); + assert_eq!(FailoverGroupStore::read_catalog(&path).expect("catalog"), batch.catalog); + assert_eq!(FailoverGroupStore::read_meta(&path).expect("meta"), batch.meta); + + let data = FailoverGroupStore::load_target_data(&path, 7).expect("loads"); + assert_eq!(data.meta, batch.meta); + assert_eq!(data.groups.len(), 1); +} + +#[tokio::test] +async fn catalog_written_without_groups() { + let (app_config, target, _dir) = persist_test_env(""); + let (mut playlist, _) = grouped_fixture_ungrouped(); + let mut batch = enabled_batch(FailoverGroupBatch::default()); + persist_batch(&app_config, &target, &mut playlist, &mut batch).await.expect("persists"); + + let path = target_path(&app_config, &target); + assert!(!path.join("failover_groups.db").exists()); + assert!(!path.join("failover_groups.member.db").exists()); + assert_eq!(FailoverGroupStore::read_catalog(&path).expect("catalog"), batch.catalog); + assert_eq!(FailoverGroupStore::read_meta(&path).expect("meta"), batch.meta); + + let (mut playlist, _) = grouped_fixture_ungrouped(); + let mut empty = FailoverGroupBatch { enabled: true, ..FailoverGroupBatch::default() }; + persist_batch(&app_config, &target, &mut playlist, &mut empty).await.expect("persists"); + assert!(path.join("failover_groups.catalog.db").exists()); + assert!(FailoverGroupStore::read_catalog(&path).expect("catalog").is_empty()); + assert_eq!(FailoverGroupStore::read_meta(&path).expect("meta"), FailoverGroupMeta::default()); +} + +#[tokio::test] +async fn rejected_persistence_writes_no_catalog_or_meta() { + let (app_config, target, _dir) = persist_test_env_rejecting_empty(); + let mut playlist = vec![]; + let mut batch = enabled_batch(grouped_fixture().1); + assert!(persist_batch(&app_config, &target, &mut playlist, &mut batch).await.is_err()); + let path = target_path(&app_config, &target); + assert!(!path.join("failover_groups.catalog.db").exists()); + assert!(!path.join("failover_groups.meta.json").exists()); + assert!(stage_leftovers(&path).is_empty()); +} + +#[cfg(unix)] +#[test] +fn failed_stage_leaves_no_catalog_or_meta() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir"); + let locked = dir.path().join("locked"); + std::fs::create_dir(&locked).expect("mkdir"); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o555)).expect("chmod"); + if std::fs::File::create(locked.join("probe")).is_ok() { + // Permission bits are not enforced (root), so the stage cannot be made to fail. + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).expect("chmod"); + return; + } + + let catalog = vec![catalog_record("101", "BBC One FHD")]; + let meta = sample_meta(); + let result = FailoverGroupStore::write_staged( + &locked, + &[], + &[], + Some(crate::FailoverGroupSideFiles { catalog: &catalog, meta: &meta }), + ); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).expect("chmod"); + assert!(result.is_err()); + assert_eq!(std::fs::read_dir(&locked).expect("readdir").count(), 0); +} + +#[test] +fn dropped_stage_removes_catalog_and_meta_files() { + let dir = tempfile::tempdir().expect("tempdir"); + let catalog = vec![catalog_record("101", "BBC One FHD")]; + let meta = sample_meta(); + let staged = FailoverGroupStore::write_staged( + dir.path(), + &[], + &[], + Some(crate::FailoverGroupSideFiles { catalog: &catalog, meta: &meta }), + ) + .expect("staged"); + assert_eq!(stage_leftovers(dir.path()).len(), 2); + assert!(!dir.path().join("failover_groups.catalog.db").exists()); + staged.discard(); + assert!(stage_leftovers(dir.path()).is_empty()); +} + +#[tokio::test] +async fn disabled_target_removes_catalog_and_meta() { + let (app_config, target, _dir) = persist_test_env(""); + let (mut playlist, batch) = grouped_fixture(); + let mut batch = enabled_batch(batch); + persist_batch(&app_config, &target, &mut playlist, &mut batch).await.expect("persists"); + let path = target_path(&app_config, &target); + assert!(path.join("failover_groups.catalog.db").exists()); + + let (mut plain, _) = grouped_fixture_ungrouped(); + persist_batch(&app_config, &target, &mut plain, &mut FailoverGroupBatch::default()).await.expect("persists"); + for name in + ["failover_groups.db", "failover_groups.member.db", "failover_groups.catalog.db", "failover_groups.meta.json"] + { + assert!(!path.join(name).exists(), "{name} removed"); + } + assert!(FailoverGroupStore::read_catalog(&path).expect("catalog").is_empty()); + assert_eq!(FailoverGroupStore::read_meta(&path).expect("meta"), FailoverGroupMeta::default()); +} + +#[tokio::test] +async fn meta_records_overrides_etag_and_error() { + let (app_config, target, _dir) = persist_test_env(""); + let (mut playlist, _) = grouped_fixture_ungrouped(); + let mut batch = FailoverGroupBatch { + enabled: true, + meta: FailoverGroupMeta { overrides_etag: Some("abc".intern()), ..FailoverGroupMeta::default() }, + ..FailoverGroupBatch::default() + }; + persist_batch(&app_config, &target, &mut playlist, &mut batch).await.expect("persists"); + let path = target_path(&app_config, &target); + let meta = FailoverGroupStore::read_meta(&path).expect("meta"); + assert_eq!(meta.overrides_etag.as_deref(), Some("abc")); + assert_eq!(meta.overrides_error, None); + assert_eq!(meta.applied_overrides, None); + let json = std::fs::read_to_string(path.join("failover_groups.meta.json")).expect("json"); + assert!(!json.contains("overrides_error") && !json.contains("applied_overrides"), "{json}"); + + let (mut playlist, _) = grouped_fixture_ungrouped(); + let mut batch = enabled_batch(FailoverGroupBatch::default()); + persist_batch(&app_config, &target, &mut playlist, &mut batch).await.expect("persists"); + let meta = FailoverGroupStore::read_meta(&path).expect("meta"); + assert_eq!(meta.overrides_etag.as_deref(), Some("etag-1")); + assert_eq!(meta.overrides_error.as_deref(), Some("broken file")); + assert_eq!(meta.applied_overrides.as_ref().map(|o| o.excluded.len()), Some(1)); +} + +#[tokio::test] +async fn publication_clears_display_stale() { + let (app_config, target, _dir) = persist_test_env(""); + let (mut playlist, batch) = grouped_fixture(); + let mut batch = enabled_batch(batch); + persist_batch(&app_config, &target, &mut playlist, &mut batch).await.expect("persists"); + let path = target_path(&app_config, &target); + assert_eq!(FailoverGroupStore::read_meta(&path).expect("meta").display_stale_groups, vec!["bbc one".to_string()]); + + let (mut playlist, batch) = grouped_fixture(); + let mut batch = FailoverGroupBatch { enabled: true, meta: FailoverGroupMeta::default(), ..batch }; + persist_batch(&app_config, &target, &mut playlist, &mut batch).await.expect("persists"); + assert!(FailoverGroupStore::read_meta(&path).expect("meta").display_stale_groups.is_empty()); + let json = std::fs::read_to_string(path.join("failover_groups.meta.json")).expect("json"); + assert!(!json.contains("display_stale_groups"), "{json}"); +} diff --git a/backend/repository/src/playlist_source.rs b/backend/repository/src/playlist_source.rs index 6717a63d7..9dc6be601 100644 --- a/backend/repository/src/playlist_source.rs +++ b/backend/repository/src/playlist_source.rs @@ -1504,6 +1504,7 @@ mod tests { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -1511,6 +1512,7 @@ mod tests { access_token_secret: [0u8; 32], encrypt_secret: [0u8; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), }) } diff --git a/backend/repository/src/storage_const.rs b/backend/repository/src/storage_const.rs index 153b72604..2e8661f08 100644 --- a/backend/repository/src/storage_const.rs +++ b/backend/repository/src/storage_const.rs @@ -27,6 +27,8 @@ 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"; +pub const FILE_FAILOVER_GROUPS: &str = "failover_groups.db"; +pub const FILE_FAILOVER_GROUPS_MEMBER: &str = "failover_groups.member.db"; +pub const FILE_FAILOVER_GROUPS_ALIAS: &str = "failover_groups.alias.db"; +pub const FILE_FAILOVER_GROUPS_CATALOG: &str = "failover_groups.catalog.db"; +pub const FILE_FAILOVER_GROUPS_META: &str = "failover_groups.meta.json"; diff --git a/backend/repository/src/strm_repository.rs b/backend/repository/src/strm_repository.rs index 4a61e10c9..4465a7ea7 100644 --- a/backend/repository/src/strm_repository.rs +++ b/backend/repository/src/strm_repository.rs @@ -1319,6 +1319,7 @@ mod tests { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, } @@ -1336,6 +1337,7 @@ mod tests { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), } } diff --git a/backend/repository/src/target_bouquet_repository.rs b/backend/repository/src/target_bouquet_repository.rs index 4ad9b6a0c..9730977f0 100644 --- a/backend/repository/src/target_bouquet_repository.rs +++ b/backend/repository/src/target_bouquet_repository.rs @@ -565,6 +565,7 @@ mod tests { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -572,6 +573,7 @@ mod tests { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), } } diff --git a/backend/repository/src/target_stream_item.rs b/backend/repository/src/target_stream_item.rs index 1465cb343..dc1e52a43 100644 --- a/backend/repository/src/target_stream_item.rs +++ b/backend/repository/src/target_stream_item.rs @@ -1,4 +1,4 @@ -use crate::{ChannelGroupRecord, PlaylistStorageState}; +use crate::{FailoverGroupRecord, PlaylistStorageState}; use shared::{ model::{M3uPlaylistItem, VirtualId, XtreamPlaylistItem}, utils::Internable, @@ -9,14 +9,14 @@ use tuliprox_core::model::{AppConfig, ConfigTarget}; #[derive(Debug, Clone, PartialEq)] pub enum ResolvedStreamTarget { Item, - Group(Arc), - Member { group: Arc, index: usize }, + Group(Arc), + Member { group: Arc, index: usize }, } -/// Whether `target` resolves channel groups at all. Targets without them skip every group lookup. +/// Whether `target` resolves failover 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 fn failover_groups_enabled(target: &ConfigTarget) -> bool { + target.options.as_ref().and_then(|options| options.failover_groups.as_ref()).is_some_and(|groups| groups.enabled) } pub async fn classify_virtual_id( @@ -25,10 +25,10 @@ pub async fn classify_virtual_id( target: &ConfigTarget, vid: VirtualId, ) -> ResolvedStreamTarget { - if !channel_groups_enabled(target) { + if !failover_groups_enabled(target) { return ResolvedStreamTarget::Item; } - let target_data = playlists.channel_groups.get_or_load(app_config, playlists, target).await; + let target_data = playlists.failover_groups.get_or_load(app_config, playlists, target).await; if let Some(group) = target_data.groups.get(&vid) { return ResolvedStreamTarget::Group(Arc::clone(group)); } @@ -46,7 +46,7 @@ pub async fn classify_virtual_id( } #[must_use] -pub fn member_to_xtream_item(group: &ChannelGroupRecord, index: usize, target: &ConfigTarget) -> XtreamPlaylistItem { +pub fn member_to_xtream_item(group: &FailoverGroupRecord, index: usize, target: &ConfigTarget) -> XtreamPlaylistItem { let _ = target; let member = &group.members[index]; XtreamPlaylistItem { @@ -74,7 +74,7 @@ pub fn member_to_xtream_item(group: &ChannelGroupRecord, index: usize, target: & } #[must_use] -pub fn member_to_m3u_item(group: &ChannelGroupRecord, index: usize, target: &ConfigTarget) -> M3uPlaylistItem { +pub fn member_to_m3u_item(group: &FailoverGroupRecord, index: usize, target: &ConfigTarget) -> M3uPlaylistItem { let _ = target; let member = &group.members[index]; M3uPlaylistItem { @@ -109,12 +109,12 @@ pub fn member_to_m3u_item(group: &ChannelGroupRecord, index: usize, target: &Con mod tests { use super::*; use crate::{ - persist_playlist_views_with_groups, ChannelGroupOrigin, ChannelGroupStore, PendingChannelGroup, - PlaylistStorageState, TargetPlaylistPersistOptions, + persist_playlist_views_with_groups, FailoverGroupBatch, FailoverGroupOrigin, FailoverGroupStore, + PendingFailoverGroup, PlaylistStorageState, TargetPlaylistPersistOptions, }; use arc_swap::ArcSwap; use shared::model::{ - ChannelGroupPolicy, ConfigPaths, ConfigTargetDto, M3uTargetOutputDto, PlaylistGroup, PlaylistItem, + ConfigPaths, ConfigTargetDto, FailoverGroupPolicy, M3uTargetOutputDto, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, TargetOutputDto, UUIDType, XtreamCluster, }; use std::path::PathBuf; @@ -142,7 +142,7 @@ mod tests { &self.target, Some(&self.playlists), TargetPlaylistPersistOptions::default(), - &mut [], + &mut FailoverGroupBatch::default(), ) .await .expect("republished"); @@ -169,6 +169,7 @@ mod tests { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -176,6 +177,7 @@ mod tests { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(tuliprox_core::model::MediaToolCapabilities::new()), + failover: Arc::default(), }) } @@ -184,7 +186,7 @@ mod tests { crate::get_target_storage_path(&config, &target.name).expect("target storage path") } - fn grouped_fixture() -> (Vec, Vec) { + fn grouped_fixture() -> (Vec, FailoverGroupBatch) { let group_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000001"); let group_channel = PlaylistItem { header: PlaylistItemHeader { @@ -198,7 +200,7 @@ mod tests { input_name: "korath".intern(), item_type: PlaylistItemType::Live, xtream_cluster: XtreamCluster::Live, - channel_group_uuid: Some(group_uuid), + failover_group_uuid: Some(group_uuid), ..PlaylistItemHeader::default() }, }; @@ -261,31 +263,31 @@ mod tests { xtream_cluster: XtreamCluster::Live, }, ]; - let pending = vec![PendingChannelGroup { + let pending = FailoverGroupBatch::with_groups(vec![PendingFailoverGroup { group_uuid, key: Arc::from("bbc one"), - origin: ChannelGroupOrigin::Auto, - policy: ChannelGroupPolicy::Priority, + origin: FailoverGroupOrigin::Auto, + policy: FailoverGroupPolicy::Priority, members: vec![member1, member2], orphaned: vec![], epg_split: false, - }]; + }]); (playlist, pending) } - fn grouped_fixture_ungrouped() -> (Vec, Vec) { + fn grouped_fixture_ungrouped() -> (Vec, FailoverGroupBatch) { let (mut playlist, _) = grouped_fixture(); playlist.remove(0); - (playlist, Vec::new()) + (playlist, FailoverGroupBatch::default()) } - fn grouped_fixture_with_favourite_copy() -> (Vec, Vec) { + fn grouped_fixture_with_favourite_copy() -> (Vec, FailoverGroupBatch) { 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), + failover_group_uuid: Some(group_item.header.uuid), group: "My Favourites".intern(), ..group_item.header.clone() }, @@ -312,7 +314,7 @@ mod tests { ..ConfigTargetDto::default() }); target.options = Some(shared::model::ConfigTargetOptions { - channel_groups: Some(shared::model::ChannelGroupsTargetConfig { enabled: true, ..Default::default() }), + failover_groups: Some(shared::model::FailoverGroupsTargetConfig { enabled: true, ..Default::default() }), ..Default::default() }); let (mut playlist, mut pending) = grouped_fixture(); @@ -331,7 +333,7 @@ mod tests { 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 record = FailoverGroupStore::read_group(&path, group_vid).expect("read").expect("group"); let member_vids = record.members.iter().map(|m| m.member_vid).collect(); PersistedGroupEnv { app_config, @@ -358,7 +360,7 @@ mod tests { ..ConfigTargetDto::default() }); target.options = Some(shared::model::ConfigTargetOptions { - channel_groups: Some(shared::model::ChannelGroupsTargetConfig { enabled: true, ..Default::default() }), + failover_groups: Some(shared::model::FailoverGroupsTargetConfig { enabled: true, ..Default::default() }), ..Default::default() }); let (mut playlist, mut pending) = grouped_fixture_with_favourite_copy(); @@ -379,7 +381,7 @@ mod tests { 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 record = FailoverGroupStore::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 } } diff --git a/backend/repository/src/user_repository.rs b/backend/repository/src/user_repository.rs index 9c8a044e6..44d34f6a8 100644 --- a/backend/repository/src/user_repository.rs +++ b/backend/repository/src/user_repository.rs @@ -673,6 +673,7 @@ mod tests { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, }))), @@ -681,6 +682,7 @@ mod tests { access_token_secret: Default::default(), encrypt_secret: Default::default(), media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), }; let target_user = vec![user]; store_api_user(&cfg, &target_user).await.expect("the users must be stored"); @@ -728,6 +730,7 @@ mod tests { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, }))), @@ -736,6 +739,7 @@ mod tests { access_token_secret: Default::default(), encrypt_secret: Default::default(), media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), }; let db_path = get_api_user_db_path(&cfg); @@ -768,6 +772,7 @@ mod tests { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, }))), @@ -776,6 +781,7 @@ mod tests { access_token_secret: Default::default(), encrypt_secret: Default::default(), media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), }; save_xtream_user_bouquet_for_target(&app_config, "target", dir.path(), XtreamCluster::Live, None) diff --git a/backend/repository/src/xtream_repository/tests/support.rs b/backend/repository/src/xtream_repository/tests/support.rs index 09de160ff..d55f1d955 100644 --- a/backend/repository/src/xtream_repository/tests/support.rs +++ b/backend/repository/src/xtream_repository/tests/support.rs @@ -38,6 +38,7 @@ pub(in crate::xtream_repository::tests) fn test_app_config(storage_dir: &Path) - mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -45,6 +46,7 @@ pub(in crate::xtream_repository::tests) fn test_app_config(storage_dir: &Path) - access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), }) } diff --git a/backend/session/Cargo.toml b/backend/session/Cargo.toml index 20d8a9968..564d85d76 100644 --- a/backend/session/Cargo.toml +++ b/backend/session/Cargo.toml @@ -28,7 +28,6 @@ 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" diff --git a/backend/session/src/active_provider_manager/tests/capacity_probe.rs b/backend/session/src/active_provider_manager/tests/capacity_probe.rs index 07f1327d5..1dd876423 100644 --- a/backend/session/src/active_provider_manager/tests/capacity_probe.rs +++ b/backend/session/src/active_provider_manager/tests/capacity_probe.rs @@ -37,6 +37,7 @@ fn app_config_with_sources_and_grace(sources: SourcesConfig, grace_period_millis mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -44,6 +45,7 @@ fn app_config_with_sources_and_grace(sources: SourcesConfig, grace_period_millis access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), } } diff --git a/backend/session/src/active_provider_manager/tests/support.rs b/backend/session/src/active_provider_manager/tests/support.rs index d867a2b76..4f7589cd3 100644 --- a/backend/session/src/active_provider_manager/tests/support.rs +++ b/backend/session/src/active_provider_manager/tests/support.rs @@ -53,6 +53,7 @@ pub(in crate::active_provider_manager::tests) fn build_test_app_config( mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -60,6 +61,7 @@ pub(in crate::active_provider_manager::tests) fn build_test_app_config( access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), } } diff --git a/backend/session/src/active_user_manager/observability.rs b/backend/session/src/active_user_manager/observability.rs index 6c420cb59..ad6755b91 100644 --- a/backend/session/src/active_user_manager/observability.rs +++ b/backend/session/src/active_user_manager/observability.rs @@ -108,7 +108,7 @@ impl ActiveUserManager { }) } - /// Provider of the session bound to a channel group member. + /// Provider of the session bound to a failover 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)?; diff --git a/backend/session/src/active_user_manager/session_lifecycle.rs b/backend/session/src/active_user_manager/session_lifecycle.rs index 144b0cb44..216a27013 100644 --- a/backend/session/src/active_user_manager/session_lifecycle.rs +++ b/backend/session/src/active_user_manager/session_lifecycle.rs @@ -404,7 +404,7 @@ impl ActiveUserManager { } } - /// After a shared channel group stream switched member: every viewer's stream and session + /// After a shared failover 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(); diff --git a/backend/session/src/connection_manager/tests.rs b/backend/session/src/connection_manager/tests.rs index 937a01a27..336d6f5dd 100644 --- a/backend/session/src/connection_manager/tests.rs +++ b/backend/session/src/connection_manager/tests.rs @@ -98,6 +98,7 @@ fn create_test_app_config() -> AppConfig { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -105,6 +106,7 @@ fn create_test_app_config() -> AppConfig { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), } } diff --git a/backend/session/src/event_manager.rs b/backend/session/src/event_manager.rs index 399add313..1d202442d 100644 --- a/backend/session/src/event_manager.rs +++ b/backend/session/src/event_manager.rs @@ -157,7 +157,7 @@ struct LatchedEvents { pub struct EventManager { pub(crate) account_provider: std::sync::OnceLock>, - pub(crate) member_health: std::sync::OnceLock>, + pub(crate) member_health: std::sync::OnceLock>, channel_tx: tokio::sync::broadcast::Sender, meters: StreamMeterRegistry, stats: Arc, @@ -393,7 +393,7 @@ impl FilteredEventReceiver { } impl EventManager { - pub fn bind_member_health(&self, health: &Arc) { + pub fn bind_member_health(&self, health: &Arc) { let _ = self.member_health.set(Arc::downgrade(health)); } diff --git a/backend/session/src/channel_group/circuit.rs b/backend/session/src/failover_group/circuit.rs similarity index 97% rename from backend/session/src/channel_group/circuit.rs rename to backend/session/src/failover_group/circuit.rs index 33fb34435..62ed34cd9 100644 --- a/backend/session/src/channel_group/circuit.rs +++ b/backend/session/src/failover_group/circuit.rs @@ -1,6 +1,6 @@ use axum::http::StatusCode; use dashmap::DashMap; -use shared::model::ChannelGroupCircuitConfig; +use shared::model::FailoverGroupCircuitConfig; use std::{ collections::{HashSet, VecDeque}, sync::Arc, @@ -61,11 +61,11 @@ impl CircuitPolicy { } impl Default for CircuitPolicy { - fn default() -> Self { Self::from(&ChannelGroupCircuitConfig::default()) } + fn default() -> Self { Self::from(&FailoverGroupCircuitConfig::default()) } } -impl From<&ChannelGroupCircuitConfig> for CircuitPolicy { - fn from(cfg: &ChannelGroupCircuitConfig) -> Self { +impl From<&FailoverGroupCircuitConfig> for CircuitPolicy { + fn from(cfg: &FailoverGroupCircuitConfig) -> Self { Self { failures: cfg.source_failures, cooldown: Duration::from_secs(cfg.cooldown_secs), @@ -173,7 +173,7 @@ pub(crate) fn record_input_failure_in_shared( #[cfg(test)] mod tests { use super::*; - use crate::channel_group::health::MemberHealthIndex; + use crate::failover_group::health::MemberHealthIndex; fn key(input: &str, stream_id: &str) -> MemberKey { (input.into(), stream_id.into()) } diff --git a/backend/session/src/channel_group/health.rs b/backend/session/src/failover_group/health.rs similarity index 99% rename from backend/session/src/channel_group/health.rs rename to backend/session/src/failover_group/health.rs index 455a8182c..8b54ed44c 100644 --- a/backend/session/src/channel_group/health.rs +++ b/backend/session/src/failover_group/health.rs @@ -59,7 +59,7 @@ 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; + /// Keys of all failover group members. Probe results of other streams are not kept; /// `None` until the members are known. member_keys: ArcSwapOption>, shared: Arc, @@ -89,7 +89,7 @@ impl MemberHealthIndex { 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 }); + sink.emit(shared::model::EventMessage::FailoverGroupCircuitChanged { input_name, input_stream_id, state }); } } diff --git a/backend/session/src/channel_group/mod.rs b/backend/session/src/failover_group/mod.rs similarity index 100% rename from backend/session/src/channel_group/mod.rs rename to backend/session/src/failover_group/mod.rs diff --git a/backend/session/src/channel_group/ranking.rs b/backend/session/src/failover_group/ranking.rs similarity index 90% rename from backend/session/src/channel_group/ranking.rs rename to backend/session/src/failover_group/ranking.rs index a86ba15a1..94312989e 100644 --- a/backend/session/src/channel_group/ranking.rs +++ b/backend/session/src/failover_group/ranking.rs @@ -1,9 +1,9 @@ -use crate::channel_group::{ +use crate::failover_group::{ circuit::{CircuitView, MemberKey}, health::{member_key, MemberHealthIndex}, }; use shared::{ - model::{AudioChannels, AudioCodec, ChannelGroupPolicy, RuntimeAudioLanguage, VideoCodec, VideoResolution}, + model::{AudioChannels, AudioCodec, FailoverGroupPolicy, RuntimeAudioLanguage, VideoCodec, VideoResolution}, utils::quality_rank, }; use std::{cmp::Reverse, sync::Arc, time::Instant}; @@ -41,14 +41,7 @@ pub enum SelectionMode { }, } -#[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, -} +pub use shared::model::ScoreBreakdown; #[derive(Debug, Clone, PartialEq, Eq)] pub struct RankedCandidate { @@ -97,7 +90,7 @@ fn filter_candidates<'a>( } fn compute_quality_points( - probe: Option<&crate::channel_group::health::ProbePart>, + probe: Option<&crate::failover_group::health::ProbePart>, title: &str, min_bitrate: u32, max_bitrate: u32, @@ -138,7 +131,7 @@ fn compute_quality_points( 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 { +fn compute_runtime_bonuses(probe: Option<&crate::failover_group::health::ProbePart>, mode: &SelectionMode) -> i32 { let SelectionMode::Runtime { current_codec, current_resolution, current_audio, .. } = mode else { return 0; }; @@ -174,7 +167,7 @@ fn compute_runtime_bonuses(probe: Option<&crate::channel_group::health::ProbePar pub fn rank_members( target_name: &Arc, members: &[GroupMember], - policy: ChannelGroupPolicy, + policy: FailoverGroupPolicy, health: &MemberHealthIndex, mode: &SelectionMode, now: Instant, @@ -228,10 +221,10 @@ pub fn rank_members( } match policy { - ChannelGroupPolicy::Auto => { + FailoverGroupPolicy::Auto => { ranked.sort_by_key(|c| (c.score.demoted, Reverse(c.score.total), c.index)); } - ChannelGroupPolicy::Priority => { + FailoverGroupPolicy::Priority => { ranked.sort_by_key(|c| (c.score.demoted, c.index)); } } @@ -242,7 +235,7 @@ pub fn rank_members( #[cfg(test)] mod tests { use super::*; - use crate::channel_group::{ + use crate::failover_group::{ circuit::{CircuitPolicy, FailureClass}, health::{member_key, QosKey, QosPart}, test_support::test_member, @@ -294,7 +287,7 @@ mod tests { fn rank( members: &[GroupMember], - policy: ChannelGroupPolicy, + policy: FailoverGroupPolicy, index: &MemberHealthIndex, mode: &SelectionMode, ) -> Vec { @@ -304,19 +297,19 @@ mod tests { #[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); + assert_eq!(rank(&members, FailoverGroupPolicy::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); + assert_eq!(rank(&members, FailoverGroupPolicy::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); + let ranked = rank(&members, FailoverGroupPolicy::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) @@ -326,7 +319,7 @@ mod tests { #[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); + let ranked = rank(&members, FailoverGroupPolicy::Auto, &health, &SelectionMode::Live); assert_eq!(ranked[0].index, 1); assert_eq!(ranked[0].score.qos, 50); } @@ -336,7 +329,7 @@ mod tests { 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); + let ranked = rank(&members, FailoverGroupPolicy::Priority, &health, &SelectionMode::Live); assert_eq!(ranked.iter().map(|c| c.index).collect::>(), vec![1, 2, 0]); assert!(ranked[2].score.demoted); } @@ -352,7 +345,7 @@ mod tests { 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); + let ranked = rank(&members, FailoverGroupPolicy::Auto, &health, &SelectionMode::Live); assert_eq!(ranked[0].index, 2); assert!(ranked[1].score.demoted && ranked[2].score.demoted); } @@ -361,7 +354,7 @@ mod tests { 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); + assert_eq!(rank(&members, FailoverGroupPolicy::Auto, &health, &SelectionMode::Live)[0].score.total, 0); } #[test] @@ -374,7 +367,7 @@ mod tests { current_audio: None, audio_language: RuntimeAudioLanguage::Prefer, }; - assert_eq!(rank(&members, ChannelGroupPolicy::Auto, &health, &mode)[0].index, 1); + assert_eq!(rank(&members, FailoverGroupPolicy::Auto, &health, &mode)[0].index, 1); } fn set_audio(health: &MemberHealthIndex, member: &GroupMember, json: &str) { @@ -402,7 +395,7 @@ mod tests { set_audio(&health, &members[1], r#"[{"codec_name":"ac3","channels":6,"tags":{"language":"deu"}}]"#); let ranked = rank( &members, - ChannelGroupPolicy::Auto, + FailoverGroupPolicy::Auto, &health, &runtime_with_audio(Some("deu"), RuntimeAudioLanguage::Prefer), ); @@ -419,7 +412,7 @@ mod tests { set_audio(&health, &members[2], r#"[{"codec_name":"aac","channels":2,"tags":{"language":"deu"}}]"#); let ranked = rank( &members, - ChannelGroupPolicy::Auto, + FailoverGroupPolicy::Auto, &health, &runtime_with_audio(Some("deu"), RuntimeAudioLanguage::Require), ); @@ -434,7 +427,7 @@ mod tests { 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::>(), + rank(&members, FailoverGroupPolicy::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/failover_group/test_support.rs similarity index 100% rename from backend/session/src/channel_group/test_support.rs rename to backend/session/src/failover_group/test_support.rs diff --git a/backend/session/src/lib.rs b/backend/session/src/lib.rs index 2b8c262ae..ad8142e5a 100644 --- a/backend/session/src/lib.rs +++ b/backend/session/src/lib.rs @@ -21,9 +21,9 @@ 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 failover_group; pub mod meter; pub mod meter_registry; pub mod provider_dns_manager; @@ -38,7 +38,7 @@ pub mod stream_options; pub mod streams; pub use self::{ - 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::*, + active_provider_manager::*, active_user_manager::*, admission::*, admission_strategy::*, connection_manager::*, + event_manager::*, failover_group::*, meter::*, meter_registry::*, provider_leases::*, provider_lineup_manager::*, + provider_session_headers::*, stream::*, streams::*, }; diff --git a/backend/session/src/streams/shared_stream_manager/tests.rs b/backend/session/src/streams/shared_stream_manager/tests.rs index 8aa799cb1..696b7f971 100644 --- a/backend/session/src/streams/shared_stream_manager/tests.rs +++ b/backend/session/src/streams/shared_stream_manager/tests.rs @@ -63,6 +63,7 @@ fn create_test_app_config_with_conns(max_connections: u16) -> AppConfig { mapping_files_used: None, template_file_path: None, template_files_used: None, + failover_file_path: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })), @@ -70,6 +71,7 @@ fn create_test_app_config_with_conns(max_connections: u16) -> AppConfig { access_token_secret: [0; 32], encrypt_secret: [0; 16], media_tools: Arc::new(MediaToolCapabilities::new()), + failover: Arc::default(), } } diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 917beb79e..8174cc658 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -23,8 +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) + - [Failover Groups & Stream Failover](./configuration/failover-groups.md) + - [Failover Groups: Settings Reference](./configuration/failover-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.md b/docs/src/configuration/channel-groups.md deleted file mode 100644 index be373a255..000000000 --- a/docs/src/configuration/channel-groups.md +++ /dev/null @@ -1,226 +0,0 @@ -# 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 e2ebaa6b9..5418268e1 100644 --- a/docs/src/configuration/config.md +++ b/docs/src/configuration/config.md @@ -15,6 +15,7 @@ backup_dir: ./data/backup user_config_dir: ./data/user mapping_path: mapping.yml template_path: template.yml +failover_path: null # auto-detect failover.yml or failover.d in the config directory update_on_boot: false config_hot_reload: false accept_insecure_ssl_certificates: false @@ -40,7 +41,7 @@ hdhomerun: library: reverse_proxy: metadata_update: -channel_groups: +failover_groups: ``` ### Global System & Storage Settings (Flat Keys) @@ -55,8 +56,9 @@ channel_groups: | `user_config_dir` | String | No | `{storage_dir}/user` | Storage location for user-specific configurations (like favorites or custom bouquets created via the Web UI). | | `mapping_path` | String | No | `mapping.yml` | Path to the mapping file. **Pro-Tip:** If you specify a folder path here (e.g., `./config/mappings/`), Tuliprox loads *all* `.yml` files in that folder in **alphanumeric** order and merges them. Note: This is a lexicographic sort, meaning `m_10.yml` comes before `m_2.yml`. Name files carefully (e.g., `m_01.yml`, `m_02.yml`). | | `template_path` | String | No | `template.yml` | Path to the template macro file. Specifying a folder here is also possible and highly recommended (see above). | +| `failover_path` | String | No | auto-detected | Failover override files: a single file or a directory with `.yml` / `.yaml` files, relative to the config directory. Holds corrections of automatic failover groups and your own groups, see [Failover Groups](./failover-groups.md#corrections-and-own-groups-failover-files). Default: `failover.yml` if it exists, else directory `failover.d`, else `failover.yml` (created by the Web UI on first save). CLI: `--failover `. | | `update_on_boot` | Bool | No | `false` | Forces Tuliprox to immediately query all providers and rebuild all playlists upon startup. If `false`, the proxy serves the local DB cache from the last run until the scheduler triggers the next update. | -| `config_hot_reload` | Bool | No | `false` | Spawns a filesystem watcher for `mapping.yml` and `api-proxy.yml`. Upon saving, mappings and user credentials become active immediately *without* requiring a server restart. **(See Bind-Mount Note below)** | +| `config_hot_reload` | Bool | No | `false` | Spawns a filesystem watcher for `mapping.yml`, `api-proxy.yml`, templates and the [failover files](./failover-groups.md#hot-reload). Upon saving, mappings, user credentials and failover corrections become active *without* a server restart. Mapping, template and failover directories are watched for `.yml` and `.yaml` files. **(See Bind-Mount Note below)** | | `accept_insecure_ssl_certificates` | Bool | No | `false` | Set to `true` if your upstream provider uses expired, self-signed, or improperly configured HTTPS certificates. Otherwise, the HTTP client drops the connection securely. | | `sleep_timer_mins` | Int | No | `null` | Automatic kill-switch for proxied streams. Forcibly terminates active stream connections after X minutes (Protects against users falling asleep with the TV on). | | `connect_timeout_secs` | Int | No | `10` | Maximum time (in seconds) Tuliprox waits to establish the initial TCP connection to a provider. `0` disables the timeout and the connection attempt continues until the provider closes it or a network timeout occurs (Warning: risk of hanging threads!). | @@ -88,21 +90,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) | -| `channel_groups` | Matching, source selection and failover of channel groups. | [See Channel Groups](./channel-groups-settings.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) | +| `failover_groups` | Matching, source selection and failover of failover groups. | [See Failover Groups](./failover-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/channel-groups-settings.md b/docs/src/configuration/failover-groups-settings.md similarity index 93% rename from docs/src/configuration/channel-groups-settings.md rename to docs/src/configuration/failover-groups-settings.md index 0e3e186ad..22bcedcaa 100644 --- a/docs/src/configuration/channel-groups-settings.md +++ b/docs/src/configuration/failover-groups-settings.md @@ -1,16 +1,16 @@ -# Channel Groups: Settings Reference +# Failover 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). +This page explains every failover group setting: what it does, which values it takes, its default, when to change it, +and an example. For how failover groups work as a whole, see [Failover Groups & Stream Failover](./failover-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. | +| Where | What | +|:---------------------------------------------------------------|:---------------------------------------------------------------------| +| `config.yml` → `failover_groups` | All settings below. They apply to every target with failover groups. | +| Web UI: **Settings → Failover groups** | Edits the same block. | +| `source.yml` → `targets[].options.failover_groups` | `enabled` and a few [overrides](#target-overrides) for one target. | +| Web UI: **Source editor → Targets → target → Failover 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. @@ -18,7 +18,7 @@ its default. A complete global block with all defaults: ```yaml -channel_groups: +failover_groups: match_by: caption match_as_ascii: false match_normalize: @@ -389,7 +389,7 @@ 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 +A target turns failover 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 | @@ -418,7 +418,7 @@ member and is shared by all targets, and the timing describes your providers, no targets: - name: tv options: - channel_groups: + failover_groups: enabled: true ``` @@ -426,7 +426,7 @@ targets: ```yaml # config.yml -channel_groups: +failover_groups: match_as_ascii: true name_strip_quality: true match_normalize: @@ -440,7 +440,7 @@ channel_groups: ```yaml # config.yml -channel_groups: +failover_groups: policy: priority ``` @@ -453,7 +453,7 @@ Put the paid provider's input first in the source, so its channels come first in targets: - name: living-room options: - channel_groups: + failover_groups: enabled: true failover: false ``` @@ -465,7 +465,7 @@ targets: targets: - name: family options: - channel_groups: + failover_groups: enabled: true allow_provider_grace: false share_live_streams: @@ -479,8 +479,8 @@ targets: | 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. | +| Different channels were joined | Keep `epg_conflict: split`; remove too broad `ignore_tokens`; use the mapping field `failover_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.*`. | +| A good provider is always tried last | It is paused after failures; wait for the pause to end or check the events `failover_group.*`. | diff --git a/docs/src/configuration/failover-groups.md b/docs/src/configuration/failover-groups.md new file mode 100644 index 000000000..c89e3b11b --- /dev/null +++ b/docs/src/configuration/failover-groups.md @@ -0,0 +1,538 @@ +# Failover 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. **Failover 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. + +Failover 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 `failover_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: + failover_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 → Failover groups** edits the global block of `config.yml`. +- **Source editor → Targets → *target* → Failover 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 `failover_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 + +Automatic grouping is only the base. You can correct an automatic group (order, members, name, logo, policy), dissolve +it, or build your own groups, either in the [Failover groups editor](#editor-web-ui) or by editing YAML files. The +files only hold your deviations from the automatic result; see +[Corrections and own groups (failover files)](#corrections-and-own-groups-failover-files). + +## 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 `failover_group.member_switched` (with the reason); start selections emit +`failover_group.member_selected`, exhausted groups `failover_group.exhausted`. + +HLS playback switches members only when a new entry is started; HLS runtime failover is not implemented. + +## Corrections and own groups (failover files) + +Automatic grouping stays the base and keeps working: channels that appear later are grouped automatically, and they +also join an automatic group you corrected. **Failover files** are an overlay on top of it. They store only your +deviations: + +- corrections of automatic groups (order, members, name, logo, EPG id, playlist group, policy, dissolving), +- your own **manual groups**, +- channels that must never be grouped automatically (**excluded**). + +The failover files are the only place where these decisions live. The `.db` files in `storage_dir` +(including `failover_groups.db`) are derived data: you can delete them at any time, the next playlist update builds +them again from the playlist and the failover files. + +Targets without a section in the failover files behave exactly as before. + +### Where the files live + +Like `mapping_path` and `template_path`, the location is either **one file** or **a directory** with any number of +files. + +| Setting | Meaning | +| :------------------------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `failover_path` in `config.yml` | Path to a file or a directory. A relative path is resolved against the config directory. Environment variables are resolved in the path. | +| `--failover ` on the CLI | Same as `failover_path`, takes precedence over `config.yml`. | +| nothing configured | `failover.yml` in the config directory if it exists, else the directory `failover.d` if it exists, else `failover.yml`, which the Web UI creates with the first save. | + +```yaml +# config.yml +failover_path: failover.d # a file or a directory, relative to the config directory +``` + +If nothing is configured and both `failover.yml` and `failover.d` exist, `failover.yml` is used and a warning names +the ignored directory. + +A configured path counts as a directory when it exists as a directory, ends with `/` or `\`, or ends with `.d`. + +In directory mode Tuliprox reads all `.yml` and `.yaml` files below the directory (subdirectories included), sorted by +file name. Hidden files and editor temporary files (names starting with `.`, ending with `~`, or ending with `.swp`) +are ignored, so a half-saved editor file is never read. The file names are up to you; `.yml` is the +convention the Web UI uses for new files. + +Typical layouts, all equivalent for Tuliprox: + +```text +config/failover.yml # all targets in one file +config/failover.d/main-tv.yml # one file per target +config/failover.d/kids.yml +config/failover.d/failover_02.yml # any name, any number of targets per file +``` + +Every target is defined in **exactly one** file. The same target twice, in two files or twice in one file, is a load +error that names both files. An empty file, a file that only contains comments, or `targets: []` is valid and +contributes nothing. + +The content of failover files is **not** environment-resolved. Placeholders such as `${VAR}` stay as they are. + +### File format + +The same format is used for a single file and for every file of a directory: + +```yaml +version: 1 +targets: + - target: Main TV + groups: # manual groups + - id: sport-xtra-k3f9 + name: Sport Xtra + logo: https://example.invalid/sport-xtra.png + epg_channel_id: sportxtra.example + playlist_group: Sport + policy: priority + members: + - { input: prov-a, stream_id: "123" } + - { input: prov-b, stream_id: "9876" } + auto_overrides: # corrections of automatic groups, keyed by auto key + - auto_key: "zdf" + policy: auto + order: [ { input: prov-b, stream_id: "9876" } ] + add: [ { input: prov-c, stream_id: "77" } ] + remove: [ { input: prov-d, stream_id: "5" } ] + name: ZDF + - auto_key: "rtl|rtl.de" # groups split by EPG id keep the "key|epg" form + disabled: true # dissolve this automatic group + excluded: # never grouped automatically (manual groups may still use them) + - { input: prov-c, stream_id: "55" } + - target: Kids + groups: [] +``` + +`version` must be `1` and `target` must be the name of a target from `source.yml`. Channels are always referenced by +**input name** and **provider stream id** (`input`, `stream_id`). + +**Manual groups** (`groups`) + +| Parameter | Type | Required | Description | +| :--------------- | :----- | :------: | :------------------------------------------------------------------------------------------------------------------------ | +| `id` | String | Yes | Stable id: `a-z`, `0-9`, `-`, at most 64 characters, unique within the target. Does not change when you rename the group. | +| `name` | String | Yes | Name of the group in the playlist, at most 200 characters. | +| `logo` | String | No | Logo URL, must start with `http://` or `https://`. | +| `epg_channel_id` | String | No | EPG id of the group. | +| `playlist_group` | String | No | Playlist group (category) the group appears in. | +| `policy` | Enum | No | `auto` or `priority`; without it the setting of the target or `config.yml` applies. | +| `members` | List | No | Channels of the group, in the order that `policy: priority` uses. | + +**Corrections of automatic groups** (`auto_overrides`) + +| Parameter | Type | Description | +| :------------------------------------------------- | :----- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `auto_key` | String | Identifies the automatic group: its match key. A group that was split by EPG id has the form `key\|epg`, for example `rtl\|rtl.de`. Unique within the target. | +| `order` | List | Members that go first, in this order. Other members follow in their natural order. | +| `add` | List | Channels that join the group although their own key is different. | +| `remove` | List | Channels that leave the group. | +| `policy` | Enum | `auto` or `priority` for this group. | +| `name`, `logo`, `epg_channel_id`, `playlist_group` | String | Replace the value the group takes from its first member. Empty values are ignored. | +| `disabled` | Bool | `true` dissolves the group. Its channels stay ungrouped and keep their key, so removing `disabled` brings the group back, including channels that arrived in the meantime. | + +You rarely have to find an `auto_key` yourself: the Web UI writes it, and `GET /api/v1/failover/groups` returns it +as `key` of every group. + +**Excluded channels** (`excluded`) are skipped when automatic groups are built. They can still be members of manual +groups or be added to an automatic group explicitly. + +### Precedence + +A channel is a member of **at most one** group. If several rules claim it, the first of these wins: + +1. a manual group (the first manual group in the file wins when two list the same channel), +2. `add` of an automatic correction, +3. `excluded`, +4. its natural automatic group. + +Names of manual groups and automatic keys live in separate namespaces: a manual group `zdf` and an automatic group +with key `zdf` do not collide. + +A group still needs at least two usable members. A manual group with fewer than two resolvable channels is inactive +and releases its channels again. + +### Problems are reported, not hidden + +Nothing is dropped silently. Every playlist update logs a summary per target, and the Web UI shows the details in the +[problems view](#editor-web-ui): + +| Problem | Meaning | +| :--------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Outdated correction (stale) | An `auto_overrides` entry whose `auto_key` matches no automatic group any more, for example because the provider or the match settings changed. It has no effect. | +| Inactive manual group | A manual group with fewer than two resolvable members. | +| Conflicting channel | A channel claimed by more than one group; only the first claim counts. | +| Unresolved channel | A reference in `order`, `add`, `remove` or `members` that matches no channel of the playlist (for example the provider dropped it). The group keeps working with the rest. | +| Dissolved group | An automatic group switched off with `disabled`. | +| Validation finding | Invalid `id`, empty or too long `name`, invalid `logo`, duplicate `id` or `auto_key`, a channel in two manual groups or in a manual group and an `add` list. | +| Section of an unknown target | The `target` name matches no target, for example after renaming a target. | +| Section of a disabled target | The target exists, but failover groups are disabled for it. The section has no effect until you enable failover groups for the target. | + +Sections are never deleted automatically, so a renamed target does not lose its corrections. The Web UI offers +**Assign to target…** for such a section, which only rewrites its `target:` field. + +If the failover files cannot be loaded when the server starts (YAML syntax error, unsupported `version`, a target +defined twice, unreadable file), the overrides are switched off, playback and automatic grouping keep working, the +playlist update reports the error and the editor shows it in a banner. The Web UI refuses to write to the files until +they load again; it never overwrites a broken file. + +### Hot reload + +With `config_hot_reload: true` in `config.yml`, Tuliprox watches the failover file or directory. + +- A change is picked up without a restart. Creating and deleting files in `failover.d` triggers a reload as well. +- An **invalid** change (syntax error, unsupported version, target defined twice) is rejected: the previous state stays + active, and an error appears as toast in the Web UI and as `config.reload_failed` event. Fixing the file clears it. +- Changes that only affect **order** or **policy** of existing groups take effect immediately, also when another + channel becomes the first member: playback follows the new order at once. The playlist entry (name, logo, EPG id, + category and the other fields taken from the first member) refreshes with the next playlist update. Until then the + editor shows a hint next to **Apply**, unless the group has `name` (automatic groups), `logo`, `epg_channel_id` and + `playlist_group` set explicitly in the file. Removing a correction that only held the policy is such a change as + well; removing an `order` correction needs a playlist update, because the natural order is not stored. +- **Structural** changes (members, metadata, new or dissolved groups, exclusions) need a playlist update, just like + changes to `mapping.yml`. Run it with **Apply** in the editor, with `POST /api/v1/playlist/update` or with the next + scheduled update. Until then the editor shows "saved, apply pending". +- Saves from the Web UI are not reloaded a second time. + +Limits: + +- The watched paths are fixed when the server starts. A `failover.d` directory that is created later, or a changed + `failover_path`, is only watched after a restart. (A new `failover.yml` next to `config.yml` is noticed.) +- With `config_hot_reload: false` the files are read at start only. Saving in the Web UI still works and detects + edits that were made on disk. + +The directories `mapping.d`, `template.d` and `failover.d` are handled alike: `.yml` and `.yaml` files trigger a +reload, hidden and editor temporary files are skipped. + +### Writing from the Web UI + +When you save in the editor, Tuliprox rewrites the file that owns the target's section: + +- Other sections in the same file are kept (semantically), but **comments and formatting of that file are lost**, + like for every other config file the Web UI writes. If a file is shared by several targets, the editor tells you + once per session before the first save. +- A target without a section gets a new file `failover.d/.yml` in directory mode. In single-file mode the + section is appended to the file, which is created if missing. +- If a section becomes empty (no groups, corrections or exclusions), it is removed. In directory mode a file without + sections is deleted; in file mode the file keeps `targets: []`. +- The write is atomic and checks that the section still has the content the editor started from. Edits made on disk + in the meantime are detected as a conflict instead of being overwritten. + +Keep hand edits in files that the Web UI does not write often, or use one file per target, if you want to keep your +comments. + +## Editor (Web UI) + +The page **Failover groups** (sidebar → *Playlist* → *Failover groups*) edits the failover files. It shows +targets that have failover groups enabled and needs the `playlist.read` permission; saving and applying need +`playlist.write`. Without it the page is read-only. + +Before the editor can list channels, the target needs one playlist update with failover groups enabled +("Run a playlist update first" otherwise). That update stores the channel catalog the editor works on. No provider is +contacted while you edit. + +### Layout + +- **Header.** Target selection, group search, filters (*All*, *Automatic*, *Edited*, *Manual*, *Dissolved*, + *Problems* with a counter), the file that owns the section and its state (*unsaved changes*, *saved, apply + pending*, *applied*, *file error*), and the actions **New group**, **Undo**, **Refresh preview**, **Discard + changes**, **Save**, **Save & apply** and **Apply**. +- **Group list.** All effective groups of the target with their origin (*Automatic*, *Edited*, *Manual*, + *Dissolved*) and the number of usable members. +- **Group detail.** Name, logo, EPG id, playlist group and *Source order* (`auto` or `priority`) with a hint for the + current value; **Reset to automatic** for edited automatic groups; **Dissolve group** for automatic groups and + **Delete group** for manual groups. +- **Member table.** Members in their effective order. Reorder by drag and drop or with the row actions *Move up*, + *Move down*, *Move to first place*, *Move to…* another group, *Remove from group* and *Exclude*. The columns show + input, title, resolution, codec, bitrate, QoS (24 h / 7 d), the status of the member (including pauses of + the [circuit breaker](#how-a-source-is-selected)) and the used and maximum connections of its input. They are loaded + from in-memory data for the selected group only. +- **Candidate panel.** Search all channels of the target and filter them by *Ungrouped*, *Grouped* or *Excluded*. + Add channels to the selected group, move them out of another group, or create a **new group from the + selection**. + +### What an action writes + +Every action changes a draft of the target's section. Nothing is written before **Save**. + +| Action | Automatic group | Manual group | +| :------------------------------ | :------------------------------------------ | :------------------------------------ | +| Reorder, move to first place | `order` | order of `members` | +| Add a channel | `add` | appended to `members` | +| Remove a channel | `remove` | removed from `members` | +| Move a channel to another group | remove from the first, add to the second | same | +| Exclude a channel | `excluded`, plus `remove` if it is a member | removed from `members` and `excluded` | +| Name, logo, EPG id, policy | fields of the correction | fields of the group | +| Dissolve | `disabled: true` | delete group | +| Reset to automatic | delete the correction | - | + +New manual groups get an `id` made from their first name plus a short random suffix; it stays the same when you +rename the group. + +### Preview + +After each change the editor asks the server which groups the draft would produce and shows them in the list and +the table, with **Updating preview…** while it waits. The preview uses the same grouping code as the playlist update +and writes nothing. It shows the effect on groups and members; it does not tell you whether the playlist already +contains it. + +### Save and apply + +- **Save** writes the YAML file. If the change only affects order or policy of existing groups, it is applied at once + ("Saved and applied"), also when another channel becomes the first member. Running streams keep their member; new + selections use the new order. The playlist entry of a group with a new first member refreshes with the next playlist + update; the editor shows a hint until then. +- Otherwise the state is *saved, apply pending*. **Apply** (or **Save & apply**) queues a playlist update for this + target, which respects the input cache. If another update is already queued, the editor tells you and you can + retry. +- The header shows *applied* once the playlist was built from the saved section. +- After a hot apply that changed the first member of a group, the header shows "The new order is active. Name and logo + of the playlist entry update with the next playlist update." and the affected groups carry a marker in the group + list. The next playlist update clears both. When a playlist update for the open target finishes (success or + partial), the editor reloads its preview by itself; updates of other targets and failed runs do not trigger it. + +### Problems view + +The **Problems** filter lists, for the selected target: + +- outdated corrections: **Convert to manual group** (builds a manual group from the references of the correction) or + **Remove correction**, +- inactive manual groups: **Open group** or **Delete group**, +- conflicting channels (**Remove from** the group that should not claim the channel) and unresolved channels + (**Remove reference**), +- validation findings, +- sections of unknown targets and of targets with failover groups disabled (listed separately, with the note "target has + failover groups disabled"), with **Assign to target…** and **Delete section**. + +### Changes made elsewhere + +If the section changes on disk (hand edit, hot reload, a second browser tab) while you have unsaved changes, a +dialog offers **Reload (discard my changes)** or **Keep editing**. Saving is rejected until you reload, so nothing is +overwritten silently. Without unsaved changes the page reloads by itself. Leaving the page with unsaved changes asks +for confirmation. + +### Deep links + +- Source editor → *Targets* → *target* → *Failover groups* has a **Failover groups** button that opens the page for + this target. It appears when failover groups are enabled and you may read playlists. +- For channels of a target with failover groups, the context menu of the playlist explorer offers **Open failover group** (searches the group of the + channel) and **Add to failover group…** (preselects the channel as candidate). +- The path of the failover files is a field of the main configuration page (**Failover Path**). + +### Interfaces for scripts + +The editor uses the REST API below `/api/v1/failover/`; see the +[REST API Cookbook](../rest-api-cookbook.md#failover-groups-editor). When a failover file changes on disk, or when +anyone saves or resets a section, the server sends the event `failover.overrides_changed` with the affected +targets (`{"targets": ["Main TV"]}`) over the Web UI WebSocket to clients with `playlist.read`. The editor uses it to +reload by itself. Like the other `failover_group.*` events it is not part of the notification pipeline. + +## Global settings (`config.yml` → `failover_groups`) + +All keys are optional; the block itself can be left out. Every setting is explained with examples in the +[Settings Reference](./failover-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.failover_groups`) + +| Parameter | Type | Default | Description | +|:-----------------------|:-----|:---------|:---------------------------------------------------------------------------------| +| `enabled` | Bool | `false` | Turns failover 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 +failover_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: + failover_groups: + enabled: true + share_live_streams: + mpeg_ts: true # optional: one provider connection per group for all viewers + - name: tv-box + options: + failover_groups: + enabled: true + failover: false # this player stutters on a switch; keep the start selection only +``` diff --git a/docs/src/configuration/source.md b/docs/src/configuration/source.md index 3978e6b84..eec80a709 100644 --- a/docs/src/configuration/source.md +++ b/docs/src/configuration/source.md @@ -1620,7 +1620,7 @@ targets: match_by: caption keep: best_quality match_as_ascii: false - channel_groups: + failover_groups: enabled: true ``` @@ -1628,17 +1628,17 @@ targets: | Parameter | Type | Required | Default | Technical Impact & Background | |:-------------------------------------------|:-----|:--------:|:--------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ignore_logo` | Bool | No | `false` | Ignores `tvg-logo` and `tvg-logo-small` attributes. This reduces downstream device-side logo caching and can keep generated M3U playlists leaner for clients with limited storage or poor cache invalidation behavior. | -| `share_live_streams.hls` | Bool | No | `false` | Enables HLS live sharing for the new HLS cache proxy path. This is a configuration switch for the HLS cache feature and is independent from MPEG-TS stream sharing. | -| `share_live_streams.mpeg_ts` | Bool | No | `false` | Allows Tuliprox to share MPEG-TS live stream connections in reverse proxy mode. This can reduce upstream provider connection usage when multiple clients watch the same channel, but it increases memory usage per shared channel. | -| `remove_duplicates` | Bool | No | `false` | Legacy pre-transform identity deduplication. It runs independently for each input before the F/R/M pipe and removes repeated source identities before mapping can emit additional items. The field remains supported for backward compatibility. | -| `deduplicate` | Map | No | - | Post-merge, quality-aware content deduplication. It runs after all inputs have been transformed and merged. Channels whose match value is identical after stripping quality tokens (`4K`, `UHD`, `2160p`, `QHD`, `1440p`, `FHD`, `1080p`, `HD`, `720p`, `SD`, `480p`, `576p`) collapse to one entry. Sub-keys: `match_by` (`caption` (default), `name`, `title`), `keep` (`best_quality` (default), `first`) and `match_as_ascii` (default `false`). Matching is per cluster across all groups; ties keep the first occurrence. This lets mappings affect the final duplicate comparison results. | -| `epg_output.clear_invalid_ids` | Bool | No | `false` | Clears an EPG ID when it does not resolve to the processed EPG data. Playlist entries are never removed. Smart matching runs first, and IDs introduced or changed by later mappings are validated again before the `persist` filter. | -| `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. | +| `ignore_logo` | Bool | No | `false` | Ignores `tvg-logo` and `tvg-logo-small` attributes. This reduces downstream device-side logo caching and can keep generated M3U playlists leaner for clients with limited storage or poor cache invalidation behavior. | +| `share_live_streams.hls` | Bool | No | `false` | Enables HLS live sharing for the new HLS cache proxy path. This is a configuration switch for the HLS cache feature and is independent from MPEG-TS stream sharing. | +| `share_live_streams.mpeg_ts` | Bool | No | `false` | Allows Tuliprox to share MPEG-TS live stream connections in reverse proxy mode. This can reduce upstream provider connection usage when multiple clients watch the same channel, but it increases memory usage per shared channel. | +| `remove_duplicates` | Bool | No | `false` | Legacy pre-transform identity deduplication. It runs independently for each input before the F/R/M pipe and removes repeated source identities before mapping can emit additional items. The field remains supported for backward compatibility. | +| `deduplicate` | Map | No | - | Post-merge, quality-aware content deduplication. It runs after all inputs have been transformed and merged. Channels whose match value is identical after stripping quality tokens (`4K`, `UHD`, `2160p`, `QHD`, `1440p`, `FHD`, `1080p`, `HD`, `720p`, `SD`, `480p`, `576p`) collapse to one entry. Sub-keys: `match_by` (`caption` (default), `name`, `title`), `keep` (`best_quality` (default), `first`) and `match_as_ascii` (default `false`). Matching is per cluster across all groups; ties keep the first occurrence. This lets mappings affect the final duplicate comparison results. | +| `epg_output.clear_invalid_ids` | Bool | No | `false` | Clears an EPG ID when it does not resolve to the processed EPG data. Playlist entries are never removed. Smart matching runs first, and IDs introduced or changed by later mappings are validated again before the `persist` filter. | +| `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. | +| `failover_groups` | Map | No | - | Turns failover groups on for this target (`enabled`) and overrides single global settings of `config.yml` `failover_groups`. See [Failover Groups & Stream Failover](./failover-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`. > Start with [Shared HLS Sessions](./shared-hls-sessions.md) for the feature overview and diff --git a/docs/src/features.md b/docs/src/features.md index f72011251..76f64b1dc 100644 --- a/docs/src/features.md +++ b/docs/src/features.md @@ -40,8 +40,9 @@ 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)) +- group equal live channels from several inputs into one channel with source selection and runtime failover, and + correct or extend the groups in a Web UI editor or in YAML failover files + ([Failover Groups](./configuration/failover-groups.md)) ## Metadata and library diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index 8ae589b5d..6e51cc2a3 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -94,6 +94,7 @@ Options: -i, --source -m, --mapping -T, --template + --failover -t, --target -a, --api-proxy -s, --server diff --git a/docs/src/installation.md b/docs/src/installation.md index 03e622442..2ce41bd9d 100644 --- a/docs/src/installation.md +++ b/docs/src/installation.md @@ -159,6 +159,7 @@ You can view all arguments using `./tuliprox --help`: | `-i, --source ` | Specific path to the `source.yml`. | | `-m, --mapping ` | Specific path to the mapping file/directory. | | `-T, --template ` | Specific path to the template file/directory. | +| `--failover ` | Specific path to the failover override file/directory (default: `failover_path` of `config.yml`, else `failover.yml` / `failover.d`). See [Failover Groups](configuration/failover-groups.md#corrections-and-own-groups-failover-files). | | `-t, --target ` | **Target Override:** Forces processing of the specified target *only*. Extremely useful to quickly re-render a broken list via shell without blocking the entire system, even if `enabled: false` in config. | | `-a, --api-proxy ` | Specific path to the `api-proxy.yml`. | | `-s, --server` | Run in continuous Server Mode. | diff --git a/docs/src/operations-debugging.md b/docs/src/operations-debugging.md index 9cee01763..3b49ca6bb 100644 --- a/docs/src/operations-debugging.md +++ b/docs/src/operations-debugging.md @@ -15,6 +15,7 @@ While Tuliprox is usually run via Docker, understanding the CLI flags is crucial | `-s, --server` | Starts continuous Server Mode (API, Web UI, Background Workers). Without this flag, Tuliprox acts as a "One-Shot" playlist generator that downloads, processes, and immediately exits. | | `-H, --home ` | Sets the Home Directory. All relative paths in the configuration are resolved against this directory. If not set, resolves via `TULIPROX_HOME` env variable, or finally the binary's directory. | | `-c, -i, -a, -m, -T` | Overrides specific config paths (e.g., `-c /etc/tuliprox/config.yml`). Useful for testing experimental configurations without altering the production setup. | +| `--failover ` | Overrides the path of the failover override file or directory (corrections of failover groups). See [Failover Groups](configuration/failover-groups.md#corrections-and-own-groups-failover-files). | | `-t, --target ` | **Targeted Processing:** Forces processing of the specified target *only*. **Crucial:** This bypasses the `enabled: false` state in the config! Extremely useful to quickly re-render a broken list via cron/shell without blocking the entire system with other heavy targets. | | `--genpwd` | Interactively generates a secure `Argon2id` password hash for the `user.txt` file. Never store plaintext passwords! | | `--healthcheck` | Docker Support: Pings the API over localhost. Returns Exit Code `0` if the server responds with `{"status": "ok"}`. | diff --git a/docs/src/rest-api-cookbook.md b/docs/src/rest-api-cookbook.md index 9163f10aa..cdc467027 100644 --- a/docs/src/rest-api-cookbook.md +++ b/docs/src/rest-api-cookbook.md @@ -401,6 +401,88 @@ Every call answers with the complete state: - Unknown inputs answer `404`, remarks longer than 500 characters `400`. - Without Web UI authentication the endpoints are as open as the other `/api/v1` routes. +## Failover groups editor + +The Web UI page [Failover groups](configuration/failover-groups.md#editor-web-ui) is built on the routes below. They +read and write the [failover files](configuration/failover-groups.md#corrections-and-own-groups-failover-files), so +they are also useful for scripts. Reading needs `playlist.read`, everything that changes something (`PUT`, +`DELETE`, `reassign`, `apply`) needs `playlist.write`. All answers are sent with `Cache-Control: no-store`. + +The `target` parameter is the target **name** from `source.yml`. Only targets with failover groups enabled are +accepted; others answer `404`. + +```bash +#!/bin/bash + +BASE_URL="http://localhost:8901" +TOKEN="PUT_YOUR_TOKEN_HERE" +AUTH=(-H "Authorization: Bearer $TOKEN") + +# Targets with failover groups, their section etag, the applied etag and unknown (orphan) sections +curl -s "$BASE_URL/api/v1/failover/targets" "${AUTH[@]}" | jq . + +# Effective groups of the last playlist update, plus diagnostics +curl -s -G "$BASE_URL/api/v1/failover/groups" "${AUTH[@]}" --data-urlencode "target=Main TV" | jq . + +# The stored section of the target (the base of an edit) with its etag and owning file +curl -s -G "$BASE_URL/api/v1/failover/overrides" "${AUTH[@]}" --data-urlencode "target=Main TV" | jq . + +# Page through the channels of the target: filter = all | ungrouped | grouped | excluded +curl -s -G "$BASE_URL/api/v1/failover/candidates" "${AUTH[@]}" \ + --data-urlencode "target=Main TV" --data-urlencode "q=zdf" \ + --data-urlencode "filter=ungrouped" --data-urlencode "offset=0" --data-urlencode "limit=50" | jq . + +# Preview a draft section: the groups it would produce, nothing is written +curl -s -X POST -G "$BASE_URL/api/v1/failover/preview" "${AUTH[@]}" --data-urlencode "target=Main TV" \ + -H "Content-Type: application/json" \ + --data-raw '{"target":"Main TV","excluded":[{"input":"prov-c","stream_id":"55"}]}' | jq . + +# Save the section; expected_etag is the etag you based the change on (null for a new section) +curl -s -X PUT -G "$BASE_URL/api/v1/failover/overrides" "${AUTH[@]}" --data-urlencode "target=Main TV" \ + -H "Content-Type: application/json" \ + --data-raw '{"expected_etag":null,"overrides":{"target":"Main TV","excluded":[{"input":"prov-c","stream_id":"55"}]}}' | jq . + +# Start a playlist update for the target so the saved section is used +curl -s -X POST -G "$BASE_URL/api/v1/failover/apply" "${AUTH[@]}" --data-urlencode "target=Main TV" | jq . +``` + +| Method | Path | Parameters | Purpose | +| :------- | :------------------------------------ | :-------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/api/v1/failover/targets` | --------------------------------------------- | Used files, load error, targets with `etag`, `applied_etag`, `file`, `group_count`, `problem_count`, validation findings; sections of targets without failover groups or of unknown targets are listed with `state: "disabled"` or `state: "orphan"`. | +| `GET` | `/api/v1/failover/groups` | `target` | Effective groups (`key`, origin, policy, metadata, members) of the saved state, diagnostics, `etag`, `applied_etag` and `display_stale`. | +| `GET` | `/api/v1/failover/overrides` | `target` | The stored section with `etag` and `file`; an empty section without `etag` when none exists. | +| `PUT` | `/api/v1/failover/overrides` | `target`, body `{ expected_etag, overrides }` | Validates and writes the section. An empty section removes it. Answers `{ etag, file, hot_applied }`. | +| `DELETE` | `/api/v1/failover/overrides` | `target`, `expected_etag` | Removes the whole section of the target (also of a target that no longer exists). | +| `POST` | `/api/v1/failover/overrides/reassign` | body `{ from, to, expected_etag }` | Moves a section to a target with failover groups enabled by rewriting its `target:` field (orphan sections). | +| `POST` | `/api/v1/failover/preview` | `target`, body: draft section | Groups and diagnostics the draft would produce. Writes nothing. | +| `GET` | `/api/v1/failover/candidates` | `target`, `q`, `filter`, `offset`, `limit` | Channel catalog of the target; `limit` defaults to 100, at most 500. | +| `GET` | `/api/v1/failover/health` | `target`, `group` (virtual id of the group) | Per member: rank, QoS, probed quality, circuit state, connection slots, score; plus the capacity of the group. Reads in-memory data only. | +| `POST` | `/api/v1/failover/apply` | `target` | Queues a manual playlist update for the target. Answers `202` with the run id. | + +Status codes: + +| Status | Meaning | +| :----- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `404` | Target unknown or failover groups not enabled for it; `health`: group not found; `reassign`: `to` is no target with failover groups enabled. | +| `409` | `PUT`, `DELETE`, `reassign`: the section changed since `expected_etag` (also by a hand edit on disk). `reassign` also answers `409` when the target `to` already has a section. The body is `{"current_etag": "..."}`. `groups`, `preview`, `candidates`: no channel catalog yet, run a playlist update first. `apply`: an update is already queued. | +| `422` | `PUT`: the section is invalid. The body lists the findings, for example `[{"error": "invalid_manual_id", "detail": "Zdf"}]`. | +| `423` | The failover files failed to load, so writing is refused until they are fixed. | + +Notes: + +- `preview` uses the same grouping code as a playlist update, so the result matches what an applied save produces. +- `PUT` rejects a section with validation findings (`422`); `preview` does not validate, it shows what the draft would do. +- `hot_applied: true` means the change only reordered members or switched the policy and is already active, also when + it changes the first member of a group. Otherwise call `apply`, which respects the input cache like a normal manual + update. +- `display_stale` lists the keys of groups whose first member changed by such a change while name, logo, EPG id or + playlist group are not all set explicitly: the published playlist entry still shows the old first member until the + next playlist update, which clears the list. +- `PUT` and `DELETE` need the current etag. Read it with `GET /failover/overrides` immediately before the change; a + `409` returns the etag on disk. +- Every save, reset, reassign and every change of the files on disk sends the WebSocket event + `failover.overrides_changed` with `{"targets": [...]}` to clients with `playlist.read`. + ## Available `/api/v1` Endpoints This is a compact operator-oriented overview of the `/api/v1` REST API groups currently registered by the backend. @@ -522,6 +604,21 @@ form's scheduled interval, never as a hard block. | `POST` | `/api/v1/playlist/filter/preview` | Dry-run a filter DSL expression against a target's stored playlist | | `GET` | `/api/v1/playlist/resource/{resource}` | Public resource access for playlist-related assets | +### Failover groups + +| Method | Path | Purpose | +| --- | --- | --- | +| `GET` | `/api/v1/failover/targets` | Failover files, load error and per-target status (`playlist.read`) | +| `GET` | `/api/v1/failover/groups` | Effective groups and diagnostics of a target (`playlist.read`) | +| `GET` | `/api/v1/failover/overrides` | Stored section of a target (`playlist.read`) | +| `PUT` | `/api/v1/failover/overrides` | Save the section of a target (`playlist.write`) | +| `DELETE` | `/api/v1/failover/overrides` | Reset the section of a target (`playlist.write`) | +| `POST` | `/api/v1/failover/overrides/reassign` | Move a section to another target (`playlist.write`) | +| `POST` | `/api/v1/failover/preview` | Preview a draft section (`playlist.read`) | +| `GET` | `/api/v1/failover/candidates` | Search channels of a target (`playlist.read`) | +| `GET` | `/api/v1/failover/health` | Member health of a group (`playlist.read`) | +| `POST` | `/api/v1/failover/apply` | Queue a playlist update for a target (`playlist.write`) | + ### Configuration | Method | Path | Purpose | diff --git a/frontend/public/assets/i18n/ar.json b/frontend/public/assets/i18n/ar.json index 1c0d07c23..7a71e6a2f 100644 --- a/frontend/public/assets/i18n/ar.json +++ b/frontend/public/assets/i18n/ar.json @@ -585,6 +585,7 @@ "MAPPING_PATH": "مسار أو دليل يحتوي على ملفات تهيئة التعيين.", "PROCESS_PARALLEL": "إذا تم التفعيل، تتم معالجة قوائم تشغيل الإدخال بالتوازي لتقليل زمن الوصول. ومع ذلك، هذا غير مدعوم حاليًا.", "SLEEP_TIMER_MINS": "يُغلق البث تلقائيًا ويوقف البث بعد العدد المحدد من الدقائق من عدم النشاط.", + "FAILOVER_PATH": "مسار اختياري لملف أو دليل failover (مثل `failover.yml` أو `failover.d/`) يحتوي على تعديلات مجموعات failover. اتركه فارغًا ليتم اكتشاف `failover.yml` / `failover.d` تلقائيًا في دليل التهيئة.", "TEMPLATE_PATH": "مسار أو دليل يحتوي على ملفات تهيئة القوالب.", "UPDATE_ON_BOOT": "إذا تم التفعيل، يُشغّل Tuliprox تحديثًا كاملاً لقائمة التشغيل فورًا عند بدء التشغيل. مفيد أثناء الإعداد الأولي، لكن عادةً يُضبط على false للعمليات المستقرة.", "USER_ACCESS_CONTROL": "إذا تم التفعيل، فإن السمات (إذا تم تعيينها)\n\n- تاريخ انتهاء الصلاحية،\n- الحالة و\n- max_connections\n\nيتم فحصها للسماح أو رفض الوصول لمستخدمي واجهة API.", @@ -874,10 +875,10 @@ "INPUT": "المدخل الذي يتم إيقافه عن الخدمة. أثناء الصيانة تحصل البثوث الجديدة على فيديو الصيانة ولا يتصل Tuliprox بمزوده؛ تستمر البثوث والتسجيلات الجارية.", "REASON": "ملاحظة اختيارية تظهر في قائمة الصيانة، مثل سبب تعطل المزود. 500 حرف كحد أقصى." }, - "CHANNEL_GROUPS_TARGET_CONFIG": { + "FAILOVER_GROUPS_TARGET_CONFIG": { "ENABLED": "يجمع القنوات المباشرة المتطابقة في هذا الهدف في قناة واحدة. يبدأ التشغيل بأفضل مصدر متاح وينتقل إلى مصدر آخر عند تعطله." }, - "CHANNEL_GROUPS_CONFIG": { + "FAILOVER_GROUPS_CONFIG": { "MATCH_BY": "الحقل الذي يحدد أن قناتين متطابقتان: caption أو name أو title أو معرّف قناة EPG. يتم تجاهل كلمات الجودة مثل HD أو FHD.", "MATCH_AS_ASCII": "يقارن الأسماء بعد تحويل الحروف المشكّلة والخاصة إلى حروف بسيطة، فتتطابق \"Télé\" و\"Tele\".", "EPG_CONFLICT": "split: القنوات ذات الاسم نفسه ومعرّفات EPG مختلفة تبقى في مجموعات منفصلة (برامج مختلفة). ignore: تُجمع رغم ذلك.", @@ -890,11 +891,11 @@ "IGNORE_TOKENS": "كلمات يتم تجاهلها عند مقارنة الأسماء، مثل \"backup\" أو \"hevc\".", "ALIASES": "أسماء للقناة نفسها: يُقارن الاسم الأيسر كالاسم الأيمن، مثل \"das erste\" كـ \"ard\"." }, - "CHANNEL_GROUP_START_CONFIG": { + "FAILOVER_GROUP_START_CONFIG": { "TIMEOUT_SECS": "أطول مدة يجرب فيها البدء المصادر قبل أن يتلقى العميل فيديو عدم التوفر. 0 تعني بلا حد.", "MAX_ATTEMPTS": "أقصى عدد من المصادر يجربها البدء." }, - "CHANNEL_GROUP_FAILOVER_CONFIG": { + "FAILOVER_GROUP_RUNTIME_CONFIG": { "ENABLED": "عند تعطل المصدر، يفتح Tuliprox مصدرًا آخر من المجموعة ويكمل البث نفسه، ويحتفظ المشغل باتصاله. لـ MPEG-TS فقط؛ HLS يغيّر المصدر فقط عند بدء بث جديد.", "NO_DATA_TIMEOUT_SECS": "يُستبدل المصدر الذي لا يرسل صورة ولا صوتًا لهذه المدة. اجعلها أكبر من فاصل الإطارات الرئيسية (بضع ثوانٍ). 0 يعطّل الفحص.", "SLOW_SOURCE_PCT": "يجب أن يقدّم المصدر هذه النسبة على الأقل من السرعة الفعلية. 90 تعني: خلال 10 ثوانٍ يجب أن يقدّم 9 ثوانٍ من الفيديو، وإلا يفرغ مخزن المشغل وتتقطع الصورة. 0 يعطّل الفحص.", @@ -906,12 +907,25 @@ "MAX_SWITCHES_WINDOW_SECS": "النافذة الزمنية لأقصى عدد تبديلات.", "AUDIO_LANGUAGE": "prefer: يفضّل التبديل مصدرًا بلغة الصوت نفسها. require: تُستخدم فقط المصادر بلغة الصوت نفسها." }, - "CHANNEL_GROUP_CIRCUIT_CONFIG": { + "FAILOVER_GROUP_CIRCUIT_CONFIG": { "SOURCE_FAILURES": "أعطال متتالية يُوقف بعدها المصدر مؤقتًا وتُجرّب مصادر أخرى أولًا.", "COOLDOWN_SECS": "أول إيقاف للمصدر المتعطل. يتضاعف ما دام المصدر يتعطل.", "COOLDOWN_MAX_SECS": "أطول إيقاف للمصدر المتعطل.", "INPUT_FAILURES": "أعطال قنوات مختلفة لمدخل واحد ضمن النافذة يُجرَّب بعدها المدخل كله أخيرًا (المزود متوقف).", "INPUT_WINDOW_SECS": "النافذة الزمنية لأعطال المدخل." + }, + "FAILOVER_GROUP_META": { + "NAME": "الاسم المعروض للقناة المدمجة. اتركه فارغًا للإبقاء على الاسم التلقائي.", + "LOGO": "رابط شعار القناة المدمجة (http أو https). الفارغ يبقي الشعار التلقائي.", + "EPG_CHANNEL_ID": "معرّف قناة EPG للقناة المدمجة. الفارغ يبقي المعرّف التلقائي.", + "PLAYLIST_GROUP": "مجموعة القائمة التي تظهر فيها القناة المدمجة. الفارغ يبقي المجموعة التلقائية.", + "POLICY": "ترتيب المصادر لهذه المجموعة. «عام» يتبع إعداد الهدف؛ auto يرتّب حسب الحالة والجودة، وpriority يحافظ على ترتيب القائمة." + }, + "NEW_FAILOVER_GROUP": { + "NAME": "اسم المجموعة اليدوية الجديدة. يُشتق معرّفها من الاسم." + }, + "FAILOVER_GROUP_TARGET": { + "TARGET": "الهدف الذي يتم تحرير مجموعات failover الخاصة به. تُعرض فقط الأهداف التي فُعّلت فيها مجموعات failover." } }, "HINT": { @@ -947,8 +961,8 @@ "RESTART_TO_APPLY_CHANGES": "تحتاج إلى إعادة التشغيل لتطبيق التغييرات.", "SCHEDULE_EXAMPLE": "مثال على الجدول", "WEB_ROOT": "Web-Root: يحتوي على ملفات واجهة الويب. عادة ما يتم وضعها في نفس الدليل تحت ./web", - "CHANNEL_GROUPS_CONFIG": "تجمع مجموعات القنوات القناة المباشرة نفسها من عدة مدخلات في قناة واحدة. تنطبق هذه الإعدادات على كل هدف يفعّل مجموعات القنوات، ويمكن للهدف تجاوز بعضها في صفحة مجموعات القنوات الخاصة به.", - "CHANNEL_GROUPS_TARGET": "يفعّل مجموعات القنوات لهذا الهدف. الإعدادات المتروكة على \"عام\" تستخدم الإعدادات > مجموعات القنوات." + "FAILOVER_GROUPS_CONFIG": "تجمع مجموعات القنوات القناة المباشرة نفسها من عدة مدخلات في قناة واحدة. تنطبق هذه الإعدادات على كل هدف يفعّل مجموعات القنوات، ويمكن للهدف تجاوز بعضها في صفحة مجموعات القنوات الخاصة به.", + "FAILOVER_GROUPS_TARGET": "يفعّل مجموعات القنوات لهذا الهدف. الإعدادات المتروكة على \"عام\" تستخدم الإعدادات > مجموعات القنوات." }, "LABEL": { "HLS_STARTUP_LIMITS": "حدود البدء السريع", @@ -1692,6 +1706,7 @@ "THRESHOLD_SCORE": "درجة العتبة", "TEMPLATES": "القوالب", "TEMPLATE_PATH": "مسار القالب", + "FAILOVER_PATH": "مسار الإخفاق (Failover)", "THROTTLE": "تحديد السرعة", "THROTTLE_KBPS": "تحديد السرعة كيلوبت/ث", "THUMBNAILS": "الصور المصغرة", @@ -1976,41 +1991,119 @@ "RECORDING_RULE_DELETE_CANCEL": "حذف القاعدة وإلغاء التسجيلات القادمة", "RECORDING_RULE_ACTION_ENABLE": "تمكين", "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": "يُقارن كـ" + "FAILOVER_GROUPS": "مجموعات القنوات", + "FAILOVER_GROUPS_MATCH_BY": "المطابقة حسب", + "FAILOVER_GROUPS_ALLOW_PROVIDER_GRACE": "السماح باتصال السماح لدى المزود", + "FAILOVER_GROUPS_CONFIG": "مجموعات القنوات", + "FAILOVER_GROUPS_GLOBAL": "عام", + "FAILOVER_GROUPS_MATCH_AS_ASCII": "المقارنة كـ ASCII", + "FAILOVER_GROUPS_STRIP_REGEX": "إزالة من الاسم (regex)", + "FAILOVER_GROUPS_IGNORE_TOKENS": "كلمات متجاهلة", + "FAILOVER_GROUPS_ALIASES": "أسماء بديلة", + "FAILOVER_GROUPS_EPG_CONFLICT": "معرّفات EPG مختلفة", + "FAILOVER_GROUPS_NAME_STRIP_QUALITY": "إزالة كلمات الجودة من الاسم", + "FAILOVER_GROUPS_POLICY": "ترتيب المصادر", + "FAILOVER_GROUPS_RUNTIME": "تبديل المصدر أثناء المشاهدة", + "FAILOVER_GROUPS_GROUPING": "التجميع", + "FAILOVER_GROUPS_START": "البدء", + "FAILOVER_GROUPS_CIRCUIT": "إيقاف المصادر المتعطلة مؤقتًا", + "FAILOVER_GROUPS_START_TIMEOUT_SECS": "مهلة البدء (ث)", + "FAILOVER_GROUPS_START_MAX_ATTEMPTS": "المصادر المجرّبة عند البدء", + "FAILOVER_GROUPS_NO_DATA_TIMEOUT_SECS": "مهلة انعدام البيانات (ث)", + "FAILOVER_GROUPS_SLOW_SOURCE_PCT": "الحد الأدنى للسرعة (%)", + "FAILOVER_GROUPS_SLOW_SOURCE_SECS": "بطيء جدًا لمدة (ث)", + "FAILOVER_GROUPS_MAX_OUTAGE_SECS": "أقصى انقطاع (ث)", + "FAILOVER_GROUPS_SAME_SOURCE_RETRIES": "إعادة محاولات المصدر نفسه", + "FAILOVER_GROUPS_STABLE_AFTER_SECS": "مستقر بعد (ث)", + "FAILOVER_GROUPS_MAX_SWITCHES": "أقصى عدد تبديلات", + "FAILOVER_GROUPS_MAX_SWITCHES_WINDOW_SECS": "نافذة التبديلات (ث)", + "FAILOVER_GROUPS_AUDIO_LANGUAGE": "لغة الصوت", + "FAILOVER_GROUPS_SOURCE_FAILURES": "الأعطال حتى الإيقاف", + "FAILOVER_GROUPS_COOLDOWN_SECS": "الإيقاف (ث)", + "FAILOVER_GROUPS_COOLDOWN_MAX_SECS": "أقصى إيقاف (ث)", + "FAILOVER_GROUPS_INPUT_FAILURES": "أعطال المدخل حتى الإيقاف", + "FAILOVER_GROUPS_INPUT_WINDOW_SECS": "نافذة أعطال المدخل (ث)", + "FAILOVER_GROUPS_IGNORE_TOKEN": "مثال: backup", + "FAILOVER_GROUPS_ALIAS_NAME": "الاسم", + "FAILOVER_GROUPS_ALIAS_TARGET": "يُقارن كـ", + "VIEW_TYPE_FAILOVER_GROUPS": "مجموعات القنوات", + "EPG_CHANNEL_ID": "معرّف قناة EPG", + "LOGO": "الشعار", + "FAILOVER_GROUPS_ADD_CHANNELS": "إضافة قنوات", + "FAILOVER_GROUPS_ADD_SELECTED": "إضافة المحدد إلى {group}", + "FAILOVER_GROUPS_ADD_TO_GROUP": "إضافة إلى {group}", + "FAILOVER_GROUPS_OPEN_GROUP": "فتح مجموعة failover", + "FAILOVER_GROUPS_ADD_TO_GROUP_ELLIPSIS": "إضافة إلى مجموعة failover…", + "FAILOVER_GROUPS_MOVE_TO_GROUP": "نقل إلى {group}", + "FAILOVER_GROUPS_APPLY": "تطبيق", + "FAILOVER_GROUPS_ASSIGN_TO_TARGET": "تعيين إلى هدف…", + "FAILOVER_GROUPS_COL_ACTIONS": "إجراءات", + "FAILOVER_GROUPS_COL_BITRATE": "معدل البت", + "FAILOVER_GROUPS_COL_CIRCUIT": "الحالة", + "FAILOVER_GROUPS_COL_CODEC": "الترميز", + "FAILOVER_GROUPS_COL_IN_GROUP": "في المجموعة", + "FAILOVER_GROUPS_COL_INPUT": "إدخال", + "FAILOVER_GROUPS_COL_ORDER": "الترتيب", + "FAILOVER_GROUPS_COL_PLAYLIST_GROUP": "مجموعة القائمة", + "FAILOVER_GROUPS_COL_POSITION": "#", + "FAILOVER_GROUPS_COL_QOS": "جودة الخدمة 24س/7أ", + "FAILOVER_GROUPS_COL_RESOLUTION": "الدقة", + "FAILOVER_GROUPS_COL_SELECT": "تحديد", + "FAILOVER_GROUPS_COL_SLOTS": "الاتصالات", + "FAILOVER_GROUPS_COL_TITLE": "العنوان", + "FAILOVER_GROUPS_CONFLICTS": "قنوات متعارضة", + "FAILOVER_GROUPS_CONVERT_TO_MANUAL": "تحويل إلى مجموعة يدوية", + "FAILOVER_GROUPS_DELETE_GROUP": "حذف المجموعة", + "FAILOVER_GROUPS_DELETE_SECTION": "حذف القسم", + "FAILOVER_GROUPS_DISCARD": "تجاهل التغييرات", + "FAILOVER_GROUPS_DISSOLVE": "حل المجموعة", + "FAILOVER_GROUPS_DISSOLVED": "محلولة", + "FAILOVER_GROUPS_DRAG": "اسحب لإعادة الترتيب", + "FAILOVER_GROUPS_EXCLUDE_MEMBER": "استبعاد", + "FAILOVER_GROUPS_FILTER_EXCLUDED": "المستبعدة", + "FAILOVER_GROUPS_FILTER_GROUPED": "المجمّعة", + "FAILOVER_GROUPS_FILTER_UNGROUPED": "غير المجمّعة", + "FAILOVER_GROUPS_FINDINGS": "نتائج التحقق", + "FAILOVER_GROUPS_HEALTH_CLOSED": "سليم", + "FAILOVER_GROUPS_HEALTH_COUNTING": "يتم عدّ الأعطال", + "FAILOVER_GROUPS_HEALTH_HALF_OPEN": "قيد الاختبار", + "FAILOVER_GROUPS_HEALTH_OPEN": "متوقف مؤقتًا", + "FAILOVER_GROUPS_INACTIVE": "غير نشطة", + "FAILOVER_GROUPS_INACTIVE_GROUPS": "مجموعات يدوية غير نشطة", + "FAILOVER_GROUPS_KEEP_EDITING": "متابعة التحرير", + "FAILOVER_GROUPS_MOVE_DOWN": "تحريك للأسفل", + "FAILOVER_GROUPS_MOVE_FIRST": "نقل إلى المركز الأول", + "FAILOVER_GROUPS_MOVE_TO": "نقل إلى…", + "FAILOVER_GROUPS_MOVE_UP": "تحريك للأعلى", + "FAILOVER_GROUPS_NEW_FROM_SELECTION": "مجموعة جديدة من المحدد", + "FAILOVER_GROUPS_NEW_GROUP": "مجموعة جديدة", + "FAILOVER_GROUPS_OPEN_GROUP": "فتح المجموعة", + "FAILOVER_GROUPS_ORIGIN_AUTO": "تلقائية", + "FAILOVER_GROUPS_ORIGIN_EDITED": "معدّلة", + "FAILOVER_GROUPS_ORIGIN_MANUAL": "يدوية", + "FAILOVER_GROUPS_ORPHANS": "أقسام أهداف غير معروفة", + "FAILOVER_GROUPS_DISABLED_SECTIONS": "أقسام أهداف عُطّلت فيها مجموعات failover", + "FAILOVER_GROUPS_PLAYLIST_GROUP": "مجموعة القائمة", + "FAILOVER_GROUPS_PROBLEMS": "مشاكل", + "FAILOVER_GROUPS_REFRESH_PREVIEW": "تحديث المعاينة", + "FAILOVER_GROUPS_RELOAD_DISCARD": "إعادة التحميل (تجاهل تغييراتي)", + "FAILOVER_GROUPS_REMOVE_FROM": "إزالة من {group}", + "FAILOVER_GROUPS_REMOVE_MEMBER": "إزالة من المجموعة", + "FAILOVER_GROUPS_REMOVE_OVERRIDE": "إزالة التصحيح", + "FAILOVER_GROUPS_REMOVE_REFERENCE": "إزالة المرجع", + "FAILOVER_GROUPS_RESET_AUTO": "إعادة التعيين إلى تلقائي", + "FAILOVER_GROUPS_SAVE_AND_APPLY": "حفظ وتطبيق", + "FAILOVER_GROUPS_SEARCH_CHANNELS": "بحث عن قنوات…", + "FAILOVER_GROUPS_SEARCH_GROUPS": "بحث عن مجموعات…", + "FAILOVER_GROUPS_SELECTED": "تم تحديد {count}", + "FAILOVER_GROUPS_STALE": "تصحيحات قديمة", + "FAILOVER_GROUPS_STATUS_APPLIED": "مطبّق", + "FAILOVER_GROUPS_STATUS_APPLY_PENDING": "تم الحفظ، بانتظار التطبيق", + "FAILOVER_GROUPS_STATUS_FILE_ERROR": "خطأ في الملف", + "FAILOVER_GROUPS_STATUS_UNSAVED": "تغييرات غير محفوظة", + "FAILOVER_GROUPS_UNDO": "تراجع", + "FAILOVER_GROUPS_UNRESOLVED": "قنوات غير محلولة", + "FAILOVER_GROUPS_CURRENT_VALUES": "القيم الحالية" }, "MESSAGES": { "CLIPBOARD_NOT_SUPPORTED": "الحافظة غير مدعومة.\nمتصفحك أو السياق الحالي لا يسمح بالوصول إلى الحافظة.\nيرجى استخدام HTTPS أو localhost.", @@ -2284,7 +2377,41 @@ }, "USER_BOUQUET": { "FAIL": "فشل تنزيل باقات المستخدم!" - } + }, + "FAILOVER_GROUPS_APPLY_BUSY": "هناك تحديث للقائمة في قائمة الانتظار لهذا الهدف بالفعل.", + "FAILOVER_GROUPS_APPLY_HINT": "يبدأ «تطبيق» تحديثًا للقائمة، ويُراعى فيه مخزن الإدخال المؤقت.", + "FAILOVER_GROUPS_APPLY_QUEUED": "تمت إضافة تحديث القائمة إلى قائمة الانتظار.", + "FAILOVER_GROUPS_CANDIDATES_FAILED": "تعذّر تحميل القنوات.", + "FAILOVER_GROUPS_CHANGED_ELSEWHERE": "تم تغيير تصحيحات هذا الهدف في مكان آخر (ملف أو علامة تبويب أخرى). هل تريد إعادة التحميل وتجاهل تغييراتك أم متابعة التحرير؟ سيُرفض الحفظ حتى تعيد التحميل.", + "FAILOVER_GROUPS_CONFIRM_DELETE_GROUP": "هل تريد حذف هذه المجموعة اليدوية؟", + "FAILOVER_GROUPS_CONFIRM_DELETE_SECTION": "هل تريد حذف قسم هذا الهدف بالكامل من ملف failover؟", + "FAILOVER_GROUPS_CONFIRM_DISCARD": "هل تريد تجاهل كل التغييرات غير المحفوظة؟", + "FAILOVER_GROUPS_CONFIRM_SWITCH": "لديك تغييرات غير محفوظة. تغيير الهدف سيتجاهلها.", + "FAILOVER_GROUPS_CONFLICT_HINT": "هذه القنوات مطلوبة من أكثر من مجموعة؛ يُعتدّ بالطلب الأول فقط.", + "FAILOVER_GROUPS_DISSOLVED_HINT": "هذه المجموعة التلقائية محلولة وتبقى قنواتها بلا مجموعة. أعد التعيين لاستعادة المجموعة التلقائية.", + "FAILOVER_GROUPS_FILE_ERROR": "تعذّرت قراءة ملفات failover؛ التحرير معطّل حتى يتم إصلاح الملف", + "FAILOVER_GROUPS_INACTIVE_HINT": "تحتوي هذه المجموعة على أقل من قناتين قابلتين للاستخدام ولا تُستخدم بعد.", + "FAILOVER_GROUPS_INVALID": "التصحيحات غير صالحة؛ راجع قائمة المشاكل.", + "FAILOVER_GROUPS_LOCKED": "تعذّرت الكتابة في ملف failover", + "FAILOVER_GROUPS_NEW_FILE": "سيتم إنشاء ملف failover جديد", + "FAILOVER_GROUPS_NO_CATALOG": "لا يوجد كتالوج قنوات بعد. شغّل تحديث القائمة لهذا الهدف أولًا.", + "FAILOVER_GROUPS_NO_PROBLEMS": "لم يتم العثور على مشاكل.", + "FAILOVER_GROUPS_NO_TARGETS": "لا يوجد هدف مفعّل فيه مجموعات failover. فعّلها في إعدادات الهدف.", + "FAILOVER_GROUPS_TARGET_UNAVAILABLE": "الهدف غير متاح في صفحة مجموعات failover. احفظ الهدف أولاً مع تفعيل مجموعات failover.", + "FAILOVER_GROUPS_ORPHAN_HINT": "تنتمي هذه الأقسام إلى أهداف لم تعد موجودة. عيّن القسم إلى هدف آخر أو احذفه.", + "FAILOVER_GROUPS_DISABLED_HINT": "هذه الأهداف موجودة، لكن مجموعات failover معطّلة فيها. فعّل مجموعات failover للهدف أو عيّن القسم إلى هدف آخر أو احذفه.", + "FAILOVER_GROUPS_TARGET_DISABLED": "مجموعات failover معطّلة لهذا الهدف", + "FAILOVER_GROUPS_DISPLAY_STALE": "الترتيب الجديد مفعّل. يتم تحديث اسم وشعار عنصر قائمة التشغيل عند التحديث التالي لقائمة التشغيل.", + "FAILOVER_GROUPS_RESOLVED_MEMBERS": "{count} قابلة للاستخدام", + "FAILOVER_GROUPS_SAVED_APPLIED": "تم الحفظ والتطبيق.", + "FAILOVER_GROUPS_SAVED_PENDING": "تم الحفظ. اضغط «تطبيق» لاستخدامه (يراعي التحديث مخزن الإدخال المؤقت).", + "FAILOVER_GROUPS_SECTION_UPDATED": "تم تحديث قسم failover.", + "FAILOVER_GROUPS_SELECT_GROUP": "اختر مجموعة لتحرير قنواتها.", + "FAILOVER_GROUPS_SHARED_FILE_NOTICE": "سيعيد هذا الحفظ كتابة {file} الذي يحتوي أيضًا على أقسام أهداف أخرى. لا يتم الحفاظ على التعليقات وتنسيق الملف. يظهر هذا التنبيه مرة واحدة لكل جلسة.", + "FAILOVER_GROUPS_STALE_HINT": "لم يعد مفتاح القناة لهذه التصحيحات يطابق أي مجموعة. حوّل التصحيح إلى مجموعة يدوية أو أزله.", + "FAILOVER_GROUPS_UNRESOLVED_HINT": "لا تطابق هذه المراجع أي قناة في القائمة. أزلها أو انتظر عودة القناة.", + "FAILOVER_GROUPS_UPDATING": "جارٍ تحديث المعاينة…", + "FAILOVER_GROUPS_CHANGED_ELSEWHERE_BANNER": "تم التغيير في مكان آخر (ملف أو علامة تبويب أخرى). سيُرفض الحفظ حتى تعيد التحميل." }, "SETUP": { "DESC": { diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 83f453dbd..2034fdf06 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -585,6 +585,7 @@ "MAPPING_PATH": "Path or directory containing mapping configuration files.", "PROCESS_PARALLEL": "If enabled, input playlists are processed in parallel to reduce latency. However this is currently not supported.", "SLEEP_TIMER_MINS": "Automatically closes streams and stops streaming after the specified number of minutes of inactivity.", + "FAILOVER_PATH": "Optional path to a failover file or directory (e.g. `failover.yml` or `failover.d/`) with failover group overrides. Leave empty to auto-detect `failover.yml` / `failover.d` in the config directory.", "TEMPLATE_PATH": "Path or directory containing template configuration files.", "UPDATE_ON_BOOT": "If enabled, Tuliprox triggers a full playlist update immediately upon startup. Helpful during initial setup, but usually set to false for stable operations.", "USER_ACCESS_CONTROL": "If enabled, the attributes (if set)\n\n- expiration date,\n- status and\n- max_connections\n\nare checked to permit or deny access for api users.", @@ -874,10 +875,10 @@ "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": { + "FAILOVER_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": { + "FAILOVER_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.", @@ -890,11 +891,11 @@ "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": { + "FAILOVER_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": { + "FAILOVER_GROUP_RUNTIME_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.", @@ -906,12 +907,25 @@ "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": { + "FAILOVER_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." + }, + "FAILOVER_GROUP_META": { + "NAME": "Name shown for the joined channel. Leave empty to keep the automatic name.", + "LOGO": "Logo URL (http or https) of the joined channel. Empty keeps the automatic logo.", + "EPG_CHANNEL_ID": "EPG channel id of the joined channel. Empty keeps the automatic id.", + "PLAYLIST_GROUP": "Playlist group the joined channel appears in. Empty keeps the automatic group.", + "POLICY": "Source order of this group. Default follows the target setting; auto ranks by health and quality, priority keeps the list order." + }, + "NEW_FAILOVER_GROUP": { + "NAME": "Name of the new manual group. Its id is derived from the name." + }, + "FAILOVER_GROUP_TARGET": { + "TARGET": "Target whose failover groups are edited. Only targets with failover groups enabled are listed." } }, "HINT": { @@ -947,8 +961,8 @@ "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", - "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." + "FAILOVER_GROUPS_CONFIG": "Failover groups join the same live channel from several inputs into one channel. These settings apply to every target that turns failover groups on; a target can override some of them on its own Failover groups page.", + "FAILOVER_GROUPS_TARGET": "Turns failover groups on for this target. Settings left on \"Global\" use Settings > Failover groups." }, "LABEL": { "HLS_STARTUP_LIMITS": "Fast start limits", @@ -1759,6 +1773,7 @@ "THRESHOLD_SCORE": "Threshold score", "TEMPLATES": "Templates", "TEMPLATE_PATH": "Template Path", + "FAILOVER_PATH": "Failover Path", "THROTTLE": "Throttle", "THROTTLE_KBPS": "Throttle kbps", "THUMBNAILS": "Thumbnails", @@ -1976,41 +1991,119 @@ "RECORDING_RULE_DELETE_CANCEL": "Delete rule and cancel upcoming recordings", "RECORDING_RULE_ACTION_ENABLE": "Enable", "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" + "FAILOVER_GROUPS": "Failover groups", + "FAILOVER_GROUPS_MATCH_BY": "Match by", + "FAILOVER_GROUPS_ALLOW_PROVIDER_GRACE": "Allow provider grace", + "FAILOVER_GROUPS_CONFIG": "Failover groups", + "FAILOVER_GROUPS_GLOBAL": "Global", + "FAILOVER_GROUPS_MATCH_AS_ASCII": "Compare as ASCII", + "FAILOVER_GROUPS_STRIP_REGEX": "Remove from name (regex)", + "FAILOVER_GROUPS_IGNORE_TOKENS": "Ignored words", + "FAILOVER_GROUPS_ALIASES": "Aliases", + "FAILOVER_GROUPS_EPG_CONFLICT": "Different EPG ids", + "FAILOVER_GROUPS_NAME_STRIP_QUALITY": "Remove quality words from name", + "FAILOVER_GROUPS_POLICY": "Source order", + "FAILOVER_GROUPS_RUNTIME": "Switch source while watching", + "FAILOVER_GROUPS_GROUPING": "Grouping", + "FAILOVER_GROUPS_START": "Start", + "FAILOVER_GROUPS_CIRCUIT": "Pause failing sources", + "FAILOVER_GROUPS_START_TIMEOUT_SECS": "Start timeout (s)", + "FAILOVER_GROUPS_START_MAX_ATTEMPTS": "Sources tried at start", + "FAILOVER_GROUPS_NO_DATA_TIMEOUT_SECS": "No data timeout (s)", + "FAILOVER_GROUPS_SLOW_SOURCE_PCT": "Minimum speed (%)", + "FAILOVER_GROUPS_SLOW_SOURCE_SECS": "Too slow for (s)", + "FAILOVER_GROUPS_MAX_OUTAGE_SECS": "Max. outage (s)", + "FAILOVER_GROUPS_SAME_SOURCE_RETRIES": "Retries of the same source", + "FAILOVER_GROUPS_STABLE_AFTER_SECS": "Stable after (s)", + "FAILOVER_GROUPS_MAX_SWITCHES": "Max. switches", + "FAILOVER_GROUPS_MAX_SWITCHES_WINDOW_SECS": "Switch window (s)", + "FAILOVER_GROUPS_AUDIO_LANGUAGE": "Audio language", + "FAILOVER_GROUPS_SOURCE_FAILURES": "Failures until pause", + "FAILOVER_GROUPS_COOLDOWN_SECS": "Pause (s)", + "FAILOVER_GROUPS_COOLDOWN_MAX_SECS": "Max. pause (s)", + "FAILOVER_GROUPS_INPUT_FAILURES": "Input failures until pause", + "FAILOVER_GROUPS_INPUT_WINDOW_SECS": "Input failure window (s)", + "FAILOVER_GROUPS_IGNORE_TOKEN": "Example: backup", + "FAILOVER_GROUPS_ALIAS_NAME": "Name", + "FAILOVER_GROUPS_ALIAS_TARGET": "Compared as", + "VIEW_TYPE_FAILOVER_GROUPS": "Failover groups", + "EPG_CHANNEL_ID": "EPG channel id", + "LOGO": "Logo", + "FAILOVER_GROUPS_ADD_CHANNELS": "Add channels", + "FAILOVER_GROUPS_ADD_SELECTED": "Add selected to {group}", + "FAILOVER_GROUPS_ADD_TO_GROUP": "Add to {group}", + "FAILOVER_GROUPS_OPEN_GROUP": "Open failover group", + "FAILOVER_GROUPS_ADD_TO_GROUP_ELLIPSIS": "Add to failover group…", + "FAILOVER_GROUPS_MOVE_TO_GROUP": "Move to {group}", + "FAILOVER_GROUPS_APPLY": "Apply", + "FAILOVER_GROUPS_ASSIGN_TO_TARGET": "Assign to target…", + "FAILOVER_GROUPS_COL_ACTIONS": "Actions", + "FAILOVER_GROUPS_COL_BITRATE": "Bitrate", + "FAILOVER_GROUPS_COL_CIRCUIT": "Status", + "FAILOVER_GROUPS_COL_CODEC": "Codec", + "FAILOVER_GROUPS_COL_IN_GROUP": "In group", + "FAILOVER_GROUPS_COL_INPUT": "Input", + "FAILOVER_GROUPS_COL_ORDER": "Order", + "FAILOVER_GROUPS_COL_PLAYLIST_GROUP": "Playlist group", + "FAILOVER_GROUPS_COL_POSITION": "#", + "FAILOVER_GROUPS_COL_QOS": "QoS 24h/7d", + "FAILOVER_GROUPS_COL_RESOLUTION": "Resolution", + "FAILOVER_GROUPS_COL_SELECT": "Select", + "FAILOVER_GROUPS_COL_SLOTS": "Slots", + "FAILOVER_GROUPS_COL_TITLE": "Title", + "FAILOVER_GROUPS_CONFLICTS": "Conflicting channels", + "FAILOVER_GROUPS_CONVERT_TO_MANUAL": "Convert to manual group", + "FAILOVER_GROUPS_DELETE_GROUP": "Delete group", + "FAILOVER_GROUPS_DELETE_SECTION": "Delete section", + "FAILOVER_GROUPS_DISCARD": "Discard changes", + "FAILOVER_GROUPS_DISSOLVE": "Dissolve group", + "FAILOVER_GROUPS_DISSOLVED": "Dissolved", + "FAILOVER_GROUPS_DRAG": "Drag to reorder", + "FAILOVER_GROUPS_EXCLUDE_MEMBER": "Exclude", + "FAILOVER_GROUPS_FILTER_EXCLUDED": "Excluded", + "FAILOVER_GROUPS_FILTER_GROUPED": "Grouped", + "FAILOVER_GROUPS_FILTER_UNGROUPED": "Ungrouped", + "FAILOVER_GROUPS_FINDINGS": "Validation findings", + "FAILOVER_GROUPS_HEALTH_CLOSED": "OK", + "FAILOVER_GROUPS_HEALTH_COUNTING": "Failures counted", + "FAILOVER_GROUPS_HEALTH_HALF_OPEN": "Testing", + "FAILOVER_GROUPS_HEALTH_OPEN": "Paused", + "FAILOVER_GROUPS_INACTIVE": "Inactive", + "FAILOVER_GROUPS_INACTIVE_GROUPS": "Inactive manual groups", + "FAILOVER_GROUPS_KEEP_EDITING": "Keep editing", + "FAILOVER_GROUPS_MOVE_DOWN": "Move down", + "FAILOVER_GROUPS_MOVE_FIRST": "Move to first place", + "FAILOVER_GROUPS_MOVE_TO": "Move to…", + "FAILOVER_GROUPS_MOVE_UP": "Move up", + "FAILOVER_GROUPS_NEW_FROM_SELECTION": "New group from selection", + "FAILOVER_GROUPS_NEW_GROUP": "New group", + "FAILOVER_GROUPS_OPEN_GROUP": "Open group", + "FAILOVER_GROUPS_ORIGIN_AUTO": "Automatic", + "FAILOVER_GROUPS_ORIGIN_EDITED": "Edited", + "FAILOVER_GROUPS_ORIGIN_MANUAL": "Manual", + "FAILOVER_GROUPS_ORPHANS": "Sections of unknown targets", + "FAILOVER_GROUPS_DISABLED_SECTIONS": "Sections of targets with failover groups disabled", + "FAILOVER_GROUPS_PLAYLIST_GROUP": "Playlist group", + "FAILOVER_GROUPS_PROBLEMS": "Problems", + "FAILOVER_GROUPS_REFRESH_PREVIEW": "Refresh preview", + "FAILOVER_GROUPS_RELOAD_DISCARD": "Reload (discard my changes)", + "FAILOVER_GROUPS_REMOVE_FROM": "Remove from {group}", + "FAILOVER_GROUPS_REMOVE_MEMBER": "Remove from group", + "FAILOVER_GROUPS_REMOVE_OVERRIDE": "Remove correction", + "FAILOVER_GROUPS_REMOVE_REFERENCE": "Remove reference", + "FAILOVER_GROUPS_RESET_AUTO": "Reset to automatic", + "FAILOVER_GROUPS_SAVE_AND_APPLY": "Save & apply", + "FAILOVER_GROUPS_SEARCH_CHANNELS": "Search channels…", + "FAILOVER_GROUPS_SEARCH_GROUPS": "Search groups…", + "FAILOVER_GROUPS_SELECTED": "{count} selected", + "FAILOVER_GROUPS_STALE": "Outdated corrections", + "FAILOVER_GROUPS_STATUS_APPLIED": "applied", + "FAILOVER_GROUPS_STATUS_APPLY_PENDING": "saved, apply pending", + "FAILOVER_GROUPS_STATUS_FILE_ERROR": "file error", + "FAILOVER_GROUPS_STATUS_UNSAVED": "unsaved changes", + "FAILOVER_GROUPS_UNDO": "Undo", + "FAILOVER_GROUPS_UNRESOLVED": "Unresolved channels", + "FAILOVER_GROUPS_CURRENT_VALUES": "Current values" }, "MESSAGES": { "CLIPBOARD_NOT_SUPPORTED": "Clipboard not supported.\nYour browser or current context does not allow clipboard access.\nPlease use HTTPS or localhost.", @@ -2284,7 +2377,41 @@ }, "USER_BOUQUET": { "FAIL": "Failed to download user bouquets!" - } + }, + "FAILOVER_GROUPS_APPLY_BUSY": "A playlist update is already queued for this target.", + "FAILOVER_GROUPS_APPLY_HINT": "Apply starts a playlist update; the input cache is respected.", + "FAILOVER_GROUPS_APPLY_QUEUED": "Playlist update queued.", + "FAILOVER_GROUPS_CANDIDATES_FAILED": "Channels could not be loaded.", + "FAILOVER_GROUPS_CHANGED_ELSEWHERE": "The failover overrides of this target were changed elsewhere (file or another tab). Reload and discard your changes, or keep editing? Saving is rejected until you reload.", + "FAILOVER_GROUPS_CONFIRM_DELETE_GROUP": "Delete this manual group?", + "FAILOVER_GROUPS_CONFIRM_DELETE_SECTION": "Delete the whole section of this target from the failover file?", + "FAILOVER_GROUPS_CONFIRM_DISCARD": "Discard all unsaved changes?", + "FAILOVER_GROUPS_CONFIRM_SWITCH": "You have unsaved changes. Switching the target discards them.", + "FAILOVER_GROUPS_CONFLICT_HINT": "These channels are claimed by more than one group; only the first claim counts.", + "FAILOVER_GROUPS_DISSOLVED_HINT": "This automatic group is dissolved; its channels stay ungrouped. Reset it to get the automatic group back.", + "FAILOVER_GROUPS_FILE_ERROR": "The failover files cannot be read; editing is disabled until the file is fixed", + "FAILOVER_GROUPS_INACTIVE_HINT": "This group has fewer than two usable channels and is not used yet.", + "FAILOVER_GROUPS_INVALID": "The overrides are invalid; see the problems list.", + "FAILOVER_GROUPS_LOCKED": "The failover file cannot be written", + "FAILOVER_GROUPS_NEW_FILE": "A new failover file will be created", + "FAILOVER_GROUPS_NO_CATALOG": "No channel catalog yet. Run a playlist update for this target first.", + "FAILOVER_GROUPS_NO_PROBLEMS": "No problems found.", + "FAILOVER_GROUPS_NO_TARGETS": "No target has failover groups enabled. Enable them in the target settings.", + "FAILOVER_GROUPS_TARGET_UNAVAILABLE": "The target is not available in the failover groups page. Save the target with failover groups enabled first.", + "FAILOVER_GROUPS_ORPHAN_HINT": "These sections belong to targets that no longer exist. Assign a section to another target or delete it.", + "FAILOVER_GROUPS_DISABLED_HINT": "These targets exist, but failover groups are disabled for them. Enable failover groups for the target, assign the section to another target or delete it.", + "FAILOVER_GROUPS_TARGET_DISABLED": "target has failover groups disabled", + "FAILOVER_GROUPS_DISPLAY_STALE": "The new order is active. Name and logo of the playlist entry update with the next playlist update.", + "FAILOVER_GROUPS_RESOLVED_MEMBERS": "{count} usable", + "FAILOVER_GROUPS_SAVED_APPLIED": "Saved and applied.", + "FAILOVER_GROUPS_SAVED_PENDING": "Saved. Press Apply to use it (the update respects the input cache).", + "FAILOVER_GROUPS_SECTION_UPDATED": "Failover section updated.", + "FAILOVER_GROUPS_SELECT_GROUP": "Select a group to edit its channels.", + "FAILOVER_GROUPS_SHARED_FILE_NOTICE": "This save rewrites {file}, which also holds sections of other targets. Comments and formatting of that file are not preserved. This notice is shown once per session.", + "FAILOVER_GROUPS_STALE_HINT": "The channel key of these corrections matches no group anymore. Convert one into a manual group or remove it.", + "FAILOVER_GROUPS_UNRESOLVED_HINT": "These references match no channel of the playlist. Remove them or wait for the channel to return.", + "FAILOVER_GROUPS_UPDATING": "Updating preview…", + "FAILOVER_GROUPS_CHANGED_ELSEWHERE_BANNER": "Changed elsewhere (file or another tab). Saving is rejected until you reload." }, "SETUP": { "DESC": { diff --git a/frontend/public/assets/i18n/es.json b/frontend/public/assets/i18n/es.json index 5270d854c..c80290045 100644 --- a/frontend/public/assets/i18n/es.json +++ b/frontend/public/assets/i18n/es.json @@ -585,6 +585,7 @@ "MAPPING_PATH": "Ruta o directorio que contiene los archivos de configuración de mapeo.", "PROCESS_PARALLEL": "Si está habilitado, las listas de reproducción de entrada se procesan en paralelo para reducir la latencia. Sin embargo, esto actualmente no está soportado.", "SLEEP_TIMER_MINS": "Cierra automáticamente los streams y detiene la reproducción tras el número especificado de minutos de inactividad.", + "FAILOVER_PATH": "Ruta opcional a un archivo o directorio de failover (p. ej. `failover.yml` o `failover.d/`) con ajustes de grupos de failover. Déjela vacía para detectar automáticamente `failover.yml` / `failover.d` en el directorio de configuración.", "TEMPLATE_PATH": "Ruta o directorio que contiene los archivos de configuración de plantilla.", "UPDATE_ON_BOOT": "Si está habilitado, Tuliprox activa una actualización completa de la lista de reproducción inmediatamente al iniciar. Útil durante la configuración inicial, pero normalmente se establece en falso para operaciones estables.", "USER_ACCESS_CONTROL": "Si está habilitado, los atributos (si están establecidos)\n\n- fecha de expiración,\n- estado y\n- max_connections\n\nse comprueban para permitir o denegar el acceso a los usuarios de la API.", @@ -874,10 +875,10 @@ "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": { + "FAILOVER_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": { + "FAILOVER_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.", @@ -890,11 +891,11 @@ "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": { + "FAILOVER_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": { + "FAILOVER_GROUP_RUNTIME_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.", @@ -906,12 +907,25 @@ "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": { + "FAILOVER_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." + }, + "FAILOVER_GROUP_META": { + "NAME": "Nombre mostrado para el canal unido. Déjalo vacío para conservar el nombre automático.", + "LOGO": "URL del logotipo (http o https) del canal unido. Vacío conserva el logotipo automático.", + "EPG_CHANNEL_ID": "ID de canal EPG del canal unido. Vacío conserva el ID automático.", + "PLAYLIST_GROUP": "Grupo de lista en el que aparece el canal unido. Vacío conserva el grupo automático.", + "POLICY": "Orden de fuentes de este grupo. Global sigue el ajuste del destino; auto ordena por estado y calidad, priority mantiene el orden de la lista." + }, + "NEW_FAILOVER_GROUP": { + "NAME": "Nombre del nuevo grupo manual. Su ID se deriva del nombre." + }, + "FAILOVER_GROUP_TARGET": { + "TARGET": "Destino cuyos grupos de failover se editan. Solo aparecen los destinos con grupos de failover activados." } }, "HINT": { @@ -947,8 +961,8 @@ "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", - "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." + "FAILOVER_GROUPS_CONFIG": "Los grupos de failover 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 failover; un target puede sobrescribir algunos en su propia página de grupos de failover.", + "FAILOVER_GROUPS_TARGET": "Activa los grupos de failover para este target. Los ajustes en \"Global\" usan Ajustes > Grupos de failover." }, "LABEL": { "HLS_STARTUP_LIMITS": "Límites de inicio rápido", @@ -1759,6 +1773,7 @@ "THRESHOLD_SCORE": "Puntuación umbral", "TEMPLATES": "Plantillas", "TEMPLATE_PATH": "Ruta de plantilla", + "FAILOVER_PATH": "Ruta de failover", "THROTTLE": "Limitar", "THROTTLE_KBPS": "Límite kbps", "THUMBNAILS": "Miniaturas", @@ -1976,41 +1991,119 @@ "RECORDING_RULE_DELETE_CANCEL": "Eliminar regla y cancelar próximas grabaciones", "RECORDING_RULE_ACTION_ENABLE": "Habilitar", "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" + "FAILOVER_GROUPS": "Grupos de failover", + "FAILOVER_GROUPS_MATCH_BY": "Comparar por", + "FAILOVER_GROUPS_ALLOW_PROVIDER_GRACE": "Permitir gracia del proveedor", + "FAILOVER_GROUPS_CONFIG": "Grupos de failover", + "FAILOVER_GROUPS_GLOBAL": "Global", + "FAILOVER_GROUPS_MATCH_AS_ASCII": "Comparar como ASCII", + "FAILOVER_GROUPS_STRIP_REGEX": "Quitar del nombre (regex)", + "FAILOVER_GROUPS_IGNORE_TOKENS": "Palabras ignoradas", + "FAILOVER_GROUPS_ALIASES": "Alias", + "FAILOVER_GROUPS_EPG_CONFLICT": "IDs de EPG distintos", + "FAILOVER_GROUPS_NAME_STRIP_QUALITY": "Quitar palabras de calidad del nombre", + "FAILOVER_GROUPS_POLICY": "Orden de fuentes", + "FAILOVER_GROUPS_RUNTIME": "Cambiar de fuente durante la reproducción", + "FAILOVER_GROUPS_GROUPING": "Agrupación", + "FAILOVER_GROUPS_START": "Inicio", + "FAILOVER_GROUPS_CIRCUIT": "Pausar fuentes con fallos", + "FAILOVER_GROUPS_START_TIMEOUT_SECS": "Tiempo de inicio (s)", + "FAILOVER_GROUPS_START_MAX_ATTEMPTS": "Fuentes probadas al inicio", + "FAILOVER_GROUPS_NO_DATA_TIMEOUT_SECS": "Tiempo sin datos (s)", + "FAILOVER_GROUPS_SLOW_SOURCE_PCT": "Velocidad mínima (%)", + "FAILOVER_GROUPS_SLOW_SOURCE_SECS": "Demasiado lenta durante (s)", + "FAILOVER_GROUPS_MAX_OUTAGE_SECS": "Corte máximo (s)", + "FAILOVER_GROUPS_SAME_SOURCE_RETRIES": "Reintentos de la misma fuente", + "FAILOVER_GROUPS_STABLE_AFTER_SECS": "Estable tras (s)", + "FAILOVER_GROUPS_MAX_SWITCHES": "Máx. cambios", + "FAILOVER_GROUPS_MAX_SWITCHES_WINDOW_SECS": "Ventana de cambios (s)", + "FAILOVER_GROUPS_AUDIO_LANGUAGE": "Idioma de audio", + "FAILOVER_GROUPS_SOURCE_FAILURES": "Fallos hasta la pausa", + "FAILOVER_GROUPS_COOLDOWN_SECS": "Pausa (s)", + "FAILOVER_GROUPS_COOLDOWN_MAX_SECS": "Pausa máx. (s)", + "FAILOVER_GROUPS_INPUT_FAILURES": "Fallos de entrada hasta la pausa", + "FAILOVER_GROUPS_INPUT_WINDOW_SECS": "Ventana de fallos de entrada (s)", + "FAILOVER_GROUPS_IGNORE_TOKEN": "Ejemplo: backup", + "FAILOVER_GROUPS_ALIAS_NAME": "Nombre", + "FAILOVER_GROUPS_ALIAS_TARGET": "Se compara como", + "VIEW_TYPE_FAILOVER_GROUPS": "Grupos de failover", + "EPG_CHANNEL_ID": "ID de canal EPG", + "LOGO": "Logotipo", + "FAILOVER_GROUPS_ADD_CHANNELS": "Añadir canales", + "FAILOVER_GROUPS_ADD_SELECTED": "Añadir la selección a {group}", + "FAILOVER_GROUPS_ADD_TO_GROUP": "Añadir a {group}", + "FAILOVER_GROUPS_OPEN_GROUP": "Abrir grupo de failover", + "FAILOVER_GROUPS_ADD_TO_GROUP_ELLIPSIS": "Añadir a grupo de failover…", + "FAILOVER_GROUPS_MOVE_TO_GROUP": "Mover a {group}", + "FAILOVER_GROUPS_APPLY": "Aplicar", + "FAILOVER_GROUPS_ASSIGN_TO_TARGET": "Asignar a un destino…", + "FAILOVER_GROUPS_COL_ACTIONS": "Acciones", + "FAILOVER_GROUPS_COL_BITRATE": "Bitrate", + "FAILOVER_GROUPS_COL_CIRCUIT": "Estado", + "FAILOVER_GROUPS_COL_CODEC": "Códec", + "FAILOVER_GROUPS_COL_IN_GROUP": "En el grupo", + "FAILOVER_GROUPS_COL_INPUT": "Entrada", + "FAILOVER_GROUPS_COL_ORDER": "Orden", + "FAILOVER_GROUPS_COL_PLAYLIST_GROUP": "Grupo de lista", + "FAILOVER_GROUPS_COL_POSITION": "N.º", + "FAILOVER_GROUPS_COL_QOS": "QoS 24 h/7 d", + "FAILOVER_GROUPS_COL_RESOLUTION": "Resolución", + "FAILOVER_GROUPS_COL_SELECT": "Seleccionar", + "FAILOVER_GROUPS_COL_SLOTS": "Conexiones", + "FAILOVER_GROUPS_COL_TITLE": "Título", + "FAILOVER_GROUPS_CONFLICTS": "Canales en conflicto", + "FAILOVER_GROUPS_CONVERT_TO_MANUAL": "Convertir en grupo manual", + "FAILOVER_GROUPS_DELETE_GROUP": "Eliminar grupo", + "FAILOVER_GROUPS_DELETE_SECTION": "Eliminar sección", + "FAILOVER_GROUPS_DISCARD": "Descartar cambios", + "FAILOVER_GROUPS_DISSOLVE": "Disolver grupo", + "FAILOVER_GROUPS_DISSOLVED": "Disuelto", + "FAILOVER_GROUPS_DRAG": "Arrastrar para reordenar", + "FAILOVER_GROUPS_EXCLUDE_MEMBER": "Excluir", + "FAILOVER_GROUPS_FILTER_EXCLUDED": "Excluidos", + "FAILOVER_GROUPS_FILTER_GROUPED": "Agrupados", + "FAILOVER_GROUPS_FILTER_UNGROUPED": "Sin agrupar", + "FAILOVER_GROUPS_FINDINGS": "Hallazgos de validación", + "FAILOVER_GROUPS_HEALTH_CLOSED": "Correcto", + "FAILOVER_GROUPS_HEALTH_COUNTING": "Fallos contados", + "FAILOVER_GROUPS_HEALTH_HALF_OPEN": "En prueba", + "FAILOVER_GROUPS_HEALTH_OPEN": "En pausa", + "FAILOVER_GROUPS_INACTIVE": "Inactivo", + "FAILOVER_GROUPS_INACTIVE_GROUPS": "Grupos manuales inactivos", + "FAILOVER_GROUPS_KEEP_EDITING": "Seguir editando", + "FAILOVER_GROUPS_MOVE_DOWN": "Bajar", + "FAILOVER_GROUPS_MOVE_FIRST": "Mover al primer lugar", + "FAILOVER_GROUPS_MOVE_TO": "Mover a…", + "FAILOVER_GROUPS_MOVE_UP": "Subir", + "FAILOVER_GROUPS_NEW_FROM_SELECTION": "Nuevo grupo con la selección", + "FAILOVER_GROUPS_NEW_GROUP": "Nuevo grupo", + "FAILOVER_GROUPS_OPEN_GROUP": "Abrir grupo", + "FAILOVER_GROUPS_ORIGIN_AUTO": "Automático", + "FAILOVER_GROUPS_ORIGIN_EDITED": "Editado", + "FAILOVER_GROUPS_ORIGIN_MANUAL": "Manual", + "FAILOVER_GROUPS_ORPHANS": "Secciones de destinos desconocidos", + "FAILOVER_GROUPS_DISABLED_SECTIONS": "Secciones de destinos con los grupos de failover desactivados", + "FAILOVER_GROUPS_PLAYLIST_GROUP": "Grupo de lista", + "FAILOVER_GROUPS_PROBLEMS": "Problemas", + "FAILOVER_GROUPS_REFRESH_PREVIEW": "Actualizar vista previa", + "FAILOVER_GROUPS_RELOAD_DISCARD": "Recargar (descartar mis cambios)", + "FAILOVER_GROUPS_REMOVE_FROM": "Quitar de {group}", + "FAILOVER_GROUPS_REMOVE_MEMBER": "Quitar del grupo", + "FAILOVER_GROUPS_REMOVE_OVERRIDE": "Eliminar corrección", + "FAILOVER_GROUPS_REMOVE_REFERENCE": "Eliminar referencia", + "FAILOVER_GROUPS_RESET_AUTO": "Restablecer a automático", + "FAILOVER_GROUPS_SAVE_AND_APPLY": "Guardar y aplicar", + "FAILOVER_GROUPS_SEARCH_CHANNELS": "Buscar canales…", + "FAILOVER_GROUPS_SEARCH_GROUPS": "Buscar grupos…", + "FAILOVER_GROUPS_SELECTED": "{count} seleccionados", + "FAILOVER_GROUPS_STALE": "Correcciones obsoletas", + "FAILOVER_GROUPS_STATUS_APPLIED": "aplicado", + "FAILOVER_GROUPS_STATUS_APPLY_PENDING": "guardado, pendiente de aplicar", + "FAILOVER_GROUPS_STATUS_FILE_ERROR": "error de archivo", + "FAILOVER_GROUPS_STATUS_UNSAVED": "cambios sin guardar", + "FAILOVER_GROUPS_UNDO": "Deshacer", + "FAILOVER_GROUPS_UNRESOLVED": "Canales no resueltos", + "FAILOVER_GROUPS_CURRENT_VALUES": "Valores actuales" }, "MESSAGES": { "CLIPBOARD_NOT_SUPPORTED": "Portapapeles no compatible.\nSu navegador o el contexto actual no permite el acceso al portapapeles.\nUtilice HTTPS o localhost.", @@ -2284,7 +2377,41 @@ }, "USER_BOUQUET": { "FAIL": "¡No se pudieron descargar los bouquets de usuario!" - } + }, + "FAILOVER_GROUPS_APPLY_BUSY": "Ya hay una actualización de lista en cola para este destino.", + "FAILOVER_GROUPS_APPLY_HINT": "Aplicar inicia una actualización de lista; se respeta la caché de entrada.", + "FAILOVER_GROUPS_APPLY_QUEUED": "Actualización de lista en cola.", + "FAILOVER_GROUPS_CANDIDATES_FAILED": "No se pudieron cargar los canales.", + "FAILOVER_GROUPS_CHANGED_ELSEWHERE": "Las correcciones de failover de este destino se cambiaron en otro lugar (archivo u otra pestaña). ¿Recargar y descartar tus cambios, o seguir editando? No se podrá guardar hasta que recargues.", + "FAILOVER_GROUPS_CONFIRM_DELETE_GROUP": "¿Eliminar este grupo manual?", + "FAILOVER_GROUPS_CONFIRM_DELETE_SECTION": "¿Eliminar del archivo de failover toda la sección de este destino?", + "FAILOVER_GROUPS_CONFIRM_DISCARD": "¿Descartar todos los cambios sin guardar?", + "FAILOVER_GROUPS_CONFIRM_SWITCH": "Tienes cambios sin guardar. Cambiar de destino los descartará.", + "FAILOVER_GROUPS_CONFLICT_HINT": "Estos canales son reclamados por más de un grupo; solo cuenta la primera reclamación.", + "FAILOVER_GROUPS_DISSOLVED_HINT": "Este grupo automático está disuelto; sus canales quedan sin agrupar. Restablécelo para recuperar el grupo automático.", + "FAILOVER_GROUPS_FILE_ERROR": "No se pueden leer los archivos de failover; la edición está desactivada hasta que se corrija el archivo", + "FAILOVER_GROUPS_INACTIVE_HINT": "Este grupo tiene menos de dos canales utilizables y aún no se usa.", + "FAILOVER_GROUPS_INVALID": "Las correcciones no son válidas; consulta la lista de problemas.", + "FAILOVER_GROUPS_LOCKED": "No se puede escribir el archivo de failover", + "FAILOVER_GROUPS_NEW_FILE": "Se creará un archivo de failover nuevo", + "FAILOVER_GROUPS_NO_CATALOG": "Aún no hay catálogo de canales. Ejecuta primero una actualización de lista para este destino.", + "FAILOVER_GROUPS_NO_PROBLEMS": "No se encontraron problemas.", + "FAILOVER_GROUPS_NO_TARGETS": "Ningún destino tiene grupos de failover activados. Actívalos en los ajustes del destino.", + "FAILOVER_GROUPS_TARGET_UNAVAILABLE": "El destino no está disponible en la página de grupos de failover. Guarda primero el destino con los grupos de failover activados.", + "FAILOVER_GROUPS_ORPHAN_HINT": "Estas secciones pertenecen a destinos que ya no existen. Asigna la sección a otro destino o elimínala.", + "FAILOVER_GROUPS_DISABLED_HINT": "Estos destinos existen, pero tienen los grupos de failover desactivados. Activa los grupos de failover del destino, asigna la sección a otro destino o elimínala.", + "FAILOVER_GROUPS_TARGET_DISABLED": "el destino tiene los grupos de failover desactivados", + "FAILOVER_GROUPS_DISPLAY_STALE": "El nuevo orden está activo. El nombre y el logotipo de la entrada de la lista se actualizan con la próxima actualización de la lista.", + "FAILOVER_GROUPS_RESOLVED_MEMBERS": "{count} utilizables", + "FAILOVER_GROUPS_SAVED_APPLIED": "Guardado y aplicado.", + "FAILOVER_GROUPS_SAVED_PENDING": "Guardado. Pulsa Aplicar para usarlo (la actualización respeta la caché de entrada).", + "FAILOVER_GROUPS_SECTION_UPDATED": "Sección de failover actualizada.", + "FAILOVER_GROUPS_SELECT_GROUP": "Selecciona un grupo para editar sus canales.", + "FAILOVER_GROUPS_SHARED_FILE_NOTICE": "Este guardado reescribe {file}, que también contiene secciones de otros destinos. Los comentarios y el formato de ese archivo no se conservan. Este aviso se muestra una vez por sesión.", + "FAILOVER_GROUPS_STALE_HINT": "La clave de canal de estas correcciones ya no coincide con ningún grupo. Conviértela en un grupo manual o elimínala.", + "FAILOVER_GROUPS_UNRESOLVED_HINT": "Estas referencias no coinciden con ningún canal de la lista. Elimínalas o espera a que el canal vuelva.", + "FAILOVER_GROUPS_UPDATING": "Actualizando vista previa…", + "FAILOVER_GROUPS_CHANGED_ELSEWHERE_BANNER": "Cambiado en otro lugar (archivo u otra pestaña). No se podrá guardar hasta que recargues." }, "SETUP": { "DESC": { diff --git a/frontend/public/assets/i18n/ru.json b/frontend/public/assets/i18n/ru.json index a5889f171..a2897faa7 100644 --- a/frontend/public/assets/i18n/ru.json +++ b/frontend/public/assets/i18n/ru.json @@ -585,6 +585,7 @@ "MAPPING_PATH": "Путь или директория, содержащая файлы конфигурации сопоставления.", "PROCESS_PARALLEL": "Если включено, плейлисты ввода обрабатываются параллельно для уменьшения задержки. Однако в настоящее время это не поддерживается.", "SLEEP_TIMER_MINS": "Автоматически закрывает потоки и останавливает потоковую передачу после указанного количества минут бездействия.", + "FAILOVER_PATH": "Необязательный путь к файлу или директории failover (например, `failover.yml` или `failover.d/`) с правками групп failover. Оставьте пустым, чтобы `failover.yml` / `failover.d` определялись автоматически в каталоге конфигурации.", "TEMPLATE_PATH": "Путь или директория, содержащая файлы конфигурации шаблонов.", "UPDATE_ON_BOOT": "Если включено, Tuliprox запускает полное обновление плейлиста сразу при запуске. Полезно во время начальной настройки, но обычно устанавливается в false для стабильной работы.", "USER_ACCESS_CONTROL": "Если включено, атрибуты (если заданы)\n\n- дата истечения срока действия,\n- статус и\n- max_connections\n\nпроверяются, чтобы разрешить или запретить доступ пользователям API.", @@ -874,10 +875,10 @@ "INPUT": "Вход, который выводится из эксплуатации. Пока он на обслуживании, новые потоки получают видео обслуживания, а Tuliprox не обращается к его провайдеру; текущие потоки и записи продолжаются.", "REASON": "Необязательное примечание в списке обслуживания, например почему провайдер недоступен. Не более 500 символов." }, - "CHANNEL_GROUPS_TARGET_CONFIG": { + "FAILOVER_GROUPS_TARGET_CONFIG": { "ENABLED": "Объединяет одинаковые live-каналы этой цели в один канал. Воспроизведение начинается с лучшего доступного источника и переключается на другой при сбое." }, - "CHANNEL_GROUPS_CONFIG": { + "FAILOVER_GROUPS_CONFIG": { "MATCH_BY": "Какое поле решает, что два канала одинаковы: caption, name, title или EPG id канала. Слова качества вроде HD или FHD игнорируются.", "MATCH_AS_ASCII": "Сравнивает имена после замены диакритики и особых букв на простые, так «Télé» и «Tele» совпадают.", "EPG_CONFLICT": "split: каналы с одинаковым именем, но разными EPG id остаются в разных группах (разные программы). ignore: они всё равно объединяются.", @@ -890,11 +891,11 @@ "IGNORE_TOKENS": "Слова, которые игнорируются при сравнении имён, например \"backup\" или \"hevc\".", "ALIASES": "Имена одного и того же канала: левое имя сравнивается как правое, например \"das erste\" как \"ard\"." }, - "CHANNEL_GROUP_START_CONFIG": { + "FAILOVER_GROUP_START_CONFIG": { "TIMEOUT_SECS": "Максимальное время, которое запуск перебирает источники, прежде чем клиент получит видео «недоступно». 0 — без ограничения.", "MAX_ATTEMPTS": "Сколько источников максимум пробует запуск." }, - "CHANNEL_GROUP_FAILOVER_CONFIG": { + "FAILOVER_GROUP_RUNTIME_CONFIG": { "ENABLED": "Если источник отказывает, Tuliprox открывает другой источник группы и продолжает тот же поток; плеер сохраняет соединение. Только для MPEG-TS; HLS меняет источник только при новом запуске.", "NO_DATA_TIMEOUT_SECS": "Источник без картинки и звука дольше этого времени заменяется. Значение должно быть больше интервала ключевых кадров (несколько секунд). 0 отключает проверку.", "SLOW_SOURCE_PCT": "Источник должен выдавать не меньше этой доли реальной скорости. 90 значит: за 10 секунд он должен выдать 9 секунд видео, иначе буфер плеера опустеет и картинка будет прерываться. 0 отключает проверку.", @@ -906,12 +907,25 @@ "MAX_SWITCHES_WINDOW_SECS": "Окно времени для максимума переключений.", "AUDIO_LANGUAGE": "prefer: при переключении предпочитается источник с тем же языком аудио. require: используются только источники с тем же языком аудио." }, - "CHANNEL_GROUP_CIRCUIT_CONFIG": { + "FAILOVER_GROUP_CIRCUIT_CONFIG": { "SOURCE_FAILURES": "Сбоев подряд, после которых источник ставится на паузу и сначала пробуются другие.", "COOLDOWN_SECS": "Первая пауза сбойного источника. Удваивается, пока источник продолжает отказывать.", "COOLDOWN_MAX_SECS": "Самая длинная пауза сбойного источника.", "INPUT_FAILURES": "Сбоев разных каналов одного входа в окне, после которых весь вход пробуется последним (провайдер недоступен).", "INPUT_WINDOW_SECS": "Окно времени для сбоев входа." + }, + "FAILOVER_GROUP_META": { + "NAME": "Имя объединённого канала. Оставьте пустым, чтобы сохранить автоматическое имя.", + "LOGO": "URL логотипа (http или https) объединённого канала. Пусто — автоматический логотип.", + "EPG_CHANNEL_ID": "ID канала EPG объединённого канала. Пусто — автоматический ID.", + "PLAYLIST_GROUP": "Группа плейлиста, в которой отображается объединённый канал. Пусто — автоматическая группа.", + "POLICY": "Порядок источников этой группы. «Глобально» следует настройке цели; auto — по состоянию и качеству, priority — по порядку списка." + }, + "NEW_FAILOVER_GROUP": { + "NAME": "Имя новой ручной группы. Её ID образуется из имени." + }, + "FAILOVER_GROUP_TARGET": { + "TARGET": "Цель, группы failover которой редактируются. Показаны только цели с включёнными группами failover." } }, "HINT": { @@ -947,8 +961,8 @@ "RESTART_TO_APPLY_CHANGES": "Вам нужно перезагрузиться, чтобы применить изменения.", "SCHEDULE_EXAMPLE": "Пример расписания", "WEB_ROOT": "Web-Root: Содержит файлы веб-интерфейса. Они обычно размещаются в той же директории под ./web", - "CHANNEL_GROUPS_CONFIG": "Группы каналов объединяют один и тот же live-канал из нескольких входов в один канал. Эти настройки действуют для всех целей, где включены группы каналов; цель может переопределить часть из них на своей странице «Группы каналов».", - "CHANNEL_GROUPS_TARGET": "Включает группы каналов для этой цели. Значения «Глобально» берутся из Настройки > Группы каналов." + "FAILOVER_GROUPS_CONFIG": "Группы каналов объединяют один и тот же live-канал из нескольких входов в один канал. Эти настройки действуют для всех целей, где включены группы каналов; цель может переопределить часть из них на своей странице «Группы каналов».", + "FAILOVER_GROUPS_TARGET": "Включает группы каналов для этой цели. Значения «Глобально» берутся из Настройки > Группы каналов." }, "LABEL": { "HLS_STARTUP_LIMITS": "Ограничения быстрого старта", @@ -1678,6 +1692,7 @@ "THRESHOLD_SCORE": "Пороговая оценка", "TEMPLATES": "Шаблоны", "TEMPLATE_PATH": "Путь шаблонов", + "FAILOVER_PATH": "Путь failover", "THROTTLE": "Ограничение", "THROTTLE_KBPS": "Ограничение Кбит/с", "THUMBNAILS": "Миниатюры", @@ -1976,41 +1991,119 @@ "RECORDING_RULE_DELETE_CANCEL": "Удалить правило и отменить будущие записи", "RECORDING_RULE_ACTION_ENABLE": "Включить", "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": "Сравнивать как" + "FAILOVER_GROUPS": "Группы каналов", + "FAILOVER_GROUPS_MATCH_BY": "Сравнивать по", + "FAILOVER_GROUPS_ALLOW_PROVIDER_GRACE": "Разрешить льготное подключение", + "FAILOVER_GROUPS_CONFIG": "Группы каналов", + "FAILOVER_GROUPS_GLOBAL": "Глобально", + "FAILOVER_GROUPS_MATCH_AS_ASCII": "Сравнивать как ASCII", + "FAILOVER_GROUPS_STRIP_REGEX": "Удалить из имени (regex)", + "FAILOVER_GROUPS_IGNORE_TOKENS": "Игнорируемые слова", + "FAILOVER_GROUPS_ALIASES": "Псевдонимы", + "FAILOVER_GROUPS_EPG_CONFLICT": "Разные EPG id", + "FAILOVER_GROUPS_NAME_STRIP_QUALITY": "Убрать слова качества из имени", + "FAILOVER_GROUPS_POLICY": "Порядок источников", + "FAILOVER_GROUPS_RUNTIME": "Переключать источник во время просмотра", + "FAILOVER_GROUPS_GROUPING": "Группировка", + "FAILOVER_GROUPS_START": "Запуск", + "FAILOVER_GROUPS_CIRCUIT": "Пауза для сбойных источников", + "FAILOVER_GROUPS_START_TIMEOUT_SECS": "Тайм-аут запуска (с)", + "FAILOVER_GROUPS_START_MAX_ATTEMPTS": "Источников при запуске", + "FAILOVER_GROUPS_NO_DATA_TIMEOUT_SECS": "Тайм-аут без данных (с)", + "FAILOVER_GROUPS_SLOW_SOURCE_PCT": "Минимальная скорость (%)", + "FAILOVER_GROUPS_SLOW_SOURCE_SECS": "Слишком медленно в течение (с)", + "FAILOVER_GROUPS_MAX_OUTAGE_SECS": "Макс. перерыв (с)", + "FAILOVER_GROUPS_SAME_SOURCE_RETRIES": "Повторы того же источника", + "FAILOVER_GROUPS_STABLE_AFTER_SECS": "Стабилен через (с)", + "FAILOVER_GROUPS_MAX_SWITCHES": "Макс. переключений", + "FAILOVER_GROUPS_MAX_SWITCHES_WINDOW_SECS": "Окно переключений (с)", + "FAILOVER_GROUPS_AUDIO_LANGUAGE": "Язык аудио", + "FAILOVER_GROUPS_SOURCE_FAILURES": "Сбоев до паузы", + "FAILOVER_GROUPS_COOLDOWN_SECS": "Пауза (с)", + "FAILOVER_GROUPS_COOLDOWN_MAX_SECS": "Макс. пауза (с)", + "FAILOVER_GROUPS_INPUT_FAILURES": "Сбоев входа до паузы", + "FAILOVER_GROUPS_INPUT_WINDOW_SECS": "Окно сбоев входа (с)", + "FAILOVER_GROUPS_IGNORE_TOKEN": "Например: backup", + "FAILOVER_GROUPS_ALIAS_NAME": "Имя", + "FAILOVER_GROUPS_ALIAS_TARGET": "Сравнивать как", + "VIEW_TYPE_FAILOVER_GROUPS": "Группы каналов", + "EPG_CHANNEL_ID": "ID канала EPG", + "LOGO": "Логотип", + "FAILOVER_GROUPS_ADD_CHANNELS": "Добавить каналы", + "FAILOVER_GROUPS_ADD_SELECTED": "Добавить выбранные в {group}", + "FAILOVER_GROUPS_ADD_TO_GROUP": "Добавить в {group}", + "FAILOVER_GROUPS_OPEN_GROUP": "Открыть группу failover", + "FAILOVER_GROUPS_ADD_TO_GROUP_ELLIPSIS": "Добавить в группу failover…", + "FAILOVER_GROUPS_MOVE_TO_GROUP": "Переместить в {group}", + "FAILOVER_GROUPS_APPLY": "Применить", + "FAILOVER_GROUPS_ASSIGN_TO_TARGET": "Назначить цели…", + "FAILOVER_GROUPS_COL_ACTIONS": "Действия", + "FAILOVER_GROUPS_COL_BITRATE": "Битрейт", + "FAILOVER_GROUPS_COL_CIRCUIT": "Состояние", + "FAILOVER_GROUPS_COL_CODEC": "Кодек", + "FAILOVER_GROUPS_COL_IN_GROUP": "В группе", + "FAILOVER_GROUPS_COL_INPUT": "Вход", + "FAILOVER_GROUPS_COL_ORDER": "Порядок", + "FAILOVER_GROUPS_COL_PLAYLIST_GROUP": "Группа плейлиста", + "FAILOVER_GROUPS_COL_POSITION": "№", + "FAILOVER_GROUPS_COL_QOS": "QoS 24ч/7д", + "FAILOVER_GROUPS_COL_RESOLUTION": "Разрешение", + "FAILOVER_GROUPS_COL_SELECT": "Выбрать", + "FAILOVER_GROUPS_COL_SLOTS": "Слоты", + "FAILOVER_GROUPS_COL_TITLE": "Заголовок", + "FAILOVER_GROUPS_CONFLICTS": "Конфликтующие каналы", + "FAILOVER_GROUPS_CONVERT_TO_MANUAL": "Преобразовать в ручную группу", + "FAILOVER_GROUPS_DELETE_GROUP": "Удалить группу", + "FAILOVER_GROUPS_DELETE_SECTION": "Удалить раздел", + "FAILOVER_GROUPS_DISCARD": "Отбросить изменения", + "FAILOVER_GROUPS_DISSOLVE": "Расформировать группу", + "FAILOVER_GROUPS_DISSOLVED": "Расформирована", + "FAILOVER_GROUPS_DRAG": "Перетащите, чтобы изменить порядок", + "FAILOVER_GROUPS_EXCLUDE_MEMBER": "Исключить", + "FAILOVER_GROUPS_FILTER_EXCLUDED": "Исключённые", + "FAILOVER_GROUPS_FILTER_GROUPED": "В группах", + "FAILOVER_GROUPS_FILTER_UNGROUPED": "Без группы", + "FAILOVER_GROUPS_FINDINGS": "Результаты проверки", + "FAILOVER_GROUPS_HEALTH_CLOSED": "В норме", + "FAILOVER_GROUPS_HEALTH_COUNTING": "Сбои учитываются", + "FAILOVER_GROUPS_HEALTH_HALF_OPEN": "Проверка", + "FAILOVER_GROUPS_HEALTH_OPEN": "На паузе", + "FAILOVER_GROUPS_INACTIVE": "Неактивна", + "FAILOVER_GROUPS_INACTIVE_GROUPS": "Неактивные ручные группы", + "FAILOVER_GROUPS_KEEP_EDITING": "Продолжить редактирование", + "FAILOVER_GROUPS_MOVE_DOWN": "Вниз", + "FAILOVER_GROUPS_MOVE_FIRST": "Поставить первым", + "FAILOVER_GROUPS_MOVE_TO": "Переместить в…", + "FAILOVER_GROUPS_MOVE_UP": "Вверх", + "FAILOVER_GROUPS_NEW_FROM_SELECTION": "Новая группа из выбранных", + "FAILOVER_GROUPS_NEW_GROUP": "Новая группа", + "FAILOVER_GROUPS_OPEN_GROUP": "Открыть группу", + "FAILOVER_GROUPS_ORIGIN_AUTO": "Автоматическая", + "FAILOVER_GROUPS_ORIGIN_EDITED": "Изменена", + "FAILOVER_GROUPS_ORIGIN_MANUAL": "Ручная", + "FAILOVER_GROUPS_ORPHANS": "Разделы неизвестных целей", + "FAILOVER_GROUPS_DISABLED_SECTIONS": "Разделы целей с отключёнными группами failover", + "FAILOVER_GROUPS_PLAYLIST_GROUP": "Группа плейлиста", + "FAILOVER_GROUPS_PROBLEMS": "Проблемы", + "FAILOVER_GROUPS_REFRESH_PREVIEW": "Обновить предпросмотр", + "FAILOVER_GROUPS_RELOAD_DISCARD": "Перезагрузить (отбросить мои изменения)", + "FAILOVER_GROUPS_REMOVE_FROM": "Удалить из {group}", + "FAILOVER_GROUPS_REMOVE_MEMBER": "Убрать из группы", + "FAILOVER_GROUPS_REMOVE_OVERRIDE": "Удалить правку", + "FAILOVER_GROUPS_REMOVE_REFERENCE": "Удалить ссылку", + "FAILOVER_GROUPS_RESET_AUTO": "Сбросить на автоматическую", + "FAILOVER_GROUPS_SAVE_AND_APPLY": "Сохранить и применить", + "FAILOVER_GROUPS_SEARCH_CHANNELS": "Поиск каналов…", + "FAILOVER_GROUPS_SEARCH_GROUPS": "Поиск групп…", + "FAILOVER_GROUPS_SELECTED": "Выбрано: {count}", + "FAILOVER_GROUPS_STALE": "Устаревшие правки", + "FAILOVER_GROUPS_STATUS_APPLIED": "применено", + "FAILOVER_GROUPS_STATUS_APPLY_PENDING": "сохранено, ожидает применения", + "FAILOVER_GROUPS_STATUS_FILE_ERROR": "ошибка файла", + "FAILOVER_GROUPS_STATUS_UNSAVED": "несохранённые изменения", + "FAILOVER_GROUPS_UNDO": "Отменить", + "FAILOVER_GROUPS_UNRESOLVED": "Неразрешённые каналы", + "FAILOVER_GROUPS_CURRENT_VALUES": "Текущие значения" }, "MESSAGES": { "CLIPBOARD_NOT_SUPPORTED": "Буфер обмена не поддерживается.\nВаш браузер или текущий контекст не позволяют доступ к буферу обмена.\nПожалуйста, используйте HTTPS или localhost.", @@ -2284,7 +2377,41 @@ }, "USER_BOUQUET": { "FAIL": "Не удалось загрузить букеты пользователя!" - } + }, + "FAILOVER_GROUPS_APPLY_BUSY": "Обновление плейлиста для этой цели уже в очереди.", + "FAILOVER_GROUPS_APPLY_HINT": "«Применить» запускает обновление плейлиста; кэш входа учитывается.", + "FAILOVER_GROUPS_APPLY_QUEUED": "Обновление плейлиста поставлено в очередь.", + "FAILOVER_GROUPS_CANDIDATES_FAILED": "Не удалось загрузить каналы.", + "FAILOVER_GROUPS_CHANGED_ELSEWHERE": "Правки failover этой цели были изменены в другом месте (файл или другая вкладка). Перезагрузить и отбросить ваши изменения или продолжить редактирование? Сохранение будет отклонено, пока вы не перезагрузите данные.", + "FAILOVER_GROUPS_CONFIRM_DELETE_GROUP": "Удалить эту ручную группу?", + "FAILOVER_GROUPS_CONFIRM_DELETE_SECTION": "Удалить из файла failover весь раздел этой цели?", + "FAILOVER_GROUPS_CONFIRM_DISCARD": "Отбросить все несохранённые изменения?", + "FAILOVER_GROUPS_CONFIRM_SWITCH": "Есть несохранённые изменения. При смене цели они будут потеряны.", + "FAILOVER_GROUPS_CONFLICT_HINT": "Эти каналы заявлены несколькими группами; учитывается только первое заявление.", + "FAILOVER_GROUPS_DISSOLVED_HINT": "Эта автоматическая группа расформирована; её каналы остаются без группы. Сбросьте правку, чтобы вернуть автоматическую группу.", + "FAILOVER_GROUPS_FILE_ERROR": "Файлы failover не читаются; редактирование отключено, пока файл не исправлен", + "FAILOVER_GROUPS_INACTIVE_HINT": "В этой группе меньше двух доступных каналов, поэтому она пока не используется.", + "FAILOVER_GROUPS_INVALID": "Правки некорректны; см. список проблем.", + "FAILOVER_GROUPS_LOCKED": "Файл failover недоступен для записи", + "FAILOVER_GROUPS_NEW_FILE": "Будет создан новый файл failover", + "FAILOVER_GROUPS_NO_CATALOG": "Каталога каналов пока нет. Сначала запустите обновление плейлиста для этой цели.", + "FAILOVER_GROUPS_NO_PROBLEMS": "Проблем не найдено.", + "FAILOVER_GROUPS_NO_TARGETS": "Ни у одной цели не включены группы failover. Включите их в настройках цели.", + "FAILOVER_GROUPS_TARGET_UNAVAILABLE": "Цель недоступна на странице групп failover. Сначала сохраните цель с включёнными группами failover.", + "FAILOVER_GROUPS_ORPHAN_HINT": "Эти разделы относятся к целям, которых больше нет. Назначьте раздел другой цели или удалите его.", + "FAILOVER_GROUPS_DISABLED_HINT": "Эти цели существуют, но для них отключены группы failover. Включите группы failover для цели, назначьте раздел другой цели или удалите его.", + "FAILOVER_GROUPS_TARGET_DISABLED": "у цели отключены группы failover", + "FAILOVER_GROUPS_DISPLAY_STALE": "Новый порядок активен. Название и логотип записи плейлиста обновятся при следующем обновлении плейлиста.", + "FAILOVER_GROUPS_RESOLVED_MEMBERS": "доступно: {count}", + "FAILOVER_GROUPS_SAVED_APPLIED": "Сохранено и применено.", + "FAILOVER_GROUPS_SAVED_PENDING": "Сохранено. Нажмите «Применить», чтобы использовать (обновление учитывает кэш входа).", + "FAILOVER_GROUPS_SECTION_UPDATED": "Раздел failover обновлён.", + "FAILOVER_GROUPS_SELECT_GROUP": "Выберите группу, чтобы изменить её каналы.", + "FAILOVER_GROUPS_SHARED_FILE_NOTICE": "Это сохранение перезапишет {file}, где есть и разделы других целей. Комментарии и форматирование файла не сохраняются. Это уведомление показывается один раз за сеанс.", + "FAILOVER_GROUPS_STALE_HINT": "Ключ канала этих правок больше не соответствует ни одной группе. Преобразуйте правку в ручную группу или удалите её.", + "FAILOVER_GROUPS_UNRESOLVED_HINT": "Эти ссылки не соответствуют ни одному каналу плейлиста. Удалите их или дождитесь возвращения канала.", + "FAILOVER_GROUPS_UPDATING": "Обновление предпросмотра…", + "FAILOVER_GROUPS_CHANGED_ELSEWHERE_BANNER": "Изменено в другом месте (файл или другая вкладка). Сохранение будет отклонено, пока вы не перезагрузите данные." }, "SETUP": { "DESC": { diff --git a/frontend/scss/_theme.scss b/frontend/scss/_theme.scss index 9b91bccfb..ff4d391c6 100644 --- a/frontend/scss/_theme.scss +++ b/frontend/scss/_theme.scss @@ -288,6 +288,14 @@ body { --theme-brand-emphasis: var(--theme-palette-emphasis-tone-500); --theme-brand-accent: var(--theme-palette-accent-tone-500); + --failover-group-origin-auto: var(--theme-palette-failover-group-origin-auto); + --failover-group-origin-edited: var(--theme-palette-failover-group-origin-edited); + --failover-group-origin-manual: var(--theme-palette-failover-group-origin-manual); + --failover-group-problem: var(--theme-palette-failover-group-problem); + --failover-group-health-ok: var(--theme-palette-failover-group-health-ok); + --failover-group-health-degraded: var(--theme-palette-failover-group-health-degraded); + --failover-group-health-open: var(--theme-palette-failover-group-health-open); + --theme-brand-log-console-background: var(--theme-palette-log-console-background); --theme-brand-log-console-text: var(--theme-palette-log-console-text); --theme-brand-log-console-muted: var(--theme-palette-log-console-muted); diff --git a/frontend/scss/app/_component.scss b/frontend/scss/app/_component.scss index 05a0f6762..4e500648b 100644 --- a/frontend/scss/app/_component.scss +++ b/frontend/scss/app/_component.scss @@ -80,7 +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/failover_groups_config"; @forward "components/config/api_config"; @forward "components/config/messaging_config"; @forward "components/config/panel_api_config"; @@ -108,3 +108,4 @@ @forward "components/country"; @forward "components/recording"; @forward "components/target_bouquet_view"; +@forward "components/failover_groups"; diff --git a/frontend/scss/app/components/_failover_groups.scss b/frontend/scss/app/components/_failover_groups.scss new file mode 100644 index 000000000..7eaa269b9 --- /dev/null +++ b/frontend/scss/app/components/_failover_groups.scss @@ -0,0 +1,468 @@ +@use '../../size'; + +.tp__failover-groups { + display: flex; + flex-flow: column; + gap: var(--gap-default); + width: 100%; + min-width: 0; + box-sizing: border-box; + padding: var(--padding-default); + color: var(--text-color); + + &__header { + display: flex; + flex-flow: row wrap; + align-items: center; + justify-content: space-between; + gap: var(--gap-default); + + h2 { + margin: 0; + font-size: var(--font-size-xl); + } + } + + &__target { + display: flex; + flex-flow: row nowrap; + align-items: center; + gap: var(--gap-default); + min-width: min(20rem, 100%); + } + + &__editor { + display: flex; + flex-flow: column; + gap: var(--gap-default); + min-width: 0; + } + + &__statusbar, + &__toolbar { + display: flex; + flex-flow: row wrap; + align-items: center; + gap: var(--gap-default); + } + + &__file { + font-family: monospace; + font-size: var(--font-size-sm); + overflow-wrap: anywhere; + } + + &__hint, + &__notice { + margin: 0; + color: var(--modest-text-color); + font-size: var(--font-size-sm); + } + + &__notice { + padding: var(--padding-small); + } + + &__banner { + margin: 0; + padding: var(--padding-small) var(--padding-default); + border: 1px solid var(--warn-color); + border-radius: var(--border-radius); + background-color: var(--warn-background-color); + font-size: var(--font-size-sm); + + &--error { + border-color: var(--error-color); + background-color: var(--error-background-color); + } + } + + &__body { + display: grid; + grid-template-columns: minmax(14rem, 18rem) minmax(0, 1fr); + gap: var(--gap-default); + align-items: start; + } + + &__list, + &__pane, + &__candidates { + min-width: 0; + padding: var(--padding-default); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + background-color: var(--card-background-color); + box-sizing: border-box; + } + + &__pane { + display: flex; + flex-flow: column; + gap: var(--gap-default); + } + + &__list { + display: flex; + flex-flow: column; + gap: var(--gap-default); + } + + &__list-toolbar { + display: flex; + flex-flow: column; + gap: var(--gap-default); + } + + &__list-items { + display: flex; + flex-flow: column; + gap: var(--gap-micro); + margin: 0; + padding: 0; + max-height: 60vh; + overflow-y: auto; + list-style: none; + } + + &__list-item { + display: flex; + flex-flow: row nowrap; + align-items: center; + gap: var(--gap-default); + width: 100%; + padding: var(--padding-mini) var(--padding-small); + border: 1px solid transparent; + border-radius: var(--border-radius-small); + background: transparent; + color: inherit; + font: inherit; + text-align: start; + cursor: pointer; + + &:hover, + &:focus-visible { + background-color: var(--menu-item-hover-background-color); + } + + &.selected { + border-color: var(--border-color); + background-color: var(--menu-item-active-background-color); + } + + &.inactive &-name { + opacity: 0.7; + } + + &-name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &-count { + color: var(--modest-text-color); + font-size: var(--font-size-sm); + } + } + + &__list-empty { + padding: var(--padding-small); + color: var(--modest-text-color); + list-style: none; + } + + &__problem { + display: inline-flex; + width: 1rem; + height: 1rem; + flex: 0 0 auto; + fill: var(--failover-group-problem); + color: var(--failover-group-problem); + } + + &__display-stale { + display: inline-flex; + width: 1rem; + height: 1rem; + flex: 0 0 auto; + fill: var(--warn-color); + color: var(--warn-color); + } + + &__problem-list { + display: flex; + flex-flow: column; + gap: var(--gap-default); + margin: 0; + padding: 0; + list-style: none; + + li { + display: flex; + flex-flow: row wrap; + align-items: center; + gap: var(--gap-default); + } + } + + &__problem-name { + flex: 1 1 12rem; + min-width: 0; + overflow-wrap: anywhere; + } + + &__diagnostics { + display: flex; + flex-flow: column; + gap: var(--gap-default); + + h3 { + margin: 0; + font-size: var(--font-size-md); + } + } + + &__detail { + display: flex; + flex-flow: column; + gap: var(--gap-default); + min-width: 0; + + &-header { + display: flex; + flex-flow: row wrap; + align-items: center; + gap: var(--gap-default); + + h3 { + flex: 1 1 auto; + margin: 0; + font-size: var(--font-size-lg); + overflow-wrap: anywhere; + } + } + + &-footer { + display: flex; + justify-content: flex-end; + } + } + + &__back { + display: none; + } + + &__meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(size.$form-field-min-width, 1fr)); + gap: var(--gap-default); + } + + &__summary { + display: grid; + font-size: var(--font-size-sm); + grid-template-columns: max-content 1fr; + gap: var(--gap-micro) var(--gap-default); + margin: 0; + + dt { + color: var(--modest-text-color); + } + + dd { + margin: 0; + } + } + + &__members { + min-width: 0; + + &--busy { + opacity: 0.6; + pointer-events: none; + } + } + + &__cell { + display: block; + min-height: 1.5rem; + } + + &__position { + display: inline-flex; + flex-flow: row nowrap; + align-items: center; + gap: var(--gap-micro); + } + + &__drag-handle { + display: inline-flex; + width: 1.25rem; + height: 1.25rem; + cursor: grab; + fill: var(--modest-text-color); + + &:active { + cursor: grabbing; + } + } + + &__health { + display: inline-flex; + flex-flow: row nowrap; + align-items: center; + gap: var(--gap-small); + white-space: nowrap; + + &-dot { + width: 0.6rem; + height: 0.6rem; + border-radius: 50%; + background-color: var(--failover-group-health-ok); + } + + &--degraded &-dot { + background-color: var(--failover-group-health-degraded); + } + + &--open &-dot { + background-color: var(--failover-group-health-open); + } + } + + &__unresolved { + display: flex; + flex-flow: row wrap; + align-items: center; + gap: var(--gap-default); + } + + &__candidates { + display: flex; + flex-flow: column; + gap: var(--gap-default); + + h3 { + margin: 0; + font-size: var(--font-size-md); + } + + &-toolbar { + display: flex; + flex-flow: row wrap; + align-items: center; + gap: var(--gap-default); + } + } + + &__selection-count { + color: var(--modest-text-color); + font-size: var(--font-size-sm); + } + + &__excluded-tag { + margin-inline-start: var(--gap-default); + color: var(--modest-text-color); + font-size: var(--font-size-xs); + } + + .tp__chip { + &.tp__failover-groups__origin--auto { + color: var(--failover-group-origin-auto); + border-color: var(--failover-group-origin-auto); + background-color: color-mix(in srgb, var(--failover-group-origin-auto) 15%, transparent); + } + + &.tp__failover-groups__origin--edited { + color: var(--failover-group-origin-edited); + border-color: var(--failover-group-origin-edited); + background-color: color-mix(in srgb, var(--failover-group-origin-edited) 15%, transparent); + } + + &.tp__failover-groups__origin--manual { + color: var(--failover-group-origin-manual); + border-color: var(--failover-group-origin-manual); + background-color: color-mix(in srgb, var(--failover-group-origin-manual) 15%, transparent); + } + + &.tp__failover-groups__status--pending { + color: var(--warn-color); + border-color: var(--warn-color); + background-color: var(--warn-background-color); + } + } + + @media (max-width: size.$mobile-breakpoint) { + &__body { + grid-template-columns: minmax(0, 1fr); + } + + // The detail opens over the list; the back button returns to it. + &__body--detail &__list { + display: none; + } + + &__body:not(&__body--detail) &__pane { + display: none; + } + + &__back { + display: inline-flex; + } + + &__list-items { + max-height: none; + } + } +} + +.tp__custom-dialog.tp__failover-groups__dialog { + display: flex; + flex-flow: column; + gap: var(--gap-default); + min-width: min(24rem, 92vw); + max-width: min(36rem, 96vw); + max-height: min(80vh, 40rem); + padding: var(--padding-default); + box-sizing: border-box; + + h2 { + margin: 0; + } +} + +.tp__failover-groups__dialog-body { + display: flex; + flex-flow: column; + gap: var(--gap-default); +} + +.tp__failover-groups__dialog-list { + display: flex; + flex-flow: column; + gap: var(--gap-micro); + min-height: 0; + margin: 0; + padding: 0; + overflow-y: auto; + list-style: none; +} + +.tp__failover-groups__dialog-item { + width: 100%; + padding: var(--padding-mini) var(--padding-small); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-small); + background: transparent; + color: inherit; + font: inherit; + text-align: start; + cursor: pointer; + + &:hover, + &:focus-visible { + background-color: var(--menu-item-hover-background-color); + } +} diff --git a/frontend/scss/app/components/config/_channel_groups_config.scss b/frontend/scss/app/components/config/_failover_groups_config.scss similarity index 74% rename from frontend/scss/app/components/config/_channel_groups_config.scss rename to frontend/scss/app/components/config/_failover_groups_config.scss index 526749ba3..faf0111dd 100644 --- a/frontend/scss/app/components/config/_channel_groups_config.scss +++ b/frontend/scss/app/components/config/_failover_groups_config.scss @@ -1,4 +1,4 @@ -.tp__channel-groups-config-view { +.tp__failover-groups-config-view { &__info { max-width: 60rem; color: var(--modest-text-color); diff --git a/frontend/scss/themes/_theme-aurora.scss b/frontend/scss/themes/_theme-aurora.scss index 0e189e186..31a97de30 100644 --- a/frontend/scss/themes/_theme-aurora.scss +++ b/frontend/scss/themes/_theme-aurora.scss @@ -284,4 +284,12 @@ body[data-theme="aurora"] { rgba(6, 21, 27, 0.4) 72%, rgba(6, 21, 27, 0) 86% ); + + --theme-palette-failover-group-origin-auto: var(--theme-palette-info-tone-500); + --theme-palette-failover-group-origin-edited: var(--theme-palette-warning-tone-500); + --theme-palette-failover-group-origin-manual: var(--theme-palette-accent-tone-500); + --theme-palette-failover-group-problem: var(--theme-palette-danger-tone-500); + --theme-palette-failover-group-health-ok: var(--theme-palette-success-tone-520); + --theme-palette-failover-group-health-degraded: var(--theme-palette-warning-tone-520); + --theme-palette-failover-group-health-open: var(--theme-palette-danger-tone-520); } diff --git a/frontend/scss/themes/_theme-banana-yellow.scss b/frontend/scss/themes/_theme-banana-yellow.scss index efa00f640..e0006bfea 100644 --- a/frontend/scss/themes/_theme-banana-yellow.scss +++ b/frontend/scss/themes/_theme-banana-yellow.scss @@ -294,4 +294,12 @@ body[data-theme="banana-yellow"] { rgba(245, 239, 229, 0.4) 72%, rgba(245, 239, 229, 0) 86% ); + + --theme-palette-failover-group-origin-auto: var(--theme-palette-info-tone-500); + --theme-palette-failover-group-origin-edited: var(--theme-palette-warning-tone-500); + --theme-palette-failover-group-origin-manual: var(--theme-palette-accent-tone-500); + --theme-palette-failover-group-problem: var(--theme-palette-danger-tone-500); + --theme-palette-failover-group-health-ok: var(--theme-palette-success-tone-520); + --theme-palette-failover-group-health-degraded: var(--theme-palette-warning-tone-520); + --theme-palette-failover-group-health-open: var(--theme-palette-danger-tone-520); } diff --git a/frontend/scss/themes/_theme-bright.scss b/frontend/scss/themes/_theme-bright.scss index 18a3bc2f3..086c0ef81 100644 --- a/frontend/scss/themes/_theme-bright.scss +++ b/frontend/scss/themes/_theme-bright.scss @@ -294,4 +294,12 @@ body[data-theme="bright"] { rgba(255, 255, 255, 0.38) 72%, rgba(255, 255, 255, 0) 86% ); + + --theme-palette-failover-group-origin-auto: var(--theme-palette-info-tone-500); + --theme-palette-failover-group-origin-edited: var(--theme-palette-warning-tone-500); + --theme-palette-failover-group-origin-manual: var(--theme-palette-accent-tone-500); + --theme-palette-failover-group-problem: var(--theme-palette-danger-tone-500); + --theme-palette-failover-group-health-ok: var(--theme-palette-success-tone-520); + --theme-palette-failover-group-health-degraded: var(--theme-palette-warning-tone-520); + --theme-palette-failover-group-health-open: var(--theme-palette-danger-tone-520); } diff --git a/frontend/scss/themes/_theme-clubroom-contrast.scss b/frontend/scss/themes/_theme-clubroom-contrast.scss index 366f3f845..cf0ace7d3 100644 --- a/frontend/scss/themes/_theme-clubroom-contrast.scss +++ b/frontend/scss/themes/_theme-clubroom-contrast.scss @@ -284,4 +284,12 @@ body[data-theme="clubroom-contrast"] { rgba(25, 24, 26, 0.42) 72%, rgba(25, 24, 26, 0) 86% ); + + --theme-palette-failover-group-origin-auto: var(--theme-palette-info-tone-500); + --theme-palette-failover-group-origin-edited: var(--theme-palette-warning-tone-500); + --theme-palette-failover-group-origin-manual: var(--theme-palette-accent-tone-500); + --theme-palette-failover-group-problem: var(--theme-palette-danger-tone-500); + --theme-palette-failover-group-health-ok: var(--theme-palette-success-tone-520); + --theme-palette-failover-group-health-degraded: var(--theme-palette-warning-tone-520); + --theme-palette-failover-group-health-open: var(--theme-palette-danger-tone-520); } diff --git a/frontend/scss/themes/_theme-cool-elegance.scss b/frontend/scss/themes/_theme-cool-elegance.scss index 087d299d8..88554954d 100644 --- a/frontend/scss/themes/_theme-cool-elegance.scss +++ b/frontend/scss/themes/_theme-cool-elegance.scss @@ -294,4 +294,12 @@ body[data-theme="cool-elegance"] { rgba(245, 239, 229, 0.4) 72%, rgba(245, 239, 229, 0) 86% ); + + --theme-palette-failover-group-origin-auto: var(--theme-palette-info-tone-500); + --theme-palette-failover-group-origin-edited: var(--theme-palette-warning-tone-500); + --theme-palette-failover-group-origin-manual: var(--theme-palette-accent-tone-500); + --theme-palette-failover-group-problem: var(--theme-palette-danger-tone-500); + --theme-palette-failover-group-health-ok: var(--theme-palette-success-tone-520); + --theme-palette-failover-group-health-degraded: var(--theme-palette-warning-tone-520); + --theme-palette-failover-group-health-open: var(--theme-palette-danger-tone-520); } diff --git a/frontend/scss/themes/_theme-cvd-friendly-bright.scss b/frontend/scss/themes/_theme-cvd-friendly-bright.scss index b7455b56d..464ca6464 100644 --- a/frontend/scss/themes/_theme-cvd-friendly-bright.scss +++ b/frontend/scss/themes/_theme-cvd-friendly-bright.scss @@ -294,4 +294,12 @@ body[data-theme="cvd-friendly-bright"] { rgba(255, 255, 255, 0.38) 72%, rgba(255, 255, 255, 0) 86% ); + + --theme-palette-failover-group-origin-auto: var(--theme-palette-info-tone-500); + --theme-palette-failover-group-origin-edited: var(--theme-palette-warning-tone-500); + --theme-palette-failover-group-origin-manual: var(--theme-palette-accent-tone-500); + --theme-palette-failover-group-problem: var(--theme-palette-danger-tone-500); + --theme-palette-failover-group-health-ok: var(--theme-palette-success-tone-520); + --theme-palette-failover-group-health-degraded: var(--theme-palette-warning-tone-520); + --theme-palette-failover-group-health-open: var(--theme-palette-danger-tone-520); } diff --git a/frontend/scss/themes/_theme-cvd-friendly-dark.scss b/frontend/scss/themes/_theme-cvd-friendly-dark.scss index 9108cfc2c..eb2971661 100644 --- a/frontend/scss/themes/_theme-cvd-friendly-dark.scss +++ b/frontend/scss/themes/_theme-cvd-friendly-dark.scss @@ -284,4 +284,12 @@ body[data-theme="cvd-friendly-dark"] { rgba(0, 0, 0, 0.36) 70%, rgba(0, 0, 0, 0) 85% ); + + --theme-palette-failover-group-origin-auto: var(--theme-palette-info-tone-500); + --theme-palette-failover-group-origin-edited: var(--theme-palette-warning-tone-500); + --theme-palette-failover-group-origin-manual: var(--theme-palette-accent-tone-500); + --theme-palette-failover-group-problem: var(--theme-palette-danger-tone-500); + --theme-palette-failover-group-health-ok: var(--theme-palette-success-tone-520); + --theme-palette-failover-group-health-degraded: var(--theme-palette-warning-tone-520); + --theme-palette-failover-group-health-open: var(--theme-palette-danger-tone-520); } diff --git a/frontend/scss/themes/_theme-dark.scss b/frontend/scss/themes/_theme-dark.scss index 47541c066..ceab056fc 100644 --- a/frontend/scss/themes/_theme-dark.scss +++ b/frontend/scss/themes/_theme-dark.scss @@ -284,4 +284,12 @@ body[data-theme="dark"] { rgba(0, 0, 0, 0.35) 70%, rgba(0, 0, 0, 0) 85% ); + + --theme-palette-failover-group-origin-auto: var(--theme-palette-info-tone-500); + --theme-palette-failover-group-origin-edited: var(--theme-palette-warning-tone-500); + --theme-palette-failover-group-origin-manual: var(--theme-palette-accent-tone-500); + --theme-palette-failover-group-problem: var(--theme-palette-danger-tone-500); + --theme-palette-failover-group-health-ok: var(--theme-palette-success-tone-520); + --theme-palette-failover-group-health-degraded: var(--theme-palette-warning-tone-520); + --theme-palette-failover-group-health-open: var(--theme-palette-danger-tone-520); } diff --git a/frontend/scss/themes/_theme-dopamine.scss b/frontend/scss/themes/_theme-dopamine.scss index bf7912ed8..63fbb0e23 100644 --- a/frontend/scss/themes/_theme-dopamine.scss +++ b/frontend/scss/themes/_theme-dopamine.scss @@ -284,4 +284,12 @@ body[data-theme="dopamine"] { rgba(25, 24, 26, 0.42) 72%, rgba(25, 24, 26, 0) 86% ); + + --theme-palette-failover-group-origin-auto: var(--theme-palette-info-tone-500); + --theme-palette-failover-group-origin-edited: var(--theme-palette-warning-tone-500); + --theme-palette-failover-group-origin-manual: var(--theme-palette-accent-tone-500); + --theme-palette-failover-group-problem: var(--theme-palette-danger-tone-500); + --theme-palette-failover-group-health-ok: var(--theme-palette-success-tone-520); + --theme-palette-failover-group-health-degraded: var(--theme-palette-warning-tone-520); + --theme-palette-failover-group-health-open: var(--theme-palette-danger-tone-520); } diff --git a/frontend/scss/themes/_theme-dracula.scss b/frontend/scss/themes/_theme-dracula.scss index 1a807d3ee..8ab3fd2d2 100644 --- a/frontend/scss/themes/_theme-dracula.scss +++ b/frontend/scss/themes/_theme-dracula.scss @@ -284,4 +284,12 @@ body[data-theme="dracula"] { rgba(25, 24, 26, 0.42) 72%, rgba(25, 24, 26, 0) 86% ); + + --theme-palette-failover-group-origin-auto: var(--theme-palette-info-tone-500); + --theme-palette-failover-group-origin-edited: var(--theme-palette-warning-tone-500); + --theme-palette-failover-group-origin-manual: var(--theme-palette-accent-tone-500); + --theme-palette-failover-group-problem: var(--theme-palette-danger-tone-500); + --theme-palette-failover-group-health-ok: var(--theme-palette-success-tone-520); + --theme-palette-failover-group-health-degraded: var(--theme-palette-warning-tone-520); + --theme-palette-failover-group-health-open: var(--theme-palette-danger-tone-520); } diff --git a/frontend/scss/themes/_theme-graperoot-dark.scss b/frontend/scss/themes/_theme-graperoot-dark.scss index c6a07f3e6..38a2a0509 100644 --- a/frontend/scss/themes/_theme-graperoot-dark.scss +++ b/frontend/scss/themes/_theme-graperoot-dark.scss @@ -284,4 +284,12 @@ body[data-theme="graperoot-dark"] { rgba(0, 0, 0, 0.35) 70%, rgba(0, 0, 0, 0) 85% ); + + --theme-palette-failover-group-origin-auto: var(--theme-palette-info-tone-500); + --theme-palette-failover-group-origin-edited: var(--theme-palette-warning-tone-500); + --theme-palette-failover-group-origin-manual: var(--theme-palette-accent-tone-500); + --theme-palette-failover-group-problem: var(--theme-palette-danger-tone-500); + --theme-palette-failover-group-health-ok: var(--theme-palette-success-tone-520); + --theme-palette-failover-group-health-degraded: var(--theme-palette-warning-tone-520); + --theme-palette-failover-group-health-open: var(--theme-palette-danger-tone-520); } diff --git a/frontend/scss/themes/_theme-mermaidcore.scss b/frontend/scss/themes/_theme-mermaidcore.scss index 9ef6ae338..c3ce6c846 100644 --- a/frontend/scss/themes/_theme-mermaidcore.scss +++ b/frontend/scss/themes/_theme-mermaidcore.scss @@ -284,4 +284,12 @@ body[data-theme="mermaidcore"] { rgba(6, 21, 27, 0.4) 72%, rgba(6, 21, 27, 0) 86% ); + + --theme-palette-failover-group-origin-auto: var(--theme-palette-info-tone-500); + --theme-palette-failover-group-origin-edited: var(--theme-palette-warning-tone-500); + --theme-palette-failover-group-origin-manual: var(--theme-palette-accent-tone-500); + --theme-palette-failover-group-problem: var(--theme-palette-danger-tone-500); + --theme-palette-failover-group-health-ok: var(--theme-palette-success-tone-520); + --theme-palette-failover-group-health-degraded: var(--theme-palette-warning-tone-520); + --theme-palette-failover-group-health-open: var(--theme-palette-danger-tone-520); } diff --git a/frontend/scss/themes/_theme-monokai.scss b/frontend/scss/themes/_theme-monokai.scss index 87d650b3f..6c9f0575a 100644 --- a/frontend/scss/themes/_theme-monokai.scss +++ b/frontend/scss/themes/_theme-monokai.scss @@ -284,4 +284,12 @@ body[data-theme="monokai"] { rgba(25, 24, 26, 0.42) 72%, rgba(25, 24, 26, 0) 86% ); + + --theme-palette-failover-group-origin-auto: var(--theme-palette-info-tone-500); + --theme-palette-failover-group-origin-edited: var(--theme-palette-warning-tone-500); + --theme-palette-failover-group-origin-manual: var(--theme-palette-accent-tone-500); + --theme-palette-failover-group-problem: var(--theme-palette-danger-tone-500); + --theme-palette-failover-group-health-ok: var(--theme-palette-success-tone-520); + --theme-palette-failover-group-health-degraded: var(--theme-palette-warning-tone-520); + --theme-palette-failover-group-health-open: var(--theme-palette-danger-tone-520); } diff --git a/frontend/scss/themes/_theme-nature-pure.scss b/frontend/scss/themes/_theme-nature-pure.scss index d8e3a2ada..a778ae9fc 100644 --- a/frontend/scss/themes/_theme-nature-pure.scss +++ b/frontend/scss/themes/_theme-nature-pure.scss @@ -294,4 +294,12 @@ body[data-theme="nature-pure"] { rgba(245, 239, 229, 0.4) 72%, rgba(245, 239, 229, 0) 86% ); + + --theme-palette-failover-group-origin-auto: var(--theme-palette-info-tone-500); + --theme-palette-failover-group-origin-edited: var(--theme-palette-warning-tone-500); + --theme-palette-failover-group-origin-manual: var(--theme-palette-accent-tone-500); + --theme-palette-failover-group-problem: var(--theme-palette-danger-tone-500); + --theme-palette-failover-group-health-ok: var(--theme-palette-success-tone-520); + --theme-palette-failover-group-health-degraded: var(--theme-palette-warning-tone-520); + --theme-palette-failover-group-health-open: var(--theme-palette-danger-tone-520); } diff --git a/frontend/scss/themes/_theme-nord.scss b/frontend/scss/themes/_theme-nord.scss index ef9d9ae1c..972256ebe 100644 --- a/frontend/scss/themes/_theme-nord.scss +++ b/frontend/scss/themes/_theme-nord.scss @@ -285,4 +285,12 @@ body[data-theme="nord"] { rgba(0, 0, 0, 0.35) 70%, rgba(0, 0, 0, 0) 85% ); + + --theme-palette-failover-group-origin-auto: var(--theme-palette-info-tone-500); + --theme-palette-failover-group-origin-edited: var(--theme-palette-warning-tone-500); + --theme-palette-failover-group-origin-manual: var(--theme-palette-accent-tone-500); + --theme-palette-failover-group-problem: var(--theme-palette-danger-tone-500); + --theme-palette-failover-group-health-ok: var(--theme-palette-success-tone-520); + --theme-palette-failover-group-health-degraded: var(--theme-palette-warning-tone-520); + --theme-palette-failover-group-health-open: var(--theme-palette-danger-tone-520); } diff --git a/frontend/scss/themes/_theme-paper.scss b/frontend/scss/themes/_theme-paper.scss index dd621c423..bcf80f1e2 100644 --- a/frontend/scss/themes/_theme-paper.scss +++ b/frontend/scss/themes/_theme-paper.scss @@ -293,4 +293,12 @@ body[data-theme="paper"] { rgba(245, 239, 229, 0.4) 72%, rgba(245, 239, 229, 0) 86% ); + + --theme-palette-failover-group-origin-auto: var(--theme-palette-info-tone-500); + --theme-palette-failover-group-origin-edited: var(--theme-palette-warning-tone-500); + --theme-palette-failover-group-origin-manual: var(--theme-palette-accent-tone-500); + --theme-palette-failover-group-problem: var(--theme-palette-danger-tone-500); + --theme-palette-failover-group-health-ok: var(--theme-palette-success-tone-520); + --theme-palette-failover-group-health-degraded: var(--theme-palette-warning-tone-520); + --theme-palette-failover-group-health-open: var(--theme-palette-danger-tone-520); } diff --git a/frontend/scss/themes/_theme-refined-dark.scss b/frontend/scss/themes/_theme-refined-dark.scss index 0bd299dd4..63ab97c4e 100644 --- a/frontend/scss/themes/_theme-refined-dark.scss +++ b/frontend/scss/themes/_theme-refined-dark.scss @@ -284,4 +284,12 @@ body[data-theme="refined-dark"] { rgba(0, 0, 0, 0.35) 70%, rgba(0, 0, 0, 0) 85% ); + + --theme-palette-failover-group-origin-auto: var(--theme-palette-info-tone-500); + --theme-palette-failover-group-origin-edited: var(--theme-palette-warning-tone-500); + --theme-palette-failover-group-origin-manual: var(--theme-palette-accent-tone-500); + --theme-palette-failover-group-problem: var(--theme-palette-danger-tone-500); + --theme-palette-failover-group-health-ok: var(--theme-palette-success-tone-520); + --theme-palette-failover-group-health-degraded: var(--theme-palette-warning-tone-520); + --theme-palette-failover-group-health-open: var(--theme-palette-danger-tone-520); } diff --git a/frontend/scss/themes/_theme-sun-washed-soft.scss b/frontend/scss/themes/_theme-sun-washed-soft.scss index 192e1a31b..f3bd55e91 100644 --- a/frontend/scss/themes/_theme-sun-washed-soft.scss +++ b/frontend/scss/themes/_theme-sun-washed-soft.scss @@ -293,4 +293,12 @@ body[data-theme="sun-washed-soft"] { rgba(245, 239, 229, 0.4) 72%, rgba(245, 239, 229, 0) 86% ); + + --theme-palette-failover-group-origin-auto: var(--theme-palette-info-tone-500); + --theme-palette-failover-group-origin-edited: var(--theme-palette-warning-tone-500); + --theme-palette-failover-group-origin-manual: var(--theme-palette-accent-tone-500); + --theme-palette-failover-group-problem: var(--theme-palette-danger-tone-500); + --theme-palette-failover-group-health-ok: var(--theme-palette-success-tone-520); + --theme-palette-failover-group-health-degraded: var(--theme-palette-warning-tone-520); + --theme-palette-failover-group-health-open: var(--theme-palette-danger-tone-520); } diff --git a/frontend/scss/themes/_theme-vibrant-elegance.scss b/frontend/scss/themes/_theme-vibrant-elegance.scss index b0669334f..e9bb0f6bd 100644 --- a/frontend/scss/themes/_theme-vibrant-elegance.scss +++ b/frontend/scss/themes/_theme-vibrant-elegance.scss @@ -283,4 +283,12 @@ body[data-theme="vibrant-elegance"] { rgba(6, 21, 27, 0.4) 72%, rgba(6, 21, 27, 0) 86% ); + + --theme-palette-failover-group-origin-auto: var(--theme-palette-info-tone-500); + --theme-palette-failover-group-origin-edited: var(--theme-palette-warning-tone-500); + --theme-palette-failover-group-origin-manual: var(--theme-palette-accent-tone-500); + --theme-palette-failover-group-problem: var(--theme-palette-danger-tone-500); + --theme-palette-failover-group-health-ok: var(--theme-palette-success-tone-520); + --theme-palette-failover-group-health-degraded: var(--theme-palette-warning-tone-520); + --theme-palette-failover-group-health-open: var(--theme-palette-danger-tone-520); } diff --git a/frontend/scss/themes/_theme-vintage-neutral.scss b/frontend/scss/themes/_theme-vintage-neutral.scss index cb41079b0..db4163bb7 100644 --- a/frontend/scss/themes/_theme-vintage-neutral.scss +++ b/frontend/scss/themes/_theme-vintage-neutral.scss @@ -293,4 +293,12 @@ body[data-theme="vintage-neutral"] { rgba(245, 239, 229, 0.4) 72%, rgba(245, 239, 229, 0) 86% ); + + --theme-palette-failover-group-origin-auto: var(--theme-palette-info-tone-500); + --theme-palette-failover-group-origin-edited: var(--theme-palette-warning-tone-500); + --theme-palette-failover-group-origin-manual: var(--theme-palette-accent-tone-500); + --theme-palette-failover-group-problem: var(--theme-palette-danger-tone-500); + --theme-palette-failover-group-health-ok: var(--theme-palette-success-tone-520); + --theme-palette-failover-group-health-degraded: var(--theme-palette-warning-tone-520); + --theme-palette-failover-group-health-open: var(--theme-palette-danger-tone-520); } diff --git a/frontend/src/app/components/config/config_page.rs b/frontend/src/app/components/config/config_page.rs index e0ab29270..5a033ae2c 100644 --- a/frontend/src/app/components/config/config_page.rs +++ b/frontend/src/app/components/config/config_page.rs @@ -1,7 +1,7 @@ use shared::{ error::TuliproxError, model::{ - ApiProxyConfigDto, ChannelGroupsConfig, ConfigApiDto, HdHomeRunConfigDto, IpCheckConfigDto, LibraryConfigDto, + ApiProxyConfigDto, ConfigApiDto, FailoverGroupsConfig, HdHomeRunConfigDto, IpCheckConfigDto, LibraryConfigDto, LogConfigDto, MainConfigDto, MessagingConfigDto, MetadataUpdateConfigDto, ProxyConfigDto, ReverseProxyConfigDto, SchedulesConfigDto, SourcesConfigDto, VideoConfigDto, WebUiConfigDto, }, @@ -22,7 +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"; +pub const LABEL_FAILOVER_GROUPS_CONFIG: &str = "LABEL.FAILOVER_GROUPS_CONFIG"; const MAIN_PAGE: &str = "main"; const API_PAGE: &str = "api"; @@ -38,7 +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"; +const FAILOVER_GROUPS_PAGE: &str = "failover_groups"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)] pub enum ConfigPage { @@ -56,7 +56,7 @@ pub enum ConfigPage { IpCheck, Panel, Library, - ChannelGroups, + FailoverGroups, } impl FromStr for ConfigPage { @@ -78,7 +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), + FAILOVER_GROUPS_PAGE => Ok(ConfigPage::FailoverGroups), _ => Err(TuliproxError::Config(format!("Unknown config page: {s}"))), } } @@ -101,7 +101,7 @@ impl fmt::Display for ConfigPage { ConfigPage::IpCheck => IPCHECK_PAGE, ConfigPage::Panel => PANEL_PAGE, ConfigPage::Library => LIBRARY_PAGE, - ConfigPage::ChannelGroups => CHANNEL_GROUPS_PAGE, + ConfigPage::FailoverGroups => FAILOVER_GROUPS_PAGE, }; write!(f, "{s}") } @@ -129,7 +129,7 @@ pub enum ConfigForm { IpCheck(bool, IpCheckConfigDto), Panel(bool, SourcesConfigDto), Library(bool, LibraryConfigDto), - ChannelGroups(bool, ChannelGroupsConfig), + FailoverGroups(bool, FailoverGroupsConfig), } impl ConfigForm { @@ -151,7 +151,7 @@ impl ConfigForm { | ConfigForm::IpCheck(true, _) | ConfigForm::Panel(true, _) | ConfigForm::Library(true, _) - | ConfigForm::ChannelGroups(true, _) + | ConfigForm::FailoverGroups(true, _) ) } } @@ -173,7 +173,7 @@ pub struct ConfigFormSlots { pub ipcheck: Option, pub panel: Option, pub library: Option, - pub channel_groups: Option, + pub failover_groups: Option, } impl ConfigFormSlots { @@ -196,7 +196,7 @@ 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), + ConfigForm::FailoverGroups(_, _) => Self::set_form_slot(&mut self.failover_groups, form), } } @@ -217,7 +217,7 @@ impl ConfigFormSlots { &self.ipcheck, &self.panel, &self.library, - &self.channel_groups, + &self.failover_groups, ] } diff --git a/frontend/src/app/components/config/config_update.rs b/frontend/src/app/components/config/config_update.rs index b28911169..f0219b707 100644 --- a/frontend/src/app/components/config/config_update.rs +++ b/frontend/src/app/components/config/config_update.rs @@ -146,8 +146,8 @@ 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::FailoverGroups(_, failover_groups_cfg) => { + config.failover_groups = (!failover_groups_cfg.is_default()).then_some(failover_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 9fe0db9ff..f798530f5 100644 --- a/frontend/src/app/components/config/config_view.rs +++ b/frontend/src/app/components/config/config_view.rs @@ -3,7 +3,7 @@ use crate::{ components::{ config::{ config_page::{ - ConfigForm, ConfigFormSlots, ConfigPage, LABEL_API_CONFIG, LABEL_CHANNEL_GROUPS_CONFIG, + ConfigForm, ConfigFormSlots, ConfigPage, LABEL_API_CONFIG, LABEL_FAILOVER_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, @@ -11,7 +11,7 @@ use crate::{ }, config_update::apply_and_prepare_config, config_view_context::ConfigViewContext, - ApiConfigView, ChannelGroupsConfigView, HdHomerunConfigView, IpCheckConfigView, LibraryConfigView, + ApiConfigView, FailoverGroupsConfigView, HdHomerunConfigView, IpCheckConfigView, LibraryConfigView, LogConfigView, MainConfigView, MessagingConfigView, MetadataUpdateConfigView, PanelConfigView, ProxyConfigView, RecordingConfigView, ReverseProxyConfigView, SchedulesConfigView, WebUiConfigView, }, @@ -20,7 +20,7 @@ use crate::{ }, ConfigContext, }, - hooks::{use_key_down, use_service_context}, + hooks::{use_key_down, use_service_context, use_unsaved_changes_guard}, html_if, i18n::use_translation, services::{get_base_href, SetupCompleteRequestDto, SetupWebUserCredentialDto}, @@ -29,8 +29,6 @@ use crate::{ use log::warn; use shared::model::{permission::Permission, ApiProxyConfigDto, AppConfigDto, ConfigDto, SourcesConfigDto}; use std::str::FromStr; -use wasm_bindgen::{closure::Closure, JsCast}; -use web_sys::BeforeUnloadEvent; use yew::{platform::spawn_local, prelude::*}; const LABEL_CONFIG: &str = "LABEL.CONFIG"; @@ -63,7 +61,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, + ConfigForm::FailoverGroups(_, _) => ConfigPage::FailoverGroups, } } @@ -137,7 +135,12 @@ pub fn ConfigView() -> Html { "Metadata", ), (ConfigPage::Library, LABEL_LIBRARY_CONFIG, html! { }, "VideoLibrary"), - (ConfigPage::ChannelGroups, LABEL_CHANNEL_GROUPS_CONFIG, html! { }, "Nodes"), + ( + ConfigPage::FailoverGroups, + LABEL_FAILOVER_GROUPS_CONFIG, + html! { }, + "Nodes", + ), ]; let editing = *editing; @@ -452,27 +455,7 @@ pub fn ConfigView() -> Html { || !setup_password_repeat.is_empty()); let has_unsaved_changes = (*edit_mode || setup_mode) && (!form_state.slots.collect_modified_forms().is_empty() || setup_credentials_dirty); - { - use_effect_with(has_unsaved_changes, move |&dirty| { - let closure = Closure::::wrap(Box::new(move |event: BeforeUnloadEvent| { - event.prevent_default(); - event.set_return_value(""); - })); - if dirty { - if let Some(win) = web_sys::window() { - let _ = win.add_event_listener_with_callback("beforeunload", closure.as_ref().unchecked_ref()); - } - } - move || { - if dirty { - if let Some(win) = web_sys::window() { - let _ = - win.remove_event_listener_with_callback("beforeunload", closure.as_ref().unchecked_ref()); - } - } - } - }); - } + use_unsaved_changes_guard(has_unsaved_changes); // Save shortcut (Ctrl/Cmd+S) while editing { diff --git a/frontend/src/app/components/config/channel_groups_config_view.rs b/frontend/src/app/components/config/failover_groups_config_view.rs similarity index 68% rename from frontend/src/app/components/config/channel_groups_config_view.rs rename to frontend/src/app/components/config/failover_groups_config_view.rs index 53e0b50df..4dcb590f2 100644 --- a/frontend/src/app/components/config/channel_groups_config_view.rs +++ b/frontend/src/app/components/config/failover_groups_config_view.rs @@ -2,7 +2,7 @@ use crate::{ app::{ components::{ config::{ - config_page::{ConfigForm, LABEL_CHANNEL_GROUPS_CONFIG}, + config_page::{ConfigForm, LABEL_FAILOVER_GROUPS_CONFIG}, config_view_context::ConfigViewContext, use_emit_config_form, }, @@ -19,31 +19,31 @@ use crate::{ use_default_form_reducer, }; use shared::model::{ - ChannelGroupPolicy, ChannelGroupsConfig, ChannelMatchBy, ChannelMatchNormalize, EpgConflictMode, + ChannelMatchBy, ChannelMatchNormalize, EpgConflictMode, FailoverGroupPolicy, FailoverGroupsConfig, 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"; +const LABEL_GROUPING: &str = "LABEL.FAILOVER_GROUPS_GROUPING"; +const LABEL_START: &str = "LABEL.FAILOVER_GROUPS_START"; +const LABEL_FAILOVER: &str = "LABEL.FAILOVER_GROUPS_RUNTIME"; +const LABEL_CIRCUIT: &str = "LABEL.FAILOVER_GROUPS_CIRCUIT"; +const INFO_CONFIG: &str = "INFO.FAILOVER_GROUPS_CONFIG"; generate_form_reducer!( - state: ChannelGroupsConfigFormState { form: ChannelGroupsConfig }, - action_name: ChannelGroupsConfigFormAction, + state: FailoverGroupsConfigFormState { form: FailoverGroupsConfig }, + action_name: FailoverGroupsConfigFormAction, fields { MatchNormalize => match_normalize: ChannelMatchNormalize, } ); -/// One edit of the global channel group settings. -type Edit = fn(&mut ChannelGroupsConfig, T); +/// One edit of the global failover group settings. +type Edit = fn(&mut FailoverGroupsConfig, T); /// Applies an edit to the form and marks it modified. -type Apply = dyn Fn(&dyn Fn(&mut ChannelGroupsConfig)); +type Apply = dyn Fn(&dyn Fn(&mut FailoverGroupsConfig)); /// An empty value box, so a field without entries looks like the other fields in view mode. fn empty_value() -> Html { @@ -69,22 +69,22 @@ where } #[component] -pub fn ChannelGroupsConfigView() -> Html { +pub fn FailoverGroupsConfigView() -> 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() }); + let form_state: UseReducerHandle = + use_default_form_reducer!(FailoverGroupsConfigFormState { form: FailoverGroupsConfig::default() }); - use_emit_config_form(&form_state, config_view_ctx.on_form_change.clone(), ConfigForm::ChannelGroups); + use_emit_config_form(&form_state, config_view_ctx.on_form_change.clone(), ConfigForm::FailoverGroups); { 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 failover_groups = config_ctx.config.as_ref().and_then(|c| c.config.failover_groups.clone()); + use_effect_with((failover_groups, edit_mode), move |(failover_groups, _mode)| { + form_state.dispatch(FailoverGroupsConfigFormAction::SetAll(failover_groups.clone().unwrap_or_default())); || () }); } @@ -95,7 +95,7 @@ pub fn ChannelGroupsConfigView() -> Html { Rc::new(move |edit| { let mut config = form_state.form.clone(); edit(&mut config); - form_state.dispatch(ChannelGroupsConfigFormAction::SetAllEdited(config)); + form_state.dispatch(FailoverGroupsConfigFormAction::SetAllEdited(config)); }) }; @@ -189,30 +189,30 @@ pub fn ChannelGroupsConfigView() -> Html { on_change={Callback::from(move |value: String| { let mut normalize = form_state.form.match_normalize.clone(); normalize.strip_regex = (!value.trim().is_empty()).then_some(value); - form_state.dispatch(ChannelGroupsConfigFormAction::MatchNormalize(normalize)); + form_state.dispatch(FailoverGroupsConfigFormAction::MatchNormalize(normalize)); })} /> } } else { html! { { normalize.strip_regex.clone().unwrap_or_default() } } }; - field("LABEL.CHANNEL_GROUPS_STRIP_REGEX", "CHANNEL_MATCH_NORMALIZE.STRIP_REGEX", body) + field("LABEL.FAILOVER_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", + "LABEL.FAILOVER_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)); + form_state.dispatch(FailoverGroupsConfigFormAction::MatchNormalize(normalize)); })} /> } }, @@ -223,19 +223,19 @@ pub fn ChannelGroupsConfigView() -> Html { let entries: HashMap = normalize.aliases.iter().map(|(key, value)| (key.clone(), value.clone())).collect(); field( - "LABEL.CHANNEL_GROUPS_ALIASES", + "LABEL.FAILOVER_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)); + form_state.dispatch(FailoverGroupsConfigFormAction::MatchNormalize(normalize)); })} /> } }, @@ -247,74 +247,74 @@ pub fn ChannelGroupsConfigView() -> Html { let circuit = config.circuit; html! { -
-
{ translate.t(LABEL_CHANNEL_GROUPS_CONFIG) }
-
{ translate.t(INFO_CONFIG) }
-
+
+
{ translate.t(LABEL_FAILOVER_GROUPS_CONFIG) }
+
{ translate.t(INFO_CONFIG) }
+

{ translate.t(LABEL_GROUPING) }

- { enum_select!("LABEL.CHANNEL_GROUPS_MATCH_BY", "CHANNEL_GROUPS_CONFIG.MATCH_BY", ChannelMatchBy, + { enum_select!("LABEL.FAILOVER_GROUPS_MATCH_BY", "FAILOVER_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", + { toggle("LABEL.FAILOVER_GROUPS_MATCH_AS_ASCII", "FAILOVER_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, + { enum_select!("LABEL.FAILOVER_GROUPS_EPG_CONFLICT", "FAILOVER_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", + { toggle("LABEL.FAILOVER_GROUPS_NAME_STRIP_QUALITY", "FAILOVER_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, + { enum_select!("LABEL.FAILOVER_GROUPS_POLICY", "FAILOVER_GROUPS_CONFIG.POLICY", FailoverGroupPolicy, config.policy, |config, value| config.policy = value) }

{ translate.t(LABEL_START) }

- { toggle("LABEL.CHANNEL_GROUPS_ALLOW_PROVIDER_GRACE", "CHANNEL_GROUPS_CONFIG.ALLOW_PROVIDER_GRACE", + { toggle("LABEL.FAILOVER_GROUPS_ALLOW_PROVIDER_GRACE", "FAILOVER_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", + { number("LABEL.FAILOVER_GROUPS_START_TIMEOUT_SECS", "FAILOVER_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", + { number("LABEL.FAILOVER_GROUPS_START_MAX_ATTEMPTS", "FAILOVER_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", + { toggle("LABEL.ENABLED", "FAILOVER_GROUP_RUNTIME_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", + { number("LABEL.FAILOVER_GROUPS_NO_DATA_TIMEOUT_SECS", "FAILOVER_GROUP_RUNTIME_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", + { number("LABEL.FAILOVER_GROUPS_SLOW_SOURCE_PCT", "FAILOVER_GROUP_RUNTIME_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", + { number("LABEL.FAILOVER_GROUPS_SLOW_SOURCE_SECS", "FAILOVER_GROUP_RUNTIME_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", + { number("LABEL.FAILOVER_GROUPS_MAX_OUTAGE_SECS", "FAILOVER_GROUP_RUNTIME_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", + { number("LABEL.FAILOVER_GROUPS_SAME_SOURCE_RETRIES", "FAILOVER_GROUP_RUNTIME_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", + { number("LABEL.FAILOVER_GROUPS_STABLE_AFTER_SECS", "FAILOVER_GROUP_RUNTIME_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", + { number("LABEL.FAILOVER_GROUPS_MAX_SWITCHES", "FAILOVER_GROUP_RUNTIME_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", + { number("LABEL.FAILOVER_GROUPS_MAX_SWITCHES_WINDOW_SECS", "FAILOVER_GROUP_RUNTIME_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", + { enum_select!("LABEL.FAILOVER_GROUPS_AUDIO_LANGUAGE", "FAILOVER_GROUP_RUNTIME_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", + { number("LABEL.FAILOVER_GROUPS_SOURCE_FAILURES", "FAILOVER_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", + { number("LABEL.FAILOVER_GROUPS_COOLDOWN_SECS", "FAILOVER_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", + { number("LABEL.FAILOVER_GROUPS_COOLDOWN_MAX_SECS", "FAILOVER_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", + { number("LABEL.FAILOVER_GROUPS_INPUT_FAILURES", "FAILOVER_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", + { number("LABEL.FAILOVER_GROUPS_INPUT_WINDOW_SECS", "FAILOVER_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/main_config_view.rs b/frontend/src/app/components/config/main_config_view.rs index df1383ed9..81165cb14 100644 --- a/frontend/src/app/components/config/main_config_view.rs +++ b/frontend/src/app/components/config/main_config_view.rs @@ -24,6 +24,7 @@ const LABEL_STORAGE_DIR: &str = "LABEL.STORAGE_DIR"; const LABEL_DEFAULT_USER_AGENT: &str = "LABEL.DEFAULT_USER_AGENT"; const LABEL_MAPPING_PATH: &str = "LABEL.MAPPING_PATH"; const LABEL_TEMPLATE_PATH: &str = "LABEL.TEMPLATE_PATH"; +const LABEL_FAILOVER_PATH: &str = "LABEL.FAILOVER_PATH"; const LABEL_BACKUP_DIR: &str = "LABEL.BACKUP_DIR"; const LABEL_USER_CONFIG_DIR: &str = "LABEL.USER_CONFIG_DIR"; const LABEL_SLEEP_TIMER_MINS: &str = "LABEL.SLEEP_TIMER_MINS"; @@ -50,6 +51,7 @@ generate_form_reducer!( DefaultUserAgent => default_user_agent: Option, MappingPath => mapping_path: Option, TemplatePath => template_path: Option, + FailoverPath => failover_path: Option, BackupDir => backup_dir: Option, UserConfigDir => user_config_dir: Option, SleepTimerMins => sleep_timer_mins: Option, @@ -111,6 +113,7 @@ pub fn MainConfigView() -> Html { { config_field_optional!(form_state.form, translate.t(LABEL_DEFAULT_USER_AGENT), default_user_agent) } { config_field_optional!(form_state.form, translate.t(LABEL_MAPPING_PATH), mapping_path) } { config_field_optional!(form_state.form, translate.t(LABEL_TEMPLATE_PATH), template_path) } + { config_field_optional!(form_state.form, translate.t(LABEL_FAILOVER_PATH), failover_path) } { config_field_optional!(form_state.form, translate.t(LABEL_BACKUP_DIR), backup_dir) } { config_field_optional!(form_state.form, translate.t(LABEL_USER_CONFIG_DIR), user_config_dir) } { config_field_optional!(form_state.form, translate.t(LABEL_SLEEP_TIMER_MINS), sleep_timer_mins) } @@ -138,6 +141,7 @@ pub fn MainConfigView() -> Html { { edit_field_text_option!(form_state, translate.t(LABEL_DEFAULT_USER_AGENT), default_user_agent, MainConfigFormAction::DefaultUserAgent) } { edit_field_text_option!(form_state, translate.t(LABEL_MAPPING_PATH), mapping_path, MainConfigFormAction::MappingPath) } { edit_field_text_option!(form_state, translate.t(LABEL_TEMPLATE_PATH), template_path, MainConfigFormAction::TemplatePath) } + { edit_field_text_option!(form_state, translate.t(LABEL_FAILOVER_PATH), failover_path, MainConfigFormAction::FailoverPath) } { edit_field_text_option!(form_state, translate.t(LABEL_BACKUP_DIR), backup_dir, MainConfigFormAction::BackupDir) } { edit_field_text_option!(form_state, translate.t(LABEL_USER_CONFIG_DIR), user_config_dir, MainConfigFormAction::UserConfigDir) } { edit_field_number_option_u32!(form_state, translate.t(LABEL_SLEEP_TIMER_MINS), sleep_timer_mins, MainConfigFormAction::SleepTimerMins) } diff --git a/frontend/src/app/components/config/mod.rs b/frontend/src/app/components/config/mod.rs index 10a0e5949..b2ce2a74a 100644 --- a/frontend/src/app/components/config/mod.rs +++ b/frontend/src/app/components/config/mod.rs @@ -2,11 +2,11 @@ mod macros; mod admission_strategies; mod api_config_view; -mod channel_groups_config_view; mod config_page; mod config_update; mod config_view; mod config_view_context; +mod failover_groups_config_view; mod form_sync; mod hdhomerun_config_view; mod hdhomerun_device_view; @@ -27,10 +27,10 @@ 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::*; +pub use failover_groups_config_view::*; pub use form_sync::*; pub use hdhomerun_config_view::*; pub use ipcheck_config_view::*; diff --git a/frontend/src/app/components/dashboard/maintenance_action_card.rs b/frontend/src/app/components/dashboard/maintenance_action_card.rs index 24a023552..a245f5422 100644 --- a/frontend/src/app/components/dashboard/maintenance_action_card.rs +++ b/frontend/src/app/components/dashboard/maintenance_action_card.rs @@ -17,13 +17,6 @@ use yew::prelude::*; const ALERT_CLASS: &str = "tp__card--maintenance"; -fn dialog_portal(content: Html) -> Html { - match web_sys::window().and_then(|window| window.document()).and_then(|document| document.body()) { - Some(body) => yew::create_portal(content, body.into()), - None => content, - } -} - #[component] pub fn MaintenanceActionCard() -> Html { let services = use_service_context(); @@ -149,7 +142,7 @@ pub fn MaintenanceActionCard() -> Html { if *dialog_open { // Rendered into : inside the card the backdrop would share the card's stacking // context and let pointer events reach the dashboard behind it. - { dialog_portal(html! { + { crate::utils::in_body(html! { }) } } diff --git a/frontend/src/app/components/failover_groups/candidate_panel.rs b/frontend/src/app/components/failover_groups/candidate_panel.rs new file mode 100644 index 000000000..a3bbd2795 --- /dev/null +++ b/frontend/src/app/components/failover_groups/candidate_panel.rs @@ -0,0 +1,559 @@ +//! Channel catalog of the target: search, filter, paging and adding channels to the selected group. + +use super::{ + dialogs::NameDialog, + draft::{random_id_suffix, DraftOp, GroupRef}, + group_list::{find_group, group_ref_of}, + navigation::NumberedChannel, +}; +use crate::{ + app::components::{ + define_table_columns, input::Input, make_translated_header_callback, AppIcon, PagedTable, RadioButtonGroup, + TableDefinition, TextButton, + }, + hooks::use_service_context, + i18n::use_translation, + services::FailoverService, +}; +use gloo_timers::callback::Timeout; +use shared::model::{ + CandidateDto, CandidatePageDto, ChannelMemberRefDto, FailoverCandidateFilter, FailoverGroupListDto, + TargetFailoverOverridesDto, +}; +use std::{collections::HashMap, rc::Rc}; +use wasm_bindgen_futures::spawn_local; +use yew::prelude::*; + +const SEARCH_DEBOUNCE_MS: u32 = 300; +const DEFAULT_PAGE_SIZE: u16 = 100; + +define_table_columns! { + enum CandidateColumn { + Select => ("select", "LABEL.FAILOVER_GROUPS_COL_SELECT") { can_hide: false }, + Input => ("input", "LABEL.FAILOVER_GROUPS_COL_INPUT"), + Title => ("title", "LABEL.FAILOVER_GROUPS_COL_TITLE") { can_hide: false }, + PlaylistGroup => ("playlist_group", "LABEL.FAILOVER_GROUPS_COL_PLAYLIST_GROUP"), + Membership => ("membership", "LABEL.FAILOVER_GROUPS_COL_IN_GROUP"), + Actions => ("actions", "LABEL.FAILOVER_GROUPS_COL_ACTIONS") { can_hide: false }, + } +} + +/// The group of the edited draft a channel currently belongs to. +pub type Membership = HashMap; + +/// Group membership of every channel of the target. The draft decides first (members of manual groups, including +/// those too small to be materialized, and `add` entries of automatic groups; a manual group wins over an `add`), +/// the preview of the draft supplies the channels that belong to a group by their natural grouping. +pub fn membership_of(preview: Option<&FailoverGroupListDto>, draft: &TargetFailoverOverridesDto) -> Membership { + let mut membership: Membership = preview + .into_iter() + .flat_map(|preview| &preview.groups) + .flat_map(|group| { + let reference = group_ref_of(group); + group.members.iter().map(move |member| (member.member_ref.clone(), (reference.clone(), group.name.clone()))) + }) + .collect(); + for entry in draft.auto_overrides.iter().filter(|entry| !entry.add.is_empty()) { + let reference = GroupRef::Auto(entry.auto_key.clone()); + let name = preview + .and_then(|preview| find_group(preview, &reference)) + .map(|group| group.name.clone()) + .or_else(|| entry.name.clone()) + .unwrap_or_else(|| entry.auto_key.clone()); + for member in &entry.add { + membership.insert(member.clone(), (reference.clone(), name.clone())); + } + } + for group in &draft.groups { + let reference = GroupRef::Manual(group.id.clone()); + for member in &group.members { + membership.insert(member.clone(), (reference.clone(), group.name.clone())); + } + } + membership +} + +/// Every channel paired with the group that holds it now, for the operations that move channels between groups. +pub fn with_current_groups<'a>( + members: impl IntoIterator, + membership: &Membership, +) -> Vec<(ChannelMemberRefDto, Option)> { + members.into_iter().map(|member| (member.clone(), membership.get(member).map(|(group, _)| group.clone()))).collect() +} + +/// The single operation that puts all `members` into `target`; `None` when there is nothing to put. +pub fn add_members_op<'a>( + target: &GroupRef, + members: impl IntoIterator, + membership: &Membership, +) -> Option { + let members = with_current_groups(members, membership); + (!members.is_empty()).then(|| DraftOp::AddMembers { group: target.clone(), members }) +} + +/// Number of pages of `total` matches. +pub fn page_count(total: usize, page_size: u16) -> u32 { + let size = usize::from(page_size.max(1)); + u32::try_from(total.div_ceil(size)).unwrap_or(u32::MAX).max(1) +} + +const fn filter_id(filter: FailoverCandidateFilter) -> &'static str { + match filter { + FailoverCandidateFilter::All => "all", + FailoverCandidateFilter::Ungrouped => "ungrouped", + FailoverCandidateFilter::Grouped => "grouped", + FailoverCandidateFilter::Excluded => "excluded", + } +} + +fn filter_from_id(id: &str) -> Option { + [ + FailoverCandidateFilter::Ungrouped, + FailoverCandidateFilter::Grouped, + FailoverCandidateFilter::Excluded, + FailoverCandidateFilter::All, + ] + .into_iter() + .find(|filter| filter_id(*filter) == id) +} + +const fn filter_label_key(filter: FailoverCandidateFilter) -> &'static str { + match filter { + FailoverCandidateFilter::All => "LABEL.ALL", + FailoverCandidateFilter::Ungrouped => "LABEL.FAILOVER_GROUPS_FILTER_UNGROUPED", + FailoverCandidateFilter::Grouped => "LABEL.FAILOVER_GROUPS_FILTER_GROUPED", + FailoverCandidateFilter::Excluded => "LABEL.FAILOVER_GROUPS_FILTER_EXCLUDED", + } +} + +/// Ticks `member` when the page lists it. +fn select_listed(selection: &UseStateHandle, page: Option<&CandidatePageDto>, member: &ChannelMemberRefDto) { + if let Some(candidate) = page.and_then(|page| page.items.iter().find(|candidate| candidate.member_ref == *member)) { + let mut next = (**selection).clone(); + next.insert(member.clone(), candidate.clone()); + selection.set(next); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FetchStatus { + Loading, + Ready, + /// No playlist update has published the channel catalog yet. + NoCatalog, + Failed, +} + +#[derive(Debug, Clone, PartialEq)] +struct CandidateRow { + candidate: CandidateDto, + group: Option<(GroupRef, String)>, + excluded: bool, + selected: bool, +} + +type Selection = HashMap; + +#[derive(Properties, PartialEq)] +pub struct CandidatePanelProps { + pub target: String, + /// Changes whenever the stored section changes, so the persisted group of every channel is read again. + pub reload_key: Option, + pub selected_group: Option, + pub selected_group_name: Option, + pub membership: Rc, + pub excluded: Rc>, + pub read_only: bool, + pub on_op: Callback, + /// A channel to look at: seeds the search, lifts the filter and ticks the channel once it is listed. + #[prop_or_default] + pub focus: Option>, +} + +#[component] +pub fn CandidatePanel(props: &CandidatePanelProps) -> Html { + let translate = use_translation(); + let services = use_service_context(); + let query = use_state(String::new); + let shown_query = use_state(String::new); + let focused_member = use_mut_ref(|| None::); + let filter = use_state(|| FailoverCandidateFilter::Ungrouped); + let page = use_state(|| 1_u32); + let page_size = use_state(|| DEFAULT_PAGE_SIZE); + let result = use_state(|| None::>); + let status = use_state(|| FetchStatus::Loading); + let selection = use_state(Selection::new); + let show_name_dialog = use_state(|| false); + let generation = use_mut_ref(|| 0_u64); + let search_timer = use_mut_ref(|| None::); + let columns = use_memo((), |()| CandidateColumn::columns()); + let membership = props.membership.clone(); + + { + let result = result.clone(); + let status = status.clone(); + let generation = generation.clone(); + let services = services.clone(); + let translate = translate.clone(); + use_effect_with( + (props.target.clone(), (*query).clone(), *filter, *page, *page_size, props.reload_key.clone()), + move |(target, query, filter, page, page_size, _)| { + let ticket = { + let mut current = generation.borrow_mut(); + *current += 1; + *current + }; + status.set(FetchStatus::Loading); + let (target, query, filter, page, page_size) = + (target.clone(), query.clone(), *filter, *page, *page_size); + spawn_local(async move { + let offset = usize::try_from(page.saturating_sub(1)).unwrap_or(0) * usize::from(page_size); + let outcome = + FailoverService::search_candidates(&target, &query, filter, offset, usize::from(page_size)) + .await; + if *generation.borrow() != ticket { + return; + } + match outcome { + Ok(found) => { + result.set(Some(Rc::new(found))); + status.set(FetchStatus::Ready); + } + Err(crate::error::Error::Conflict(_)) => { + result.set(None); + status.set(FetchStatus::NoCatalog); + } + Err(err) => { + services + .toastr + .error(format!("{}: {err}", translate.t("MESSAGES.FAILOVER_GROUPS_CANDIDATES_FAILED"))); + status.set(FetchStatus::Failed); + } + } + }); + || () + }, + ); + } + + { + let query = query.clone(); + let shown_query = shown_query.clone(); + let filter = filter.clone(); + let page = page.clone(); + let status = status.clone(); + let result = result.clone(); + let selection = selection.clone(); + let focused_member = focused_member.clone(); + use_effect_with(props.focus.clone(), move |focus| { + if let Some(focus) = focus { + let text = focus.channel.query().to_string(); + let member = focus.channel.member_ref.clone(); + let unchanged = *query == text + && *filter == FailoverCandidateFilter::All + && *page == 1 + && *status == FetchStatus::Ready; + if unchanged { + select_listed(&selection, result.as_deref(), &member); + } else { + *focused_member.borrow_mut() = Some(member); + shown_query.set(text.clone()); + query.set(text); + filter.set(FailoverCandidateFilter::All); + page.set(1); + } + } + || () + }); + } + + // The channel of a deep link is ticked as soon as the search that lists it is answered. + { + let result = result.clone(); + let selection = selection.clone(); + let focused_member = focused_member.clone(); + use_effect_with(*status, move |status| { + if *status == FetchStatus::Ready { + if let Some(member) = focused_member.borrow_mut().take() { + select_listed(&selection, result.as_deref(), &member); + } + } + || () + }); + } + + let on_search = { + let query = query.clone(); + let shown_query = shown_query.clone(); + let page = page.clone(); + let search_timer = search_timer.clone(); + Callback::from(move |text: String| { + shown_query.set(text.clone()); + let query = query.clone(); + let page = page.clone(); + *search_timer.borrow_mut() = Some(Timeout::new(SEARCH_DEBOUNCE_MS, move || { + query.set(text.trim().to_string()); + page.set(1); + })); + }) + }; + + let on_filter = { + let filter = filter.clone(); + let page = page.clone(); + Callback::from(move |selected: Rc>| { + if let Some(next) = selected.first().and_then(|id| filter_from_id(id)) { + filter.set(next); + page.set(1); + } + }) + }; + + let group_of = |member: &ChannelMemberRefDto| membership.get(member).cloned(); + + let toggle = { + let selection = selection.clone(); + Callback::from(move |candidate: CandidateDto| { + let mut next = (*selection).clone(); + if next.remove(&candidate.member_ref).is_none() { + next.insert(candidate.member_ref.clone(), candidate); + } + selection.set(next); + }) + }; + + let add_one = { + let on_op = props.on_op.clone(); + let target_group = props.selected_group.clone(); + Callback::from(move |(candidate, current): (CandidateDto, Option)| { + if let Some(group) = target_group.as_ref() { + on_op + .emit(DraftOp::AddMembers { group: group.clone(), members: vec![(candidate.member_ref, current)] }); + } + }) + }; + + let add_selected = { + let on_op = props.on_op.clone(); + let target_group = props.selected_group.clone(); + let selection = selection.clone(); + let membership = membership.clone(); + Callback::from(move |_: String| { + if let Some(op) = + target_group.as_ref().and_then(|group| add_members_op(group, selection.keys(), &membership)) + { + on_op.emit(op); + selection.set(Selection::new()); + } + }) + }; + + let new_group = { + let on_op = props.on_op.clone(); + let selection = selection.clone(); + let membership = membership.clone(); + let show_name_dialog = show_name_dialog.clone(); + Callback::from(move |name: String| { + let members = with_current_groups(selection.keys(), &membership); + on_op.emit(DraftOp::NewGroupFromSelection { name, members, id_suffix: random_id_suffix() }); + selection.set(Selection::new()); + show_name_dialog.set(false); + }) + }; + + let rows: Rc>> = Rc::new( + result + .as_ref() + .map(|page| { + page.items + .iter() + .map(|candidate| { + Rc::new(CandidateRow { + group: group_of(&candidate.member_ref), + excluded: props.excluded.contains(&candidate.member_ref), + selected: selection.contains_key(&candidate.member_ref), + candidate: candidate.clone(), + }) + }) + .collect() + }) + .unwrap_or_default(), + ); + + let group_name = props.selected_group_name.clone().unwrap_or_default(); + let can_add = !props.read_only && props.selected_group.is_some(); + let render_header_cell = make_translated_header_callback(translate.clone(), |index| { + CandidateColumn::from_index(index).map(CandidateColumn::header_label) + }); + let render_data_cell = { + let translate = translate.clone(); + let toggle = toggle.clone(); + let add_one = add_one.clone(); + let group_name = group_name.clone(); + let selected_group = props.selected_group.clone(); + let read_only = props.read_only; + Callback::<(usize, usize, Rc), Html>::from( + move |(_, col, row): (usize, usize, Rc)| { + let candidate = &row.candidate; + match CandidateColumn::from_index(col) { + Some(CandidateColumn::Select) if !read_only => { + let toggle = toggle.clone(); + let candidate = candidate.clone(); + html! { + + } + } + Some(CandidateColumn::Input) => html! { candidate.member_ref.input.as_str() }, + Some(CandidateColumn::Title) => html! { + <> + { &candidate.title } + if row.excluded { + { translate.t("LABEL.FAILOVER_GROUPS_FILTER_EXCLUDED") } + } + + }, + Some(CandidateColumn::PlaylistGroup) => html! { candidate.playlist_group.as_str() }, + Some(CandidateColumn::Membership) => { + html! { row.group.as_ref().map_or("\u{2014}", |(_, name)| name.as_str()) } + } + Some(CandidateColumn::Actions) + if !read_only + && selected_group.is_some() + && row.group.as_ref().map(|(group, _)| group) != selected_group.as_ref() => + { + let current = row.group.as_ref().map(|(group, _)| group.clone()); + let candidate = candidate.clone(); + let add_one = add_one.clone(); + let key = if row.group.is_some() { + "LABEL.FAILOVER_GROUPS_MOVE_TO_GROUP" + } else { + "LABEL.FAILOVER_GROUPS_ADD_TO_GROUP" + }; + html! { + + } + } + _ => html! {}, + } + }, + ) + }; + + let total = result.as_ref().map_or(0, |found| found.total); + let total_pages = page_count(total, *page_size); + let definition = Rc::new(TableDefinition:: { + table_id: "failover_groups.candidates".into(), + columns: columns.clone(), + row_key: Callback::from(|(_, row): (usize, Rc)| { + format!("{}/{}", row.candidate.member_ref.input, row.candidate.member_ref.stream_id).into() + }), + items: if rows.is_empty() { None } else { Some(rows) }, + on_sort: Callback::noop(), + render_header_cell, + render_data_cell, + }); + + let options = Rc::new( + [ + FailoverCandidateFilter::Ungrouped, + FailoverCandidateFilter::Grouped, + FailoverCandidateFilter::Excluded, + FailoverCandidateFilter::All, + ] + .iter() + .map(|filter| filter_id(*filter).to_string()) + .collect::>(), + ); + let labels = Rc::new( + [ + FailoverCandidateFilter::Ungrouped, + FailoverCandidateFilter::Grouped, + FailoverCandidateFilter::Excluded, + FailoverCandidateFilter::All, + ] + .iter() + .map(|filter| translate.t(filter_label_key(*filter))) + .collect::>(), + ); + + let on_page_change = { + let page = page.clone(); + Callback::from(move |next: u32| page.set(next)) + }; + let on_page_size_change = { + let page_size = page_size.clone(); + let page = page.clone(); + Callback::from(move |size: u16| { + page_size.set(size); + page.set(1); + }) + }; + let on_new_group_open = { + let show_name_dialog = show_name_dialog.clone(); + Callback::from(move |_: String| show_name_dialog.set(true)) + }; + let on_name_cancel = { + let show_name_dialog = show_name_dialog.clone(); + Callback::from(move |()| show_name_dialog.set(false)) + }; + let selected_count = selection.len(); + + html! { +
+

{ translate.t("LABEL.FAILOVER_GROUPS_ADD_CHANNELS") }

+
+ + + if !props.read_only { + + { translate.t("LABEL.FAILOVER_GROUPS_SELECTED").replace("{count}", &selected_count.to_string()) } + + + + } +
+ { + match *status { + FetchStatus::NoCatalog => html! { +

{ translate.t("MESSAGES.FAILOVER_GROUPS_NO_CATALOG") }

+ }, + FetchStatus::Failed => html! { + + }, + FetchStatus::Loading | FetchStatus::Ready => html! { + + definition={definition} + page={*page} + page_size={*page_size} + total_items={total as u64} + total_pages={total_pages} + has_prev={*page > 1} + has_next={*page < total_pages} + on_page_change={on_page_change} + on_page_size_change={on_page_size_change}/> + }, + } + } + if *show_name_dialog { + + } +
+ } +} diff --git a/frontend/src/app/components/failover_groups/diagnostics_panel.rs b/frontend/src/app/components/failover_groups/diagnostics_panel.rs new file mode 100644 index 000000000..1cd8aa951 --- /dev/null +++ b/frontend/src/app/components/failover_groups/diagnostics_panel.rs @@ -0,0 +1,267 @@ +//! Problems of the overrides: findings, stale and conflicting corrections, groups that cannot be formed and +//! sections of targets that no longer exist. + +use super::{ + dialogs::{PickerDialog, PickerItem}, + draft::{random_id_suffix, DraftOp, GroupRef}, +}; +use crate::{ + app::components::{AppIcon, TextButton}, + i18n::use_translation, +}; +use shared::model::{ + ChannelMemberRefDto, FailoverGroupTargetStatusDto, FailoverOverrideDiagnosticsDto, OverrideValidationError, + TargetFailoverOverridesDto, +}; +use std::rc::Rc; +use yew::prelude::*; + +/// The draft entries that hold `member`: manual groups by member list, automatic groups by `add` list. +pub fn holders(draft: &TargetFailoverOverridesDto, member: &ChannelMemberRefDto) -> Vec { + draft + .groups + .iter() + .filter(|group| group.members.contains(member)) + .map(|group| GroupRef::Manual(group.id.clone())) + .chain( + draft + .auto_overrides + .iter() + .filter(|entry| entry.add.contains(member)) + .map(|entry| GroupRef::Auto(entry.auto_key.clone())), + ) + .collect() +} + +/// The entry a diagnostics `group` key points at: a manual group id when the draft has one, an auto key otherwise. +pub fn group_ref_for_key(draft: &TargetFailoverOverridesDto, key: &str) -> GroupRef { + if draft.groups.iter().any(|group| group.id == key) { + GroupRef::Manual(key.to_string()) + } else { + GroupRef::Auto(key.to_string()) + } +} + +/// Number of entries the problems view lists for the open target and the sections of unknown or disabled targets. +pub fn problem_total( + diagnostics: &FailoverOverrideDiagnosticsDto, + findings: &[OverrideValidationError], + groups_with_unresolved: usize, + sections: usize, +) -> usize { + findings.len() + + diagnostics.stale_auto_overrides.len() + + diagnostics.inactive_manual_groups.len() + + diagnostics.conflicting_refs.len() + + groups_with_unresolved + + sections +} + +fn member_label(member: &ChannelMemberRefDto) -> String { format!("{}/{}", member.input, member.stream_id) } + +#[derive(Properties, PartialEq)] +pub struct DiagnosticsPanelProps { + pub diagnostics: FailoverOverrideDiagnosticsDto, + pub findings: Vec, + pub draft: Rc, + /// Sections of targets that do not exist. + pub orphans: Vec, + /// Sections of targets that exist but have failover groups disabled. + pub disabled: Vec, + /// Targets a section can be assigned to. + pub assignable: Vec, + pub read_only: bool, + pub on_op: Callback, + pub on_select: Callback, + /// `(section target, new target, etag of the section)` + pub on_assign: Callback<(String, String, String)>, + /// `(section target, etag of the section)` + pub on_delete_orphan: Callback<(String, String)>, +} + +#[component] +pub fn DiagnosticsPanel(props: &DiagnosticsPanelProps) -> Html { + let translate = use_translation(); + let assigning = use_state(|| None::<(String, String)>); + let diagnostics = &props.diagnostics; + let read_only = props.read_only; + + let button = |name: &str, key: &str, icon: &str, onclick: Callback| { + html! { } + }; + let op_button = |name: &str, key: &str, icon: &str, op: DraftOp| { + let on_op = props.on_op.clone(); + button(name, key, icon, Callback::from(move |_| on_op.emit(op.clone()))) + }; + + // Sections that cannot be edited: heading, hint and one row per section with the assign and delete actions. + let section_list = |heading: &str, hint: &str, sections: &[FailoverGroupTargetStatusDto], note: Option<&str>| { + html! { + <> +

{ translate.t(heading) }

+

{ translate.t(hint) }

+
    + { for sections.iter().map(|section| { + let etag = section.etag.clone().unwrap_or_default(); + let assign = { + let assigning = assigning.clone(); + let name = section.name.clone(); + let etag = etag.clone(); + Callback::from(move |_: String| assigning.set(Some((name.clone(), etag.clone())))) + }; + let delete = { + let on_delete = props.on_delete_orphan.clone(); + let name = section.name.clone(); + Callback::from(move |_: String| on_delete.emit((name.clone(), etag.clone()))) + }; + html! { +
  • + + { §ion.name } + if let Some(file) = section.file.as_ref() { { format!(" ({file})") } } + if let Some(note) = note { { format!(": {}", translate.t(note)) } } + + { button("assign", "LABEL.FAILOVER_GROUPS_ASSIGN_TO_TARGET", "Target", assign) } + { button("delete_section", "LABEL.FAILOVER_GROUPS_DELETE_SECTION", "Delete", delete) } +
  • + } + }) } +
+ + } + }; + + let no_problems = props.findings.is_empty() + && diagnostics.stale_auto_overrides.is_empty() + && diagnostics.inactive_manual_groups.is_empty() + && diagnostics.conflicting_refs.is_empty() + && diagnostics.unresolved_refs.is_empty() + && props.orphans.is_empty() + && props.disabled.is_empty(); + + let on_pick = { + let assigning = assigning.clone(); + let on_assign = props.on_assign.clone(); + Callback::from(move |to: String| { + if let Some((from, etag)) = (*assigning).clone() { + on_assign.emit((from, to, etag)); + } + assigning.set(None); + }) + }; + let on_pick_cancel = { + let assigning = assigning.clone(); + Callback::from(move |()| assigning.set(None)) + }; + + html! { +
+ if no_problems { +

{ translate.t("MESSAGES.FAILOVER_GROUPS_NO_PROBLEMS") }

+ } + if !props.findings.is_empty() { +

{ translate.t("LABEL.FAILOVER_GROUPS_FINDINGS") }

+
    + { for props.findings.iter().map(|finding| html! { +
  • { finding.to_string() }
  • + }) } +
+ } + if !diagnostics.stale_auto_overrides.is_empty() { +

{ translate.t("LABEL.FAILOVER_GROUPS_STALE") }

+

{ translate.t("MESSAGES.FAILOVER_GROUPS_STALE_HINT") }

+
    + { for diagnostics.stale_auto_overrides.iter().map(|key| html! { +
  • + { key } + { op_button("convert", "LABEL.FAILOVER_GROUPS_CONVERT_TO_MANUAL", "Group", + DraftOp::ConvertStaleToManual { auto_key: key.clone(), id_suffix: random_id_suffix() }) } + { op_button("remove", "LABEL.FAILOVER_GROUPS_REMOVE_OVERRIDE", "Delete", + DraftOp::ResetAuto { auto_key: key.clone() }) } +
  • + }) } +
+ } + if !diagnostics.inactive_manual_groups.is_empty() { +

{ translate.t("LABEL.FAILOVER_GROUPS_INACTIVE_GROUPS") }

+
    + { for diagnostics.inactive_manual_groups.iter().map(|inactive| { + let group = GroupRef::Manual(inactive.id.clone()); + let name = props.draft.groups.iter().find(|entry| entry.id == inactive.id) + .map_or_else(|| inactive.id.clone(), |entry| entry.name.clone()); + let on_select = props.on_select.clone(); + let open = { + let group = group.clone(); + Callback::from(move |_: String| on_select.emit(group.clone())) + }; + html! { +
  • + + { format!("{name} ({})", translate.t("MESSAGES.FAILOVER_GROUPS_RESOLVED_MEMBERS") + .replace("{count}", &inactive.resolved.to_string())) } + + + { op_button("delete", "LABEL.FAILOVER_GROUPS_DELETE_GROUP", "Delete", DraftOp::Dissolve { group }) } +
  • + } + }) } +
+ } + if !diagnostics.conflicting_refs.is_empty() { +

{ translate.t("LABEL.FAILOVER_GROUPS_CONFLICTS") }

+

{ translate.t("MESSAGES.FAILOVER_GROUPS_CONFLICT_HINT") }

+
    + { for diagnostics.conflicting_refs.iter().map(|member| html! { +
  • + { member_label(member) } + { for holders(&props.draft, member).into_iter().map(|group| { + let label = match &group { + GroupRef::Manual(id) | GroupRef::Auto(id) => id.clone(), + }; + let title = translate.t("LABEL.FAILOVER_GROUPS_REMOVE_FROM").replace("{group}", &label); + let on_op = props.on_op.clone(); + let op = DraftOp::ForgetRef { group, member: member.clone() }; + html! { } + }) } +
  • + }) } +
+ } + if !diagnostics.unresolved_refs.is_empty() { +

{ translate.t("LABEL.FAILOVER_GROUPS_UNRESOLVED") }

+

{ translate.t("MESSAGES.FAILOVER_GROUPS_UNRESOLVED_HINT") }

+
    + { for diagnostics.unresolved_refs.iter().map(|unresolved| { + let group = group_ref_for_key(&props.draft, &unresolved.group); + html! { +
  • + + { format!("{}: {}", unresolved.group, member_label(&unresolved.member_ref)) } + + { op_button("remove", "LABEL.FAILOVER_GROUPS_REMOVE_REFERENCE", "Delete", + DraftOp::ForgetRef { group, member: unresolved.member_ref.clone() }) } +
  • + } + }) } +
+ } + if !props.orphans.is_empty() { + { section_list("LABEL.FAILOVER_GROUPS_ORPHANS", "MESSAGES.FAILOVER_GROUPS_ORPHAN_HINT", &props.orphans, None) } + } + if !props.disabled.is_empty() { + { section_list("LABEL.FAILOVER_GROUPS_DISABLED_SECTIONS", "MESSAGES.FAILOVER_GROUPS_DISABLED_HINT", + &props.disabled, Some("MESSAGES.FAILOVER_GROUPS_TARGET_DISABLED")) } + } + if assigning.is_some() { + >()} + on_pick={on_pick} + on_cancel={on_pick_cancel}/> + } +
+ } +} diff --git a/frontend/src/app/components/failover_groups/dialogs.rs b/frontend/src/app/components/failover_groups/dialogs.rs new file mode 100644 index 000000000..de5cff27e --- /dev/null +++ b/frontend/src/app/components/failover_groups/dialogs.rs @@ -0,0 +1,133 @@ +//! Small dialogs of the failover groups page: a name prompt and a list picker. + +use crate::{ + app::components::{CustomDialog, TextButton}, + edit_field_text, generate_form_reducer, + i18n::use_translation, + use_default_form_reducer, + utils::in_body, +}; +use yew::prelude::*; + +/// Form data of the name prompt; the type name is the prefix of the field id used for labels and explanations. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct NewFailoverGroup { + pub name: String, +} + +generate_form_reducer!( + state: NewFailoverGroupFormState { form: NewFailoverGroup }, + action_name: NewFailoverGroupFormAction, + fields { + Name => name: String, + } +); + +#[derive(Properties, PartialEq)] +pub struct NameDialogProps { + pub title: String, + pub on_submit: Callback, + pub on_cancel: Callback<()>, +} + +/// Asks for the name of a new manual group. +#[component] +pub fn NameDialog(props: &NameDialogProps) -> Html { + let translate = use_translation(); + let form_state: UseReducerHandle = + use_default_form_reducer!(NewFailoverGroupFormState { form: NewFailoverGroup::default() }); + let name = form_state.form.name.trim().to_string(); + let can_submit = !name.is_empty(); + + let on_submit = { + let on_submit = props.on_submit.clone(); + let name = name.clone(); + Callback::from(move |_: String| { + if !name.is_empty() { + on_submit.emit(name.clone()); + } + }) + }; + let on_cancel = { + let on_cancel = props.on_cancel.clone(); + Callback::from(move |_: String| on_cancel.emit(())) + }; + let on_close = { + let on_cancel = props.on_cancel.clone(); + Callback::from(move |()| on_cancel.emit(())) + }; + + in_body(html! { + +

{ &props.title }

+
+ { edit_field_text!(form_state, translate.t("LABEL.NAME"), name, NewFailoverGroupFormAction::Name) } +
+
+ + +
+
+ }) +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PickerItem { + pub id: String, + pub label: String, +} + +#[derive(Properties, PartialEq)] +pub struct PickerDialogProps { + pub title: String, + pub items: Vec, + pub on_pick: Callback, + pub on_cancel: Callback<()>, +} + +/// Lets the user pick one entry of a list; the list scrolls inside the dialog. +#[component] +pub fn PickerDialog(props: &PickerDialogProps) -> Html { + let translate = use_translation(); + let on_cancel = { + let on_cancel = props.on_cancel.clone(); + Callback::from(move |_: String| on_cancel.emit(())) + }; + let on_close = { + let on_cancel = props.on_cancel.clone(); + Callback::from(move |()| on_cancel.emit(())) + }; + + in_body(html! { + +

{ &props.title }

+
    + { for props.items.iter().map(|item| { + let onclick = { + let on_pick = props.on_pick.clone(); + let id = item.id.clone(); + Callback::from(move |_: MouseEvent| on_pick.emit(id.clone())) + }; + html! { +
  • + +
  • + } + }) } + if props.items.is_empty() { +
  • { translate.t("LABEL.NO_CONTENT") }
  • + } +
+
+ +
+
+ }) +} diff --git a/frontend/src/app/components/failover_groups/draft.rs b/frontend/src/app/components/failover_groups/draft.rs new file mode 100644 index 000000000..c04b6879e --- /dev/null +++ b/frontend/src/app/components/failover_groups/draft.rs @@ -0,0 +1,451 @@ +//! Pure edit operations on the failover override section of one target (spec 8.3). +//! +//! Every operation changes the section only, is idempotent and keeps the document minimal: override entries that +//! became empty are removed again. + +use shared::model::{ + AutoGroupOverrideDto, ChannelMemberRefDto, FailoverGroupPolicy, ManualFailoverGroupDto, TargetFailoverOverridesDto, +}; + +const ID_SLUG_MAX_LEN: usize = 59; +const ID_SUFFIX_LEN: usize = 4; +const ID_SLUG_FALLBACK: &str = "group"; +const ID_SUFFIX_ALPHABET: &[u8; 36] = b"abcdefghijklmnopqrstuvwxyz0123456789"; + +/// Identifies a group of the editor: an auto group by its auto key, a manual group by its id. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum GroupRef { + Auto(String), + Manual(String), +} + +/// New metadata of a group. Blank values clear the override; for a manual group a missing or blank `name` keeps +/// the current name because a manual group always needs one. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GroupMetaPatch { + pub name: Option, + pub logo: Option, + pub epg_channel_id: Option, + pub playlist_group: Option, +} + +/// One user action on the draft. `Edit(DraftOp)` of the editor state applies it as a single undo step. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DraftOp { + Reorder { + group: GroupRef, + from: usize, + to: usize, + current: Vec, + }, + MoveFirst { + group: GroupRef, + index: usize, + current: Vec, + }, + /// Puts several channels into `group` as one step; each is paired with the group that holds it now, if any. + AddMembers { + group: GroupRef, + members: Vec<(ChannelMemberRefDto, Option)>, + }, + RemoveMember { + group: GroupRef, + member: ChannelMemberRefDto, + }, + MoveMember { + from: GroupRef, + to: GroupRef, + member: ChannelMemberRefDto, + }, + ExcludeMember { + group: Option, + member: ChannelMemberRefDto, + }, + /// Deletes a reference from the group's entry without recording a correction (problems view). + ForgetRef { + group: GroupRef, + member: ChannelMemberRefDto, + }, + SetMeta { + group: GroupRef, + patch: GroupMetaPatch, + }, + SetPolicy { + group: GroupRef, + policy: Option, + }, + Dissolve { + group: GroupRef, + }, + ResetAuto { + auto_key: String, + }, + /// New manual group; every member is first taken out of the group it currently belongs to. + NewGroupFromSelection { + name: String, + members: Vec<(ChannelMemberRefDto, Option)>, + id_suffix: String, + }, + ConvertStaleToManual { + auto_key: String, + id_suffix: String, + }, +} + +impl DraftOp { + /// Applies the operation and returns the group that was created by it, if any. + pub fn apply(&self, section: &mut TargetFailoverOverridesDto) -> Option { + match self { + Self::Reorder { group, from, to, current } => reorder(section, group, *from, *to, current), + Self::MoveFirst { group, index, current } => move_first(section, group, *index, current), + Self::AddMembers { group, members } => add_members(section, group, members), + Self::RemoveMember { group, member } => remove_member(section, group, member), + Self::MoveMember { from, to, member } => move_member(section, from, to, member), + Self::ExcludeMember { group, member } => exclude_member(section, group.as_ref(), member), + Self::ForgetRef { group, member } => forget_ref(section, group, member), + Self::SetMeta { group, patch } => set_meta(section, group, patch.clone()), + Self::SetPolicy { group, policy } => set_policy(section, group, *policy), + Self::Dissolve { group } => dissolve(section, group), + Self::ResetAuto { auto_key } => reset_auto(section, auto_key), + Self::NewGroupFromSelection { name, members, id_suffix } => { + for (member, source) in members { + if let Some(source) = source { + remove_member(section, source, member); + } + } + let refs: Vec = members.iter().map(|(member, _)| member.clone()).collect(); + let id = create_manual_group(section, name, &refs, id_suffix); + return Some(GroupRef::Manual(id)); + } + Self::ConvertStaleToManual { auto_key, id_suffix } => { + return convert_stale_to_manual(section, auto_key, id_suffix).map(GroupRef::Manual); + } + } + None + } +} + +fn take_ref(list: &mut Vec, member: &ChannelMemberRefDto) -> bool { + match list.iter().position(|entry| entry == member) { + Some(index) => { + list.remove(index); + true + } + None => false, + } +} + +fn push_ref(list: &mut Vec, member: &ChannelMemberRefDto) { + if !list.contains(member) { + list.push(member.clone()); + } +} + +fn normalize(value: Option) -> Option { + value.map(|text| text.trim().to_string()).filter(|text| !text.is_empty()) +} + +fn is_blank_override(entry: &AutoGroupOverrideDto) -> bool { + !entry.disabled + && entry.policy.is_none() + && entry.order.is_empty() + && entry.add.is_empty() + && entry.remove.is_empty() + && entry.name.is_none() + && entry.logo.is_none() + && entry.epg_channel_id.is_none() + && entry.playlist_group.is_none() +} + +/// Runs `edit` on the override of `auto_key`, creating it on demand and removing it again when it ends up empty. +fn edit_auto( + section: &mut TargetFailoverOverridesDto, + auto_key: &str, + edit: impl FnOnce(&mut AutoGroupOverrideDto) -> R, +) -> R { + let index = section.auto_overrides.iter().position(|entry| entry.auto_key == auto_key).unwrap_or_else(|| { + section.auto_overrides.push(AutoGroupOverrideDto { auto_key: auto_key.to_string(), ..Default::default() }); + section.auto_overrides.len() - 1 + }); + let result = edit(&mut section.auto_overrides[index]); + if is_blank_override(§ion.auto_overrides[index]) { + section.auto_overrides.remove(index); + } + result +} + +fn manual_mut<'a>(section: &'a mut TargetFailoverOverridesDto, id: &str) -> Option<&'a mut ManualFailoverGroupDto> { + section.groups.iter_mut().find(|group| group.id == id) +} + +/// `current` with the entry at `from` moved to `to`; `None` when nothing would change. +fn moved(current: &[ChannelMemberRefDto], from: usize, to: usize) -> Option> { + if from == to || from >= current.len() || to >= current.len() { + return None; + } + let mut order = current.to_vec(); + let item = order.remove(from); + order.insert(to, item); + Some(order) +} + +/// Moves the member at `from` to `to` in the effective member list `current`. +/// An auto group stores the full resulting order, a manual group sorts its members accordingly; members of a +/// manual group that are not in `current` (unresolved references) keep their relative place at the end. +pub fn reorder( + section: &mut TargetFailoverOverridesDto, + group: &GroupRef, + from: usize, + to: usize, + current: &[ChannelMemberRefDto], +) { + let Some(order) = moved(current, from, to) else { return }; + match group { + GroupRef::Auto(key) => edit_auto(section, key, |entry| entry.order = order), + GroupRef::Manual(id) => { + if let Some(group) = manual_mut(section, id) { + group + .members + .sort_by_key(|member| order.iter().position(|entry| entry == member).unwrap_or(usize::MAX)); + } + } + } +} + +pub fn move_first( + section: &mut TargetFailoverOverridesDto, + group: &GroupRef, + index: usize, + current: &[ChannelMemberRefDto], +) { + reorder(section, group, index, 0, current); +} + +/// Adds a channel to the group. For an auto group a channel that was removed from it is only taken out of +/// `remove`. An excluded channel is included again because an excluded channel cannot belong to a group. +pub fn add_member(section: &mut TargetFailoverOverridesDto, group: &GroupRef, member: &ChannelMemberRefDto) { + match group { + GroupRef::Auto(key) => edit_auto(section, key, |entry| { + if !take_ref(&mut entry.remove, member) { + push_ref(&mut entry.add, member); + } + }), + GroupRef::Manual(id) => { + let Some(group) = manual_mut(section, id) else { return }; + push_ref(&mut group.members, member); + } + } + take_ref(&mut section.excluded, member); +} + +/// Removes a channel from the group. An auto group records it in `remove`, unless it had been added to the group +/// explicitly, in which case dropping that addition is enough. +pub fn remove_member(section: &mut TargetFailoverOverridesDto, group: &GroupRef, member: &ChannelMemberRefDto) { + match group { + GroupRef::Auto(key) => edit_auto(section, key, |entry| { + take_ref(&mut entry.order, member); + if !take_ref(&mut entry.add, member) { + push_ref(&mut entry.remove, member); + } + }), + GroupRef::Manual(id) => { + if let Some(group) = manual_mut(section, id) { + take_ref(&mut group.members, member); + } + } + } +} + +pub fn move_member( + section: &mut TargetFailoverOverridesDto, + from: &GroupRef, + to: &GroupRef, + member: &ChannelMemberRefDto, +) { + if from == to { + return; + } + remove_member(section, from, member); + add_member(section, to, member); +} + +/// Puts every channel into `group`: a channel that another group holds is moved, one that already is in `group` +/// stays untouched, all others are added. +pub fn add_members( + section: &mut TargetFailoverOverridesDto, + group: &GroupRef, + members: &[(ChannelMemberRefDto, Option)], +) { + for (member, current) in members { + match current { + Some(current) if current == group => {} + Some(current) => move_member(section, current, group, member), + None => add_member(section, group, member), + } + } +} + +/// Excludes a channel of the target. `group` is the group it currently belongs to, if any. +pub fn exclude_member( + section: &mut TargetFailoverOverridesDto, + group: Option<&GroupRef>, + member: &ChannelMemberRefDto, +) { + if let Some(group) = group { + remove_member(section, group, member); + } + push_ref(&mut section.excluded, member); +} + +/// Drops a reference from every list of the group's entry; unlike `remove_member` nothing is recorded. +pub fn forget_ref(section: &mut TargetFailoverOverridesDto, group: &GroupRef, member: &ChannelMemberRefDto) { + match group { + GroupRef::Auto(key) => { + if section.auto_overrides.iter().any(|entry| entry.auto_key == *key) { + edit_auto(section, key, |entry| { + take_ref(&mut entry.order, member); + take_ref(&mut entry.add, member); + take_ref(&mut entry.remove, member); + }); + } + } + GroupRef::Manual(id) => { + if let Some(group) = manual_mut(section, id) { + take_ref(&mut group.members, member); + } + } + } +} + +pub fn set_meta(section: &mut TargetFailoverOverridesDto, group: &GroupRef, patch: GroupMetaPatch) { + let GroupMetaPatch { name, logo, epg_channel_id, playlist_group } = patch; + match group { + GroupRef::Auto(key) => edit_auto(section, key, |entry| { + entry.name = normalize(name); + entry.logo = normalize(logo); + entry.epg_channel_id = normalize(epg_channel_id); + entry.playlist_group = normalize(playlist_group); + }), + GroupRef::Manual(id) => { + let Some(group) = manual_mut(section, id) else { return }; + if let Some(name) = normalize(name) { + group.name = name; + } + group.logo = normalize(logo); + group.epg_channel_id = normalize(epg_channel_id); + group.playlist_group = normalize(playlist_group); + } + } +} + +/// Sets the policy of the group; `None` falls back to the policy of the target. +pub fn set_policy(section: &mut TargetFailoverOverridesDto, group: &GroupRef, policy: Option) { + match group { + GroupRef::Auto(key) => edit_auto(section, key, |entry| entry.policy = policy), + GroupRef::Manual(id) => { + if let Some(group) = manual_mut(section, id) { + group.policy = policy; + } + } + } +} + +/// Dissolves the group: an auto group is disabled, a manual group is deleted. +pub fn dissolve(section: &mut TargetFailoverOverridesDto, group: &GroupRef) { + match group { + GroupRef::Auto(key) => edit_auto(section, key, |entry| entry.disabled = true), + GroupRef::Manual(id) => section.groups.retain(|group| group.id != *id), + } +} + +/// Deletes the override entry of an auto group. +pub fn reset_auto(section: &mut TargetFailoverOverridesDto, auto_key: &str) { + section.auto_overrides.retain(|entry| entry.auto_key != auto_key); +} + +/// Random suffix of a generated manual group id. +pub fn random_id_suffix() -> String { + (0..ID_SUFFIX_LEN).map(|_| char::from(ID_SUFFIX_ALPHABET[fastrand::usize(..ID_SUFFIX_ALPHABET.len())])).collect() +} + +/// Lowercase ascii alphanumerics of `name`, everything else collapsed to single dashes, at most 59 characters. +fn id_slug(name: &str) -> String { + let mut slug = String::with_capacity(name.len().min(ID_SLUG_MAX_LEN)); + for ch in name.chars().map(|ch| ch.to_ascii_lowercase()) { + if slug.len() >= ID_SLUG_MAX_LEN { + break; + } + if ch.is_ascii_alphanumeric() { + slug.push(ch); + } else if !slug.is_empty() && !slug.ends_with('-') { + slug.push('-'); + } + } + slug.truncate(slug.trim_end_matches('-').len()); + if slug.is_empty() { + slug.push_str(ID_SLUG_FALLBACK); + } + slug +} + +fn generate_manual_id(section: &TargetFailoverOverridesDto, name: &str, suffix: &str) -> String { + let slug = id_slug(name); + let mut id = format!("{slug}-{suffix}"); + while section.groups.iter().any(|group| group.id == id) { + id = format!("{slug}-{}", random_id_suffix()); + } + id +} + +/// Creates a manual group with the given members and returns its id (`-`). +/// The members are no longer excluded; taking them out of other groups is up to the caller. +pub fn create_manual_group( + section: &mut TargetFailoverOverridesDto, + name: &str, + members: &[ChannelMemberRefDto], + suffix: &str, +) -> String { + let id = generate_manual_id(section, name, suffix); + let mut group_members = Vec::with_capacity(members.len()); + for member in members { + push_ref(&mut group_members, member); + } + section.excluded.retain(|excluded| !group_members.contains(excluded)); + section.groups.push(ManualFailoverGroupDto { + id: id.clone(), + name: name.trim().to_string(), + members: group_members, + ..Default::default() + }); + id +} + +/// Turns the override of a stale auto key into a manual group made of the references it still holds and removes +/// the override. Returns the id of the new group, `None` when there is no override for the key. +pub fn convert_stale_to_manual( + section: &mut TargetFailoverOverridesDto, + auto_key: &str, + suffix: &str, +) -> Option { + let index = section.auto_overrides.iter().position(|entry| entry.auto_key == auto_key)?; + let entry = section.auto_overrides.remove(index); + let mut members = Vec::with_capacity(entry.order.len() + entry.add.len()); + for member in entry.order.iter().chain(&entry.add) { + if !entry.remove.contains(member) { + push_ref(&mut members, member); + } + } + let name = entry.name.as_deref().unwrap_or(auto_key); + let id = create_manual_group(section, name, &members, suffix); + if let Some(group) = manual_mut(section, &id) { + group.logo = entry.logo; + group.epg_channel_id = entry.epg_channel_id; + group.playlist_group = entry.playlist_group; + group.policy = entry.policy; + } + Some(id) +} + +#[cfg(test)] +#[path = "draft.test.rs"] +mod tests; diff --git a/frontend/src/app/components/failover_groups/draft.test.rs b/frontend/src/app/components/failover_groups/draft.test.rs new file mode 100644 index 000000000..49fc3afdd --- /dev/null +++ b/frontend/src/app/components/failover_groups/draft.test.rs @@ -0,0 +1,467 @@ +use super::*; + +fn member(input: &str, stream_id: &str) -> ChannelMemberRefDto { + ChannelMemberRefDto { input: input.to_string(), stream_id: stream_id.to_string() } +} + +fn section() -> TargetFailoverOverridesDto { TargetFailoverOverridesDto::new("Main TV") } + +fn manual(id: &str, members: &[ChannelMemberRefDto]) -> ManualFailoverGroupDto { + ManualFailoverGroupDto { + id: id.to_string(), + name: format!("Group {id}"), + members: members.to_vec(), + ..Default::default() + } +} + +fn auto(key: &str) -> GroupRef { GroupRef::Auto(key.to_string()) } + +fn manual_ref(id: &str) -> GroupRef { GroupRef::Manual(id.to_string()) } + +fn auto_entry<'a>(section: &'a TargetFailoverOverridesDto, key: &str) -> Option<&'a AutoGroupOverrideDto> { + section.auto_overrides.iter().find(|entry| entry.auto_key == key) +} + +#[test] +fn reorder_auto_group_writes_full_order() { + let (a, b, c) = (member("p", "1"), member("p", "2"), member("p", "3")); + let mut section = section(); + reorder(&mut section, &auto("zdf"), 2, 0, &[a.clone(), b.clone(), c.clone()]); + assert_eq!(auto_entry(§ion, "zdf").expect("override").order, vec![c, a, b]); +} + +#[test] +fn reorder_manual_group_sorts_members_and_keeps_unresolved_at_end() { + let (a, b, c, ghost) = (member("p", "1"), member("p", "2"), member("p", "3"), member("p", "gone")); + let mut section = section(); + section.groups.push(manual("sport", &[a.clone(), ghost.clone(), b.clone(), c.clone()])); + reorder(&mut section, &manual_ref("sport"), 0, 2, &[a.clone(), b.clone(), c.clone()]); + assert_eq!(section.groups[0].members, vec![b, c, a, ghost]); +} + +#[test] +fn reorder_with_same_or_invalid_positions_is_a_noop() { + let (a, b) = (member("p", "1"), member("p", "2")); + let mut section = section(); + reorder(&mut section, &auto("zdf"), 1, 1, &[a.clone(), b.clone()]); + reorder(&mut section, &auto("zdf"), 0, 5, &[a, b]); + assert!(section.is_empty()); +} + +#[test] +fn move_first_puts_member_at_front() { + let (a, b, c) = (member("p", "1"), member("p", "2"), member("p", "3")); + let mut section = section(); + move_first(&mut section, &auto("zdf"), 1, &[a.clone(), b.clone(), c.clone()]); + assert_eq!(auto_entry(§ion, "zdf").expect("override").order, vec![b, a, c]); +} + +#[test] +fn add_member_to_auto_group_writes_add_once() { + let a = member("p", "1"); + let mut section = section(); + add_member(&mut section, &auto("zdf"), &a); + add_member(&mut section, &auto("zdf"), &a); + assert_eq!(auto_entry(§ion, "zdf").expect("override").add, vec![a]); +} + +#[test] +fn add_member_back_to_auto_group_only_clears_remove() { + let a = member("p", "1"); + let mut section = section(); + remove_member(&mut section, &auto("zdf"), &a); + add_member(&mut section, &auto("zdf"), &a); + assert!(section.is_empty()); +} + +#[test] +fn add_member_to_manual_group_appends_without_duplicates() { + let (a, b) = (member("p", "1"), member("p", "2")); + let mut section = section(); + section.groups.push(manual("sport", std::slice::from_ref(&a))); + add_member(&mut section, &manual_ref("sport"), &b); + add_member(&mut section, &manual_ref("sport"), &b); + assert_eq!(section.groups[0].members, vec![a, b]); +} + +#[test] +fn add_member_includes_an_excluded_channel_again() { + let a = member("p", "1"); + let mut section = section(); + section.excluded.push(a.clone()); + add_member(&mut section, &auto("zdf"), &a); + assert!(section.excluded.is_empty()); + assert_eq!(auto_entry(§ion, "zdf").expect("override").add, vec![a]); +} + +#[test] +fn remove_member_from_auto_group_writes_remove() { + let (a, b) = (member("p", "1"), member("p", "2")); + let mut section = section(); + section.auto_overrides.push(AutoGroupOverrideDto { + auto_key: "zdf".to_string(), + order: vec![a.clone(), b.clone()], + ..Default::default() + }); + remove_member(&mut section, &auto("zdf"), &a); + remove_member(&mut section, &auto("zdf"), &a); + let entry = auto_entry(§ion, "zdf").expect("override"); + assert_eq!(entry.remove, vec![a]); + assert_eq!(entry.order, vec![b]); +} + +#[test] +fn remove_member_added_to_auto_group_drops_the_addition() { + let a = member("p", "1"); + let mut section = section(); + add_member(&mut section, &auto("zdf"), &a); + remove_member(&mut section, &auto("zdf"), &a); + assert!(section.is_empty()); +} + +#[test] +fn remove_member_from_manual_group_drops_member() { + let (a, b) = (member("p", "1"), member("p", "2")); + let mut section = section(); + section.groups.push(manual("sport", &[a.clone(), b.clone()])); + remove_member(&mut section, &manual_ref("sport"), &a); + assert_eq!(section.groups[0].members, vec![b]); + assert!(section.auto_overrides.is_empty()); +} + +#[test] +fn move_member_between_auto_groups_removes_and_adds() { + let a = member("p", "1"); + let mut section = section(); + move_member(&mut section, &auto("zdf"), &auto("ard"), &a); + assert_eq!(auto_entry(§ion, "zdf").expect("override").remove, vec![a.clone()]); + assert_eq!(auto_entry(§ion, "ard").expect("override").add, vec![a]); +} + +#[test] +fn move_member_from_manual_to_auto_group() { + let (a, b) = (member("p", "1"), member("p", "2")); + let mut section = section(); + section.groups.push(manual("sport", &[a.clone(), b.clone()])); + move_member(&mut section, &manual_ref("sport"), &auto("zdf"), &a); + assert_eq!(section.groups[0].members, vec![b]); + assert_eq!(auto_entry(§ion, "zdf").expect("override").add, vec![a]); +} + +#[test] +fn move_member_within_same_group_is_a_noop() { + let a = member("p", "1"); + let mut section = section(); + move_member(&mut section, &auto("zdf"), &auto("zdf"), &a); + assert!(section.is_empty()); +} + +#[test] +fn exclude_member_in_auto_group_adds_excluded_and_remove() { + let a = member("p", "1"); + let mut section = section(); + exclude_member(&mut section, Some(&auto("zdf")), &a); + exclude_member(&mut section, Some(&auto("zdf")), &a); + assert_eq!(section.excluded, vec![a.clone()]); + assert_eq!(auto_entry(§ion, "zdf").expect("override").remove, vec![a]); +} + +#[test] +fn exclude_member_in_manual_group_drops_member_and_adds_excluded() { + let (a, b) = (member("p", "1"), member("p", "2")); + let mut section = section(); + section.groups.push(manual("sport", &[a.clone(), b.clone()])); + exclude_member(&mut section, Some(&manual_ref("sport")), &a); + assert_eq!(section.groups[0].members, vec![b]); + assert_eq!(section.excluded, vec![a]); +} + +#[test] +fn exclude_ungrouped_member_only_adds_excluded() { + let a = member("p", "1"); + let mut section = section(); + exclude_member(&mut section, None, &a); + assert_eq!(section.excluded, vec![a]); + assert!(section.auto_overrides.is_empty()); +} + +#[test] +fn set_meta_on_auto_group_writes_override_fields_and_clears_blank() { + let mut section = section(); + let patch = GroupMetaPatch { + name: Some(" Das Erste ".to_string()), + logo: Some("http://x/logo.png".to_string()), + epg_channel_id: Some(" ".to_string()), + playlist_group: None, + }; + set_meta(&mut section, &auto("ard"), patch); + let entry = auto_entry(§ion, "ard").expect("override"); + assert_eq!(entry.name.as_deref(), Some("Das Erste")); + assert_eq!(entry.logo.as_deref(), Some("http://x/logo.png")); + assert_eq!(entry.epg_channel_id, None); + + set_meta(&mut section, &auto("ard"), GroupMetaPatch::default()); + assert!(section.is_empty()); +} + +#[test] +fn set_meta_on_manual_group_keeps_name_when_blank() { + let mut section = section(); + section.groups.push(manual("sport", &[])); + let patch = GroupMetaPatch { + name: Some(" ".to_string()), + playlist_group: Some("Sport".to_string()), + ..Default::default() + }; + set_meta(&mut section, &manual_ref("sport"), patch); + assert_eq!(section.groups[0].name, "Group sport"); + assert_eq!(section.groups[0].playlist_group.as_deref(), Some("Sport")); + + set_meta( + &mut section, + &manual_ref("sport"), + GroupMetaPatch { name: Some("Sports".to_string()), ..Default::default() }, + ); + assert_eq!(section.groups[0].name, "Sports"); + assert_eq!(section.groups[0].playlist_group, None); +} + +#[test] +fn set_policy_on_auto_and_manual_groups() { + let mut section = section(); + section.groups.push(manual("sport", &[])); + set_policy(&mut section, &auto("zdf"), Some(FailoverGroupPolicy::Priority)); + set_policy(&mut section, &manual_ref("sport"), Some(FailoverGroupPolicy::Priority)); + assert_eq!(auto_entry(§ion, "zdf").expect("override").policy, Some(FailoverGroupPolicy::Priority)); + assert_eq!(section.groups[0].policy, Some(FailoverGroupPolicy::Priority)); + + set_policy(&mut section, &auto("zdf"), None); + assert!(section.auto_overrides.is_empty()); +} + +#[test] +fn dissolve_auto_sets_disabled() { + let mut section = section(); + dissolve(&mut section, &auto("zdf")); + dissolve(&mut section, &auto("zdf")); + assert_eq!(section.auto_overrides.len(), 1); + assert!(auto_entry(§ion, "zdf").expect("override").disabled); +} + +#[test] +fn dissolve_manual_deletes_group() { + let mut section = section(); + section.groups.push(manual("sport", &[member("p", "1")])); + section.groups.push(manual("news", &[])); + dissolve(&mut section, &manual_ref("sport")); + assert_eq!(section.groups.iter().map(|group| group.id.as_str()).collect::>(), vec!["news"]); +} + +#[test] +fn reset_auto_removes_entry() { + let mut section = section(); + dissolve(&mut section, &auto("zdf")); + add_member(&mut section, &auto("ard"), &member("p", "1")); + reset_auto(&mut section, "zdf"); + reset_auto(&mut section, "zdf"); + assert!(auto_entry(§ion, "zdf").is_none()); + assert!(auto_entry(§ion, "ard").is_some()); +} + +#[test] +fn reset_auto_op_removes_the_override_of_a_stale_key() { + let mut section = section(); + add_member(&mut section, &auto("gone"), &member("p", "1")); + let created = DraftOp::ResetAuto { auto_key: "gone".to_string() }.apply(&mut section); + assert_eq!(created, None); + assert!(section.is_empty()); +} + +#[test] +fn add_members_adds_moves_and_skips_in_one_operation() { + let (a, b, c) = (member("p", "1"), member("p", "2"), member("p", "3")); + let mut section = section(); + section.groups.push(manual("sport", std::slice::from_ref(&b))); + section.groups.push(manual("news", std::slice::from_ref(&c))); + let op = DraftOp::AddMembers { + group: manual_ref("sport"), + members: vec![(a.clone(), None), (b.clone(), Some(manual_ref("sport"))), (c.clone(), Some(manual_ref("news")))], + }; + assert_eq!(op.apply(&mut section), None); + assert_eq!(section.groups[0].members, vec![b, a, c]); + assert!(section.groups[1].members.is_empty()); +} + +#[test] +fn add_members_moves_out_of_auto_group_and_includes_excluded_channels() { + let (a, b) = (member("p", "1"), member("p", "2")); + let mut section = section(); + section.groups.push(manual("sport", &[])); + section.excluded.push(b.clone()); + add_members(&mut section, &manual_ref("sport"), &[(a.clone(), Some(auto("zdf"))), (b.clone(), None)]); + assert_eq!(auto_entry(§ion, "zdf").expect("override").remove, vec![a.clone()]); + assert_eq!(section.groups[0].members, vec![a, b]); + assert!(section.excluded.is_empty()); +} + +#[test] +fn add_members_of_nothing_changes_nothing() { + let mut section = section(); + section.groups.push(manual("sport", &[member("p", "1")])); + let before = section.clone(); + add_members(&mut section, &manual_ref("sport"), &[]); + add_members(&mut section, &manual_ref("sport"), &[(member("p", "1"), Some(manual_ref("sport")))]); + assert_eq!(section, before); +} + +#[test] +fn create_manual_group_id_matches_format() { + let mut section = section(); + let id = create_manual_group(&mut section, " Sport HD!! ", &[member("p", "1")], "ab12"); + assert_eq!(id, "sport-hd-ab12"); + assert_eq!(section.groups[0].id, id); + assert_eq!(section.groups[0].name, "Sport HD!!"); + assert_eq!(section.groups[0].members, vec![member("p", "1")]); +} + +#[test] +fn create_manual_group_id_slug_is_limited_and_never_empty() { + let mut section = section(); + let long = create_manual_group(&mut section, &"ä-x".repeat(60), &[], "0000"); + assert!(long.len() <= 64, "{long}"); + assert!(long.ends_with("-0000")); + let empty = create_manual_group(&mut section, "!!!", &[], "0000"); + assert_eq!(empty, "group-0000"); + let exact = create_manual_group(&mut section, &"a".repeat(80), &[], "0000"); + assert_eq!(exact, format!("{}-0000", "a".repeat(59))); +} + +#[test] +fn create_manual_group_with_random_suffix_is_valid() { + let suffix = random_id_suffix(); + assert_eq!(suffix.len(), 4); + assert!(suffix.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())); +} + +#[test] +fn create_manual_group_never_reuses_an_existing_id() { + let mut section = section(); + let first = create_manual_group(&mut section, "Sport", &[], "ab12"); + let second = create_manual_group(&mut section, "Sport", &[], "ab12"); + assert_ne!(first, second); + assert!(second.starts_with("sport-")); +} + +#[test] +fn create_manual_group_releases_excluded_members() { + let a = member("p", "1"); + let mut section = section(); + section.excluded.push(a.clone()); + create_manual_group(&mut section, "Sport", &[a], "ab12"); + assert!(section.excluded.is_empty()); +} + +#[test] +fn convert_stale_to_manual_moves_refs_and_metadata() { + let (a, b, c) = (member("p", "1"), member("p", "2"), member("p", "3")); + let mut section = section(); + section.auto_overrides.push(AutoGroupOverrideDto { + auto_key: "old-zdf".to_string(), + name: Some("ZDF".to_string()), + logo: Some("https://x/zdf.png".to_string()), + policy: Some(FailoverGroupPolicy::Priority), + order: vec![a.clone(), b.clone()], + add: vec![b.clone(), c.clone()], + remove: vec![a.clone()], + ..Default::default() + }); + let id = convert_stale_to_manual(&mut section, "old-zdf", "ab12").expect("id"); + assert_eq!(id, "zdf-ab12"); + assert!(section.auto_overrides.is_empty()); + let group = §ion.groups[0]; + assert_eq!(group.name, "ZDF"); + assert_eq!(group.members, vec![b, c]); + assert_eq!(group.logo.as_deref(), Some("https://x/zdf.png")); + assert_eq!(group.policy, Some(FailoverGroupPolicy::Priority)); +} + +#[test] +fn convert_stale_to_manual_without_override_returns_none() { + let mut section = section(); + assert_eq!(convert_stale_to_manual(&mut section, "gone", "ab12"), None); + assert!(section.is_empty()); +} + +#[test] +fn convert_stale_to_manual_falls_back_to_auto_key_as_name() { + let mut section = section(); + section.auto_overrides.push(AutoGroupOverrideDto { + auto_key: "old key".to_string(), + add: vec![member("p", "1")], + ..Default::default() + }); + let id = convert_stale_to_manual(&mut section, "old key", "ab12").expect("id"); + assert_eq!(id, "old-key-ab12"); + assert_eq!(section.groups[0].name, "old key"); +} + +#[test] +fn new_group_from_selection_takes_members_out_of_their_groups() { + let (a, b) = (member("p", "1"), member("p", "2")); + let mut section = section(); + section.groups.push(manual("sport", std::slice::from_ref(&b))); + let op = DraftOp::NewGroupFromSelection { + name: "Mixed".to_string(), + members: vec![(a.clone(), Some(auto("zdf"))), (b.clone(), Some(manual_ref("sport"))), (member("p", "3"), None)], + id_suffix: "ab12".to_string(), + }; + let created = op.apply(&mut section); + assert_eq!(created, Some(manual_ref("mixed-ab12"))); + assert_eq!(auto_entry(§ion, "zdf").expect("override").remove, vec![a.clone()]); + assert!(section.groups[0].members.is_empty()); + assert_eq!(section.groups[1].members, vec![a, b, member("p", "3")]); +} + +#[test] +fn draft_op_apply_dispatches_to_operations() { + let a = member("p", "1"); + let mut section = section(); + let created = DraftOp::AddMembers { group: auto("zdf"), members: vec![(a.clone(), None)] }.apply(&mut section); + assert_eq!(created, None); + assert_eq!(auto_entry(§ion, "zdf").expect("override").add, vec![a]); +} + +#[test] +fn forget_ref_drops_the_reference_from_every_list_without_recording_a_removal() { + let (a, b) = (member("p", "1"), member("p", "2")); + let mut section = section(); + section.auto_overrides.push(AutoGroupOverrideDto { + auto_key: "zdf".to_string(), + order: vec![a.clone(), b.clone()], + add: vec![a.clone()], + ..Default::default() + }); + forget_ref(&mut section, &auto("zdf"), &a); + let entry = auto_entry(§ion, "zdf").expect("override"); + assert_eq!(entry.order, vec![b.clone()]); + assert!(entry.add.is_empty()); + assert!(entry.remove.is_empty()); + + forget_ref(&mut section, &auto("zdf"), &b); + assert!(auto_entry(§ion, "zdf").is_none(), "an empty override is removed again"); +} + +#[test] +fn forget_ref_on_manual_group_and_unknown_entries_is_harmless() { + let (a, b) = (member("p", "1"), member("p", "2")); + let mut section = section(); + section.groups.push(manual("sport", &[a.clone(), b.clone()])); + forget_ref(&mut section, &manual_ref("sport"), &a); + assert_eq!(section.groups[0].members, vec![b.clone()]); + + forget_ref(&mut section, &auto("missing"), &b); + forget_ref(&mut section, &manual_ref("missing"), &b); + assert!(section.auto_overrides.is_empty(), "no override is created for an unknown key"); + assert_eq!(section.groups[0].members, vec![b]); +} diff --git a/frontend/src/app/components/failover_groups/failover_groups_view.rs b/frontend/src/app/components/failover_groups/failover_groups_view.rs new file mode 100644 index 000000000..8f3ce2786 --- /dev/null +++ b/frontend/src/app/components/failover_groups/failover_groups_view.rs @@ -0,0 +1,982 @@ +//! The "Failover groups" page: target selection, status line and the editor of one target. + +use super::{ + candidate_panel::{membership_of, CandidatePanel}, + diagnostics_panel::{problem_total, DiagnosticsPanel}, + dialogs::NameDialog, + draft::{random_id_suffix, DraftOp, GroupRef}, + group_detail::GroupDetail, + group_list::{build_rows, find_group, GroupFilter, GroupList, RowState}, + navigation::{preselected_target, take_unapplied, FailoverGroupsPreselect, NumberedChannel, NumberedPreselect}, + state::{FailoverGroupsEditorAction, FailoverGroupsEditorState}, +}; +use crate::{ + app::{ + components::{ + select::Select, selection_first, Chip, ConfirmDialog, DropDownOption, FieldLabel, LoadingIndicator, + NoContent, TextButton, + }, + context::FailoverGroupsNavigation, + }, + error::Error, + hooks::{use_service_context, use_unsaved_changes_guard}, + i18n::use_translation, + model::{DialogResult, EventMessage}, + services::{FailoverService, SaveOutcome}, + utils::{get_session_storage_item, in_body, set_session_storage_item}, +}; +use gloo_timers::callback::Timeout; +use shared::model::{ + permission::Permission, FailoverGroupHealthDto, FailoverGroupListDto, FailoverGroupTargetStatusDto, + FailoverGroupTargetsDto, FailoverTargetState, OverrideValidationError, PlaylistUpdateRunStateEvent, + PlaylistUpdateState, +}; +use std::rc::Rc; +use wasm_bindgen_futures::spawn_local; +use yew::prelude::*; + +const PREVIEW_DEBOUNCE_MS: u32 = 300; +const SHARED_FILE_NOTICE_KEY: &str = "tp_failover_shared_file_notice"; + +/// Where the target stands relative to the failover files and the last playlist update. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SaveStatus { + FileError, + Unsaved, + ApplyPending, + Applied, +} + +pub fn save_status(file_error: bool, dirty: bool, etag: Option<&str>, applied_etag: Option<&str>) -> SaveStatus { + if file_error { + SaveStatus::FileError + } else if dirty { + SaveStatus::Unsaved + } else if etag != applied_etag { + SaveStatus::ApplyPending + } else { + SaveStatus::Applied + } +} + +pub const fn status_label_key(status: SaveStatus) -> &'static str { + match status { + SaveStatus::FileError => "LABEL.FAILOVER_GROUPS_STATUS_FILE_ERROR", + SaveStatus::Unsaved => "LABEL.FAILOVER_GROUPS_STATUS_UNSAVED", + SaveStatus::ApplyPending => "LABEL.FAILOVER_GROUPS_STATUS_APPLY_PENDING", + SaveStatus::Applied => "LABEL.FAILOVER_GROUPS_STATUS_APPLIED", + } +} + +pub const fn status_chip_class(status: SaveStatus) -> &'static str { + match status { + SaveStatus::FileError => "inactive", + SaveStatus::Unsaved | SaveStatus::ApplyPending => "tp__failover-groups__status--pending", + SaveStatus::Applied => "active", + } +} + +/// Whether the file that holds the section of `target` also holds sections of other targets. +pub fn file_is_shared(targets: &[FailoverGroupTargetStatusDto], target: &str) -> bool { + let Some(file) = targets.iter().find(|status| status.name == target).and_then(|status| status.file.as_deref()) + else { + return false; + }; + targets.iter().filter(|status| status.file.as_deref() == Some(file)).count() > 1 +} + +/// Names of the targets that can be edited (configured, failover groups enabled). +pub fn editable_targets(targets: &FailoverGroupTargetsDto) -> Vec { + targets + .targets + .iter() + .filter(|status| status.state == FailoverTargetState::Enabled) + .map(|status| status.name.clone()) + .collect() +} + +/// The override sections in `state`: of unknown targets (`Orphan`) or of targets without failover groups (`Disabled`). +pub fn sections_in_state( + targets: &FailoverGroupTargetsDto, + state: FailoverTargetState, +) -> Vec { + targets.targets.iter().filter(|status| status.state == state).cloned().collect() +} + +/// Whether the playlist entry of a group still shows the name and logo of its previous first member. +pub fn has_display_stale(preview: Option<&FailoverGroupListDto>) -> bool { + preview.is_some_and(|list| !list.display_stale.is_empty()) +} + +/// Whether a finished playlist update run can have published new channels or groups of `target`: the run did not +/// fail and processed the target. +pub fn update_publishes_changes(event: &PlaylistUpdateRunStateEvent, target: &str) -> bool { + !matches!(event.state, PlaylistUpdateState::Failure) && event.processed_target(target) +} + +/// A counter whose changes re-run the effects that depend on it. +#[derive(Debug, Default, PartialEq, Eq)] +struct Tick(u32); + +impl Reducible for Tick { + type Action = (); + + fn reduce(self: Rc, (): ()) -> Rc { Rc::new(Tick(self.0.wrapping_add(1))) } +} + +#[derive(Debug, Clone, PartialEq)] +enum PendingConfirm { + Discard, + /// The save rewrites a file that other targets share; the flag asks to apply afterwards. + SharedFile(bool), +} + +#[derive(Properties, PartialEq)] +struct TargetEditorProps { + target: String, + targets: Rc, + /// The user may not write, or the failover files are broken. + read_only: bool, + on_dirty: Callback, + on_targets_changed: Callback<()>, + on_assign: Callback<(String, String, String)>, + on_delete_orphan: Callback<(String, String)>, + /// A deep link request for this target; applied once when the section is loaded. + preselect: Option>, + on_preselect_applied: Callback, +} + +#[component] +fn TargetEditor(props: &TargetEditorProps) -> Html { + let translate = use_translation(); + let services = use_service_context(); + let target = props.target.clone(); + let editor = use_reducer({ + let target = target.clone(); + move || FailoverGroupsEditorState::new(&target) + }); + let load_tick = use_reducer(Tick::default); + let refresh = use_reducer(Tick::default); + let loaded = use_state(|| false); + let no_catalog = use_state(|| false); + let filter = use_state(GroupFilter::default); + let search = use_state(String::new); + let health = use_state(|| None::>); + let findings = use_state(Vec::::new); + let save_guard = use_mut_ref(|| false); + let locked = use_state(|| None::); + let dismissed_conflict = use_state(|| None::>); + let confirm = use_state(|| None::); + let show_new_group = use_state(|| false); + let candidate_focus = use_state(|| None::>); + let applied_preselect = use_mut_ref(|| 0_u32); + let latest_seq = use_mut_ref(|| 0_u64); + *latest_seq.borrow_mut() = editor.preview_seq; + + let status_entry = props.targets.targets.iter().find(|status| status.name == target).cloned(); + let read_only = props.read_only || locked.is_some(); + let stale = editor.preview_stale(); + + use_unsaved_changes_guard(editor.dirty); + { + let on_dirty = props.on_dirty.clone(); + use_effect_with(editor.dirty, move |dirty| { + on_dirty.emit(*dirty); + || () + }); + } + + // Load the section of the target. + { + let dispatcher = editor.dispatcher(); + let loaded = loaded.clone(); + let services = services.clone(); + use_effect_with((target.clone(), load_tick.0), move |(target, _)| { + let target = target.clone(); + spawn_local(async move { + match FailoverService::fetch_overrides(&target).await { + Ok(response) => { + dispatcher.dispatch(FailoverGroupsEditorAction::Loaded(response)); + loaded.set(true); + } + Err(err) => services.toastr.error(err.to_string()), + } + }); + || () + }); + } + + // Apply a deep link request once the section is there. + { + let dispatcher = editor.dispatcher(); + let search = search.clone(); + let filter = filter.clone(); + let candidate_focus = candidate_focus.clone(); + let applied_preselect = applied_preselect.clone(); + let on_applied = props.on_preselect_applied.clone(); + use_effect_with((props.preselect.clone(), *loaded), move |(preselect, loaded)| { + if *loaded { + let numbered = preselect.as_deref(); + if let Some(request) = take_unapplied(&mut applied_preselect.borrow_mut(), numbered) { + if request.group.is_some() { + dispatcher.dispatch(FailoverGroupsEditorAction::Select(request.group.clone())); + } + if request.group.is_some() || request.group_search.is_some() { + filter.set(GroupFilter::default()); + search.set(request.group_search.clone().unwrap_or_default()); + } + if let Some(channel) = request.channel.clone() { + let seq = numbered.map_or(0, |numbered| numbered.seq); + candidate_focus.set(Some(Rc::new(NumberedChannel { seq, channel }))); + } + if let Some(numbered) = numbered { + on_applied.emit(numbered.seq); + } + } + } + || () + }); + } + + // Resolve the groups of the draft (debounced while editing). + { + let dispatcher = editor.dispatcher(); + let draft = editor.draft.clone(); + let dirty = editor.dirty; + let persisted = editor.server.as_ref().map(|server| server.etag.clone()); + let applied = status_entry.as_ref().and_then(|status| status.applied_etag.clone()); + let no_catalog = no_catalog.clone(); + let latest_seq = latest_seq.clone(); + let services = services.clone(); + let ready = *loaded; + use_effect_with((target.clone(), editor.preview_seq, ready, refresh.0), move |(target, seq, ready, _)| { + let seq = *seq; + let timer = ready.then(|| { + let target = target.clone(); + Timeout::new(if dirty { PREVIEW_DEBOUNCE_MS } else { 0 }, move || { + spawn_local(async move { + // Without edits the persisted groups are shown when they match the stored section. + let outcome = if !dirty && persisted.as_ref().is_some_and(|etag| *etag == applied) { + FailoverService::fetch_groups(&target).await + } else { + FailoverService::preview(&target, &draft).await + }; + if *latest_seq.borrow() != seq { + return; + } + match outcome { + Ok(list) => { + no_catalog.set(false); + dispatcher.dispatch(FailoverGroupsEditorAction::PreviewLoaded(seq, list)); + } + Err(Error::Conflict(_)) => { + no_catalog.set(true); + dispatcher.dispatch(FailoverGroupsEditorAction::PreviewFailed(seq)); + } + Err(err) => { + services.toastr.error(err.to_string()); + dispatcher.dispatch(FailoverGroupsEditorAction::PreviewFailed(seq)); + } + } + }); + }) + }); + move || drop(timer) + }); + } + + // Changes made elsewhere: another tab, a hand edit of the file, a finished playlist update. + { + let dispatcher = editor.dispatcher(); + let refresh = refresh.dispatcher(); + let latest_seq = latest_seq.clone(); + let on_targets_changed = props.on_targets_changed.clone(); + let services = services.clone(); + use_effect_with(target.clone(), move |target| { + let target = target.clone(); + let subscription = services.event.subscribe(move |message| match message { + EventMessage::FailoverOverridesChanged(changed) if changed.contains(&target) => { + let dispatcher = dispatcher.clone(); + let target = target.clone(); + spawn_local(async move { + if let Ok(response) = FailoverService::fetch_overrides(&target).await { + dispatcher.dispatch(FailoverGroupsEditorAction::ExternalChange(response)); + } + }); + on_targets_changed.emit(()); + } + EventMessage::PlaylistUpdate(event) if update_publishes_changes(&event, &target) => { + dispatcher.dispatch(FailoverGroupsEditorAction::PreviewRequested(*latest_seq.borrow() + 1)); + refresh.dispatch(()); + on_targets_changed.emit(()); + } + _ => {} + }); + move || services.event.unsubscribe(subscription) + }); + } + + let preview = editor.preview.as_ref(); + let rows = Rc::new(build_rows(preview, &editor.draft)); + let selected = editor.selected.clone(); + let selected_row = selected.as_ref().and_then(|group| rows.iter().find(|row| row.group == *group)).cloned(); + let selected_dto = selected.as_ref().and_then(|group| preview.and_then(|list| find_group(list, group))).cloned(); + let group_vid = selected_dto.as_ref().and_then(|dto| dto.group_vid); + + // Health of the selected group only; it lives in memory of the server, no provider is asked. + { + let health = health.clone(); + use_effect_with((target.clone(), group_vid, refresh.0), move |(target, vid, _)| { + health.set(None); + if let Some(vid) = *vid { + let target = target.clone(); + let health = health.clone(); + spawn_local(async move { + if let Ok(found) = FailoverService::fetch_health(&target, vid).await { + health.set(Some(Rc::new(found))); + } + }); + } + || () + }); + } + + let membership = use_memo((editor.settled_seq, editor.preview_seq), { + let editor = editor.clone(); + move |_| membership_of(editor.preview.as_ref(), &editor.draft) + }); + + let server_etag = editor.server.as_ref().and_then(|server| server.etag.clone()); + let applied_etag = + preview.and_then(|list| list.applied_etag.clone()).or_else(|| status_entry.as_ref()?.applied_etag.clone()); + let status = save_status(false, editor.dirty, server_etag.as_deref(), applied_etag.as_deref()); + let file = + editor.server.as_ref().and_then(|server| server.file.clone()).or_else(|| status_entry.as_ref()?.file.clone()); + let shared_file = file_is_shared(&props.targets.targets, &target); + + let all_findings: Vec = findings + .iter() + .cloned() + .chain(status_entry.iter().flat_map(|status| status.findings.iter().cloned())) + .fold(Vec::new(), |mut unique, finding| { + if !unique.contains(&finding) { + unique.push(finding); + } + unique + }); + let orphans = sections_in_state(&props.targets, FailoverTargetState::Orphan); + let disabled = sections_in_state(&props.targets, FailoverTargetState::Disabled); + let diagnostics = preview.map(|list| list.diagnostics.clone()).unwrap_or_default(); + let problems = problem_total( + &diagnostics, + &all_findings, + rows.iter().filter(|row| row.problem && row.state == RowState::Active).count(), + orphans.len() + disabled.len(), + ); + + let on_op = { + let dispatcher = editor.dispatcher(); + Callback::from(move |op: DraftOp| dispatcher.dispatch(FailoverGroupsEditorAction::Edit(op))) + }; + + let run_save = { + let dispatcher = editor.dispatcher(); + let draft = editor.draft.clone(); + let target = target.clone(); + let server_etag = server_etag.clone(); + let services = services.clone(); + let translate = translate.clone(); + let save_guard = save_guard.clone(); + let locked = locked.clone(); + let findings = findings.clone(); + let filter = filter.clone(); + let dismissed = dismissed_conflict.clone(); + let latest_seq = latest_seq.clone(); + let on_targets_changed = props.on_targets_changed.clone(); + Callback::from(move |apply: bool| { + // The guard flips synchronously, so a second click before the next render cannot send a second request. + if std::mem::replace(&mut *save_guard.borrow_mut(), true) { + return; + } + dispatcher.dispatch(FailoverGroupsEditorAction::SaveStarted); + let (dispatcher, draft, target, server_etag) = + (dispatcher.clone(), draft.clone(), target.clone(), server_etag.clone()); + let (services, translate, save_guard, locked, findings, filter, dismissed, latest_seq, on_targets_changed) = ( + services.clone(), + translate.clone(), + save_guard.clone(), + locked.clone(), + findings.clone(), + filter.clone(), + dismissed.clone(), + latest_seq.clone(), + on_targets_changed.clone(), + ); + spawn_local(async move { + match FailoverService::save(&target, server_etag, draft.clone()).await { + Ok(SaveOutcome::Saved(result)) => { + let hot_applied = result.hot_applied; + dispatcher.dispatch(FailoverGroupsEditorAction::Saved(result, draft)); + dispatcher.dispatch(FailoverGroupsEditorAction::PreviewRequested(*latest_seq.borrow() + 1)); + findings.set(Vec::new()); + locked.set(None); + on_targets_changed.emit(()); + if hot_applied { + services.toastr.success(translate.t("MESSAGES.FAILOVER_GROUPS_SAVED_APPLIED")); + } else if apply { + match FailoverService::apply(&target).await { + Ok(_) => services.toastr.success(translate.t("MESSAGES.FAILOVER_GROUPS_APPLY_QUEUED")), + Err(Error::Conflict(_)) => { + services.toastr.warning(translate.t("MESSAGES.FAILOVER_GROUPS_APPLY_BUSY")); + } + Err(err) => services.toastr.error(err.to_string()), + } + } else { + services.toastr.success(translate.t("MESSAGES.FAILOVER_GROUPS_SAVED_PENDING")); + } + } + Ok(SaveOutcome::Conflict(current)) => { + dismissed.set(None); + dispatcher.dispatch(FailoverGroupsEditorAction::Conflict(current)); + } + Ok(SaveOutcome::Invalid(found)) => { + services.toastr.error(translate.t("MESSAGES.FAILOVER_GROUPS_INVALID")); + findings.set(found); + filter.set(GroupFilter::Problems); + } + Ok(SaveOutcome::Locked(message)) => locked.set(Some(message)), + Err(err) => services.toastr.error(err.to_string()), + } + *save_guard.borrow_mut() = false; + dispatcher.dispatch(FailoverGroupsEditorAction::SaveFinished); + }); + }) + }; + + let on_save = |apply: bool| { + let run_save = run_save.clone(); + let confirm = confirm.clone(); + let ask = shared_file && get_session_storage_item(SHARED_FILE_NOTICE_KEY).is_none(); + Callback::from(move |_: String| { + if ask { + confirm.set(Some(PendingConfirm::SharedFile(apply))); + } else { + run_save.emit(apply); + } + }) + }; + + let on_apply = { + let target = target.clone(); + let services = services.clone(); + let translate = translate.clone(); + Callback::from(move |_: String| { + let (target, services, translate) = (target.clone(), services.clone(), translate.clone()); + spawn_local(async move { + match FailoverService::apply(&target).await { + Ok(_) => services.toastr.success(translate.t("MESSAGES.FAILOVER_GROUPS_APPLY_QUEUED")), + Err(Error::Conflict(_)) => { + services.toastr.warning(translate.t("MESSAGES.FAILOVER_GROUPS_APPLY_BUSY")); + } + Err(err) => services.toastr.error(err.to_string()), + } + }); + }) + }; + + let on_confirm = { + let confirm = confirm.clone(); + let run_save = run_save.clone(); + let dispatcher = editor.dispatcher(); + let findings = findings.clone(); + Callback::from(move |result: DialogResult| { + let pending = (*confirm).clone(); + confirm.set(None); + if result != DialogResult::Ok { + return; + } + match pending { + Some(PendingConfirm::Discard) => { + dispatcher.dispatch(FailoverGroupsEditorAction::Discard); + findings.set(Vec::new()); + } + Some(PendingConfirm::SharedFile(apply)) => { + set_session_storage_item(SHARED_FILE_NOTICE_KEY, "1"); + run_save.emit(apply); + } + None => {} + } + }) + }; + + let conflict_open = editor.conflict.is_some() && *dismissed_conflict != editor.conflict; + let on_conflict = { + let dismissed = dismissed_conflict.clone(); + let conflict = editor.conflict.clone(); + let load_tick = load_tick.dispatcher(); + Callback::from(move |result: DialogResult| match result { + DialogResult::Ok => load_tick.dispatch(()), + DialogResult::Cancel => dismissed.set(conflict.clone()), + }) + }; + + let on_reload = { + let load_tick = load_tick.dispatcher(); + Callback::from(move |_: String| load_tick.dispatch(())) + }; + let on_refresh = { + let dispatcher = editor.dispatcher(); + let refresh = refresh.dispatcher(); + let latest_seq = latest_seq.clone(); + Callback::from(move |_: String| { + dispatcher.dispatch(FailoverGroupsEditorAction::PreviewRequested(*latest_seq.borrow() + 1)); + refresh.dispatch(()); + }) + }; + let on_undo = { + let dispatcher = editor.dispatcher(); + Callback::from(move |_: String| dispatcher.dispatch(FailoverGroupsEditorAction::Undo)) + }; + let on_discard = { + let confirm = confirm.clone(); + Callback::from(move |_: String| confirm.set(Some(PendingConfirm::Discard))) + }; + let on_select = { + let dispatcher = editor.dispatcher(); + Callback::from(move |group: GroupRef| dispatcher.dispatch(FailoverGroupsEditorAction::Select(Some(group)))) + }; + let on_back = { + let dispatcher = editor.dispatcher(); + Callback::from(move |()| dispatcher.dispatch(FailoverGroupsEditorAction::Select(None))) + }; + let on_filter = { + let filter = filter.clone(); + Callback::from(move |next: GroupFilter| filter.set(next)) + }; + let on_search = { + let search = search.clone(); + Callback::from(move |text: String| search.set(text)) + }; + let on_new_group = { + let show = show_new_group.clone(); + Callback::from(move |_: String| show.set(true)) + }; + let on_new_group_submit = { + let show = show_new_group.clone(); + let on_op = on_op.clone(); + Callback::from(move |name: String| { + show.set(false); + on_op.emit(DraftOp::NewGroupFromSelection { name, members: Vec::new(), id_suffix: random_id_suffix() }); + }) + }; + let on_new_group_cancel = { + let show = show_new_group.clone(); + Callback::from(move |()| show.set(false)) + }; + + let can_edit = !read_only; + let apply_class = if status == SaveStatus::ApplyPending { "primary" } else { "tertiary" }; + let sent_group_name = + selected_dto.as_ref().map(|dto| dto.name.clone()).or_else(|| selected_row.as_ref().map(|row| row.name.clone())); + + html! { +
+
+ + { file.clone().unwrap_or_else(|| translate.t("MESSAGES.FAILOVER_GROUPS_NEW_FILE")) } + + + if status == SaveStatus::ApplyPending && can_edit { + { translate.t("MESSAGES.FAILOVER_GROUPS_APPLY_HINT") } + } + if stale && *loaded { + { translate.t("MESSAGES.FAILOVER_GROUPS_UPDATING") } + } + if has_display_stale(preview) { + + { translate.t("MESSAGES.FAILOVER_GROUPS_DISPLAY_STALE") } + + } +
+
+ if can_edit { + + } + + if can_edit { + + + + + + } +
+ if let Some(message) = locked.as_ref() { + + } + if editor.conflict.is_some() && !conflict_open { + + } + if *no_catalog { +

{ translate.t("MESSAGES.FAILOVER_GROUPS_NO_CATALOG") }

+ } + if !*loaded { + + } +
+ +
+ if *filter == GroupFilter::Problems { + + } + if let Some(group) = selected.clone() { + + } else if *filter != GroupFilter::Problems { +

{ translate.t("MESSAGES.FAILOVER_GROUPS_SELECT_GROUP") }

+ } +
+
+ + if conflict_open { + { in_body(html! { + + }) } + } + if let Some(pending) = (*confirm).clone() { + { in_body(html! { + translate.t("MESSAGES.FAILOVER_GROUPS_CONFIRM_DISCARD"), + PendingConfirm::SharedFile(_) => translate.t("MESSAGES.FAILOVER_GROUPS_SHARED_FILE_NOTICE") + .replace("{file}", file.as_deref().unwrap_or_default()), + }} + ok_caption={match pending { + PendingConfirm::Discard => "LABEL.FAILOVER_GROUPS_DISCARD", + PendingConfirm::SharedFile(_) => "LABEL.SAVE", + }} + cancel_caption="LABEL.CANCEL" on_confirm={on_confirm}/> + }) } + } + if *show_new_group { + + } +
+ } +} + +#[component] +pub fn FailoverGroupsView() -> Html { + let services = use_service_context(); + let translate = use_translation(); + let can_write = services.auth.has_permission(Permission::PlaylistWrite); + let targets = use_state(|| None::>); + let tick = use_reducer(Tick::default); + let selected = use_state(|| None::); + let dirty = use_state(|| false); + let switch_to = use_state(|| None::); + let delete_orphan = use_state(|| None::<(String, String)>); + let navigation = use_context::(); + let pending_request = navigation.as_ref().and_then(|navigation| (*navigation.pending).clone()); + let active_preselect = use_state(|| None::>); + let deferred_preselect = use_state(|| None::>); + let preselect_seq = use_mut_ref(|| 0_u32); + + { + let targets = targets.clone(); + let services = services.clone(); + use_effect_with(tick.0, move |_| { + spawn_local(async move { + match FailoverService::fetch_targets().await { + Ok(found) => targets.set(Some(Rc::new(found))), + Err(err) => services.toastr.error(err.to_string()), + } + }); + || () + }); + } + { + let tick = tick.dispatcher(); + let services = services.clone(); + use_effect_with((), move |()| { + let subscription = services.event.subscribe(move |message| { + if matches!(message, EventMessage::FailoverOverridesChanged(_)) { + tick.dispatch(()); + } + }); + move || services.event.unsubscribe(subscription) + }); + } + + let names = targets.as_deref().map(editable_targets).unwrap_or_default(); + let current = selected.as_ref().filter(|name| names.contains(name)).cloned().or_else(|| names.first().cloned()); + + let on_targets_changed = { + let tick = tick.dispatcher(); + Callback::from(move |()| tick.dispatch(())) + }; + let activate_preselect = { + let active_preselect = active_preselect.clone(); + let preselect_seq = preselect_seq.clone(); + Callback::from(move |request: Rc| { + let seq = { + let mut counter = preselect_seq.borrow_mut(); + *counter += 1; + *counter + }; + active_preselect.set(Some(Rc::new(NumberedPreselect { seq, preselect: (*request).clone() }))); + }) + }; + // A deep link request is taken over once the targets are known; the sender's slot is emptied right away. + { + let navigation = navigation.clone(); + let services = services.clone(); + let translate = translate.clone(); + let selected = selected.clone(); + let switch_to = switch_to.clone(); + let deferred_preselect = deferred_preselect.clone(); + let activate_preselect = activate_preselect.clone(); + let dirty = *dirty; + let current = current.clone(); + let names = names.clone(); + let loaded = targets.is_some(); + use_effect_with((pending_request, loaded), move |(pending, loaded)| { + if let (Some(request), true, Some(navigation)) = (pending, *loaded, navigation) { + navigation.pending.set(None); + match preselected_target(request, &names) { + None => services.toastr.warning(translate.t("MESSAGES.FAILOVER_GROUPS_TARGET_UNAVAILABLE")), + Some(target) if dirty && current.as_deref() != Some(target) => { + deferred_preselect.set(Some(request.clone())); + switch_to.set(Some(target.to_string())); + } + Some(target) => { + selected.set(Some(target.to_string())); + activate_preselect.emit(request.clone()); + } + } + } + || () + }); + } + let on_preselect_applied = { + let active_preselect = active_preselect.clone(); + Callback::from(move |seq: u32| { + if active_preselect.as_ref().is_some_and(|active| active.seq == seq) { + active_preselect.set(None); + } + }) + }; + let on_dirty = { + let dirty = dirty.clone(); + Callback::from(move |value: bool| dirty.set(value)) + }; + let on_target_select = { + let selected = selected.clone(); + let switch_to = switch_to.clone(); + let dirty = *dirty; + let current = current.clone(); + Callback::from(move |(_, selection): (String, crate::app::components::DropDownSelection)| { + let Some(name) = selection_first(&selection).map(str::to_string) else { return }; + if current.as_deref() == Some(name.as_str()) { + return; + } + if dirty { + switch_to.set(Some(name)); + } else { + selected.set(Some(name)); + } + }) + }; + let on_switch_confirm = { + let selected = selected.clone(); + let switch_to = switch_to.clone(); + let deferred_preselect = deferred_preselect.clone(); + let activate_preselect = activate_preselect.clone(); + Callback::from(move |result: DialogResult| { + if result == DialogResult::Ok { + selected.set((*switch_to).clone()); + if let Some(request) = (*deferred_preselect).clone() { + activate_preselect.emit(request); + } + } + deferred_preselect.set(None); + switch_to.set(None); + }) + }; + + let report = { + let services = services.clone(); + let translate = translate.clone(); + let on_targets_changed = on_targets_changed.clone(); + Rc::new(move |outcome: Result| match outcome { + Ok(SaveOutcome::Saved(_)) => { + services.toastr.success(translate.t("MESSAGES.FAILOVER_GROUPS_SECTION_UPDATED")); + on_targets_changed.emit(()); + } + Ok(SaveOutcome::Conflict(_)) => { + services.toastr.warning(translate.t("MESSAGES.FAILOVER_GROUPS_CHANGED_ELSEWHERE")); + } + Ok(SaveOutcome::Invalid(found)) => { + services.toastr.error(found.iter().map(ToString::to_string).collect::>().join("; ")); + } + Ok(SaveOutcome::Locked(message)) => services.toastr.error(message), + Err(err) => services.toastr.error(err.to_string()), + }) + }; + let on_assign = { + let report = report.clone(); + Callback::from(move |(from, to, etag): (String, String, String)| { + let report = report.clone(); + spawn_local(async move { report(FailoverService::reassign(&from, &to, &etag).await) }); + }) + }; + let on_delete_orphan = { + let delete_orphan = delete_orphan.clone(); + Callback::from(move |request: (String, String)| delete_orphan.set(Some(request))) + }; + let on_delete_confirm = { + let delete_orphan = delete_orphan.clone(); + let report = report.clone(); + Callback::from(move |result: DialogResult| { + let request = (*delete_orphan).clone(); + delete_orphan.set(None); + if let (DialogResult::Ok, Some((name, etag))) = (result, request) { + let report = report.clone(); + spawn_local(async move { report(FailoverService::delete(&name, &etag).await) }); + } + }) + }; + + let load_error = targets.as_ref().and_then(|found| found.load_error.clone()); + let read_only = !can_write || load_error.is_some(); + let options = Rc::new( + names + .iter() + .map(|name| DropDownOption::new(name, html! { name.as_str() }, current.as_deref() == Some(name.as_str()))) + .collect::>(), + ); + + html! { +
+
+

{ translate.t("LABEL.FAILOVER_GROUPS") }

+ if !names.is_empty() { +
+ + +
+ } + + if !unresolved.is_empty() { +
+ { translate.t("LABEL.FAILOVER_GROUPS_UNRESOLVED") } + { for unresolved.iter().map(|member| { + let label = format!("{}/{}", member.input, member.stream_id); + let on_op = props.on_op.clone(); + let group = group.clone(); + let member = member.clone(); + html! { + + } + }) } +
+ } + if editable { + + } + } + if moving.is_some() { + >()} + on_pick={on_move_pick} + on_cancel={on_move_cancel}/> + } + if *confirm_delete { + { in_body(html! { + + }) } + } +
+ } +} diff --git a/frontend/src/app/components/failover_groups/group_list.rs b/frontend/src/app/components/failover_groups/group_list.rs new file mode 100644 index 000000000..62db30519 --- /dev/null +++ b/frontend/src/app/components/failover_groups/group_list.rs @@ -0,0 +1,271 @@ +//! Master list of the failover groups of one target: filter chips, search and one row per group. + +use super::draft::GroupRef; +use crate::{ + app::components::{input::Input, AppIcon, Chip, RadioButtonGroup}, + i18n::use_translation, +}; +use shared::model::{FailoverGroupDto, FailoverGroupListDto, FailoverGroupOrigin, TargetFailoverOverridesDto}; +use std::rc::Rc; +use yew::prelude::*; + +/// Whether a row stands for a working group. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RowState { + Active, + /// A manual group that resolved too few members to be materialized. + Inactive, + /// An automatic group dissolved through its override. + Dissolved, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct GroupRow { + pub group: GroupRef, + pub name: String, + pub origin: FailoverGroupOrigin, + pub member_count: usize, + pub state: RowState, + /// The group holds references that matched no channel. + pub problem: bool, + /// The playlist entry of the group still shows the name and logo of its previous first member. + pub display_stale: bool, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum GroupFilter { + #[default] + All, + Auto, + Edited, + Manual, + Dissolved, + Problems, +} + +impl GroupFilter { + pub const ALL: [Self; 6] = [Self::All, Self::Auto, Self::Edited, Self::Manual, Self::Dissolved, Self::Problems]; + + pub const fn id(self) -> &'static str { + match self { + Self::All => "all", + Self::Auto => "auto", + Self::Edited => "edited", + Self::Manual => "manual", + Self::Dissolved => "dissolved", + Self::Problems => "problems", + } + } + + pub fn from_id(id: &str) -> Option { Self::ALL.into_iter().find(|filter| filter.id() == id) } + + pub const fn label_key(self) -> &'static str { + match self { + Self::All => "LABEL.ALL", + Self::Auto => "LABEL.FAILOVER_GROUPS_ORIGIN_AUTO", + Self::Edited => "LABEL.FAILOVER_GROUPS_ORIGIN_EDITED", + Self::Manual => "LABEL.FAILOVER_GROUPS_ORIGIN_MANUAL", + Self::Dissolved => "LABEL.FAILOVER_GROUPS_DISSOLVED", + Self::Problems => "LABEL.FAILOVER_GROUPS_PROBLEMS", + } + } + + pub fn matches(self, row: &GroupRow) -> bool { + match self { + Self::All => row.state != RowState::Dissolved, + Self::Auto => row.state == RowState::Active && row.origin == FailoverGroupOrigin::Auto, + Self::Edited => row.state == RowState::Active && row.origin == FailoverGroupOrigin::AutoEdited, + Self::Manual => row.origin == FailoverGroupOrigin::Manual, + Self::Dissolved => row.state == RowState::Dissolved, + Self::Problems => row.problem || row.state == RowState::Inactive, + } + } +} + +/// The reference a group of the list is edited through: manual groups by id, all others by auto key. +pub fn group_ref_of(group: &FailoverGroupDto) -> GroupRef { + match group.origin { + FailoverGroupOrigin::Manual => GroupRef::Manual(group.key.clone()), + FailoverGroupOrigin::Auto | FailoverGroupOrigin::AutoEdited => GroupRef::Auto(group.key.clone()), + } +} + +/// The group of `list` that `group` points at. +pub fn find_group<'a>(list: &'a FailoverGroupListDto, group: &GroupRef) -> Option<&'a FailoverGroupDto> { + list.groups.iter().find(|candidate| &group_ref_of(candidate) == group) +} + +/// One row per resolved group, followed by the manual groups that could not be formed and the dissolved +/// automatic groups. +pub fn build_rows(preview: Option<&FailoverGroupListDto>, draft: &TargetFailoverOverridesDto) -> Vec { + let Some(preview) = preview else { return Vec::new() }; + let diagnostics = &preview.diagnostics; + let mut rows: Vec = preview + .groups + .iter() + .map(|group| GroupRow { + group: group_ref_of(group), + name: group.name.clone(), + origin: group.origin, + member_count: group.members.len(), + state: RowState::Active, + problem: !group.unresolved.is_empty(), + display_stale: preview.display_stale.contains(&group.key), + }) + .collect(); + rows.extend(diagnostics.inactive_manual_groups.iter().map(|inactive| { + GroupRow { + group: GroupRef::Manual(inactive.id.clone()), + name: draft + .groups + .iter() + .find(|group| group.id == inactive.id) + .map_or_else(|| inactive.id.clone(), |group| group.name.clone()), + origin: FailoverGroupOrigin::Manual, + member_count: inactive.resolved, + state: RowState::Inactive, + problem: true, + display_stale: false, + } + })); + rows.extend(diagnostics.dissolved.iter().map(|key| GroupRow { + group: GroupRef::Auto(key.clone()), + name: key.clone(), + origin: FailoverGroupOrigin::AutoEdited, + member_count: 0, + state: RowState::Dissolved, + problem: false, + display_stale: false, + })); + rows +} + +pub const fn origin_chip_class(origin: FailoverGroupOrigin) -> &'static str { + match origin { + FailoverGroupOrigin::Auto => "tp__failover-groups__origin--auto", + FailoverGroupOrigin::AutoEdited => "tp__failover-groups__origin--edited", + FailoverGroupOrigin::Manual => "tp__failover-groups__origin--manual", + } +} + +pub const fn origin_label_key(origin: FailoverGroupOrigin) -> &'static str { + match origin { + FailoverGroupOrigin::Auto => "LABEL.FAILOVER_GROUPS_ORIGIN_AUTO", + FailoverGroupOrigin::AutoEdited => "LABEL.FAILOVER_GROUPS_ORIGIN_EDITED", + FailoverGroupOrigin::Manual => "LABEL.FAILOVER_GROUPS_ORIGIN_MANUAL", + } +} + +/// Rows that pass the filter and the (case insensitive) name search. +pub fn visible_rows<'a>(rows: &'a [GroupRow], filter: GroupFilter, search: &str) -> Vec<&'a GroupRow> { + let needle = search.trim().to_lowercase(); + rows.iter() + .filter(|row| filter.matches(row)) + .filter(|row| needle.is_empty() || row.name.to_lowercase().contains(&needle)) + .collect() +} + +#[derive(Properties, PartialEq)] +pub struct GroupListProps { + pub rows: Rc>, + pub selected: Option, + pub filter: GroupFilter, + pub search: String, + pub problem_count: usize, + pub on_filter: Callback, + pub on_search: Callback, + pub on_select: Callback, +} + +#[component] +pub fn GroupList(props: &GroupListProps) -> Html { + let translate = use_translation(); + let options = Rc::new(GroupFilter::ALL.iter().map(|filter| filter.id().to_string()).collect::>()); + let labels = Rc::new( + GroupFilter::ALL + .iter() + .map(|filter| { + let label = translate.t(filter.label_key()); + if *filter == GroupFilter::Problems { + format!("{label} ({})", props.problem_count) + } else { + label + } + }) + .collect::>(), + ); + let on_filter = { + let on_filter = props.on_filter.clone(); + Callback::from(move |selection: Rc>| { + if let Some(filter) = selection.first().and_then(|id| GroupFilter::from_id(id)) { + on_filter.emit(filter); + } + }) + }; + let visible = visible_rows(&props.rows, props.filter, &props.search); + + html! { +
+
+ + +
+
    + { for visible.iter().map(|row| { + let selected = props.selected.as_ref() == Some(&row.group); + let onclick = { + let on_select = props.on_select.clone(); + let group = row.group.clone(); + Callback::from(move |_: MouseEvent| on_select.emit(group.clone())) + }; + html! { +
  • + +
  • + } + }) } + if visible.is_empty() { +
  • { translate.t("LABEL.NO_CONTENT") }
  • + } +
+
+ } +} diff --git a/frontend/src/app/components/failover_groups/health.rs b/frontend/src/app/components/failover_groups/health.rs new file mode 100644 index 000000000..4bc8074c7 --- /dev/null +++ b/frontend/src/app/components/failover_groups/health.rs @@ -0,0 +1,99 @@ +//! Display helpers for the health columns of the member table. + +const NO_VALUE: &str = "\u{2014}"; + +/// Bitrate as megabits with one decimal (`6.1M`), kilobits below one megabit. +pub fn bitrate_label(bitrate_kbps: Option) -> Option { + bitrate_kbps.filter(|kbps| *kbps > 0).map(|kbps| { + if kbps >= 1000 { + format!("{}.{}M", kbps / 1000, (kbps % 1000) / 100) + } else { + format!("{kbps}k") + } + }) +} + +/// `24h/7d` quality of service of a member. +pub fn qos_label(qos_24h: Option, qos_7d: Option) -> String { + match (qos_24h, qos_7d) { + (None, None) => NO_VALUE.to_string(), + (day, week) => { + let part = |value: Option| value.map_or_else(|| NO_VALUE.to_string(), |value| value.to_string()); + format!("{}/{}", part(day), part(week)) + } + } +} + +/// Connections in use over the connection limit of the member's input; no limit shows a dash. +pub fn slots_label(used: u32, max: u32) -> String { + if max == 0 { + format!("{used}/{NO_VALUE}") + } else { + format!("{used}/{max}") + } +} + +/// Dot colour class of a member: `open` when the member or its input is shut off, `degraded` while failures are +/// being counted, `ok` otherwise. +pub fn health_class(circuit: &str, input_circuit: &str, failure_streak: u32) -> &'static str { + let is_open = |state: &str| matches!(state, "open" | "half_open"); + if is_open(circuit) || is_open(input_circuit) { + "open" + } else if circuit == "counting" || input_circuit == "counting" || failure_streak > 0 { + "degraded" + } else { + "ok" + } +} + +pub fn circuit_label_key(circuit: &str) -> &'static str { + match circuit { + "counting" => "LABEL.FAILOVER_GROUPS_HEALTH_COUNTING", + "open" => "LABEL.FAILOVER_GROUPS_HEALTH_OPEN", + "half_open" => "LABEL.FAILOVER_GROUPS_HEALTH_HALF_OPEN", + _ => "LABEL.FAILOVER_GROUPS_HEALTH_CLOSED", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bitrate_is_shown_in_megabits_with_one_decimal() { + assert_eq!(bitrate_label(Some(6100)).as_deref(), Some("6.1M")); + assert_eq!(bitrate_label(Some(3249)).as_deref(), Some("3.2M")); + assert_eq!(bitrate_label(Some(800)).as_deref(), Some("800k")); + assert_eq!(bitrate_label(Some(0)), None); + assert_eq!(bitrate_label(None), None); + } + + #[test] + fn qos_label_shows_both_windows_or_a_dash() { + assert_eq!(qos_label(Some(92), Some(80)), "92/80"); + assert_eq!(qos_label(Some(92), None), "92/\u{2014}"); + assert_eq!(qos_label(None, None), "\u{2014}"); + } + + #[test] + fn slots_without_limit_show_a_dash() { + assert_eq!(slots_label(2, 3), "2/3"); + assert_eq!(slots_label(1, 0), "1/\u{2014}"); + } + + #[test] + fn health_class_ranks_open_above_degraded() { + assert_eq!(health_class("closed", "closed", 0), "ok"); + assert_eq!(health_class("counting", "closed", 0), "degraded"); + assert_eq!(health_class("closed", "closed", 2), "degraded"); + assert_eq!(health_class("open", "closed", 0), "open"); + assert_eq!(health_class("closed", "half_open", 0), "open"); + assert_eq!(health_class("counting", "open", 3), "open"); + } + + #[test] + fn circuit_labels_fall_back_to_closed() { + assert_eq!(circuit_label_key("half_open"), "LABEL.FAILOVER_GROUPS_HEALTH_HALF_OPEN"); + assert_eq!(circuit_label_key("unknown"), "LABEL.FAILOVER_GROUPS_HEALTH_CLOSED"); + } +} diff --git a/frontend/src/app/components/failover_groups/member_table.rs b/frontend/src/app/components/failover_groups/member_table.rs new file mode 100644 index 000000000..da3f302fc --- /dev/null +++ b/frontend/src/app/components/failover_groups/member_table.rs @@ -0,0 +1,335 @@ +//! Member table of the selected group: order, health columns and the per-member actions. + +use super::{ + draft::{DraftOp, GroupRef}, + health::{bitrate_label, circuit_label_key, health_class, qos_label, slots_label}, +}; +use crate::{ + app::components::{ + define_table_columns, make_translated_header_callback, selection_first, AppIcon, DropDownIconButton, + DropDownOption, IconButton, Table, TableDefinition, + }, + i18n::use_translation, +}; +use shared::model::{ChannelMemberRefDto, FailoverGroupHealthDto, FailoverGroupMemberDto, MemberHealthDto}; +use std::{cell::RefCell, rc::Rc}; +use yew::prelude::*; + +define_table_columns! { + enum MemberColumn { + Handle => ("handle", "LABEL.FAILOVER_GROUPS_COL_ORDER") { can_hide: false }, + Position => ("position", "LABEL.FAILOVER_GROUPS_COL_POSITION") { can_hide: false }, + Input => ("input", "LABEL.FAILOVER_GROUPS_COL_INPUT"), + Title => ("title", "LABEL.FAILOVER_GROUPS_COL_TITLE") { can_hide: false }, + Resolution => ("resolution", "LABEL.FAILOVER_GROUPS_COL_RESOLUTION"), + Codec => ("codec", "LABEL.FAILOVER_GROUPS_COL_CODEC"), + Bitrate => ("bitrate", "LABEL.FAILOVER_GROUPS_COL_BITRATE"), + Qos => ("qos", "LABEL.FAILOVER_GROUPS_COL_QOS"), + Circuit => ("circuit", "LABEL.FAILOVER_GROUPS_COL_CIRCUIT"), + Slots => ("slots", "LABEL.FAILOVER_GROUPS_COL_SLOTS"), + Actions => ("actions", "LABEL.FAILOVER_GROUPS_COL_ACTIONS") { can_hide: false }, + } +} + +const NO_VALUE: &str = "\u{2014}"; + +/// The text, or a dash when it is missing or empty. +fn or_dash(text: Option) -> String { + text.filter(|text| !text.is_empty()).unwrap_or_else(|| NO_VALUE.to_string()) +} + +/// What a member action asks for; the position is the index in the visible member list. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MemberAction { + Up, + Down, + First, + Remove, + Exclude, + /// Drag and drop: move the member to this index. + MoveTo(usize), +} + +/// The draft operation for `action` on the member at `index` of `refs` (the visible members in order). +pub fn member_action_op( + group: &GroupRef, + refs: &[ChannelMemberRefDto], + index: usize, + action: MemberAction, +) -> Option { + let member = refs.get(index)?.clone(); + let reorder = |to: usize| { + (to != index && to < refs.len()).then(|| DraftOp::Reorder { + group: group.clone(), + from: index, + to, + current: refs.to_vec(), + }) + }; + match action { + MemberAction::Up => reorder(index.checked_sub(1)?), + MemberAction::Down => reorder(index + 1), + MemberAction::MoveTo(to) => reorder(to), + MemberAction::First => { + (index > 0).then(|| DraftOp::MoveFirst { group: group.clone(), index, current: refs.to_vec() }) + } + MemberAction::Remove => Some(DraftOp::RemoveMember { group: group.clone(), member }), + MemberAction::Exclude => Some(DraftOp::ExcludeMember { group: Some(group.clone()), member }), + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct MemberRow { + pub index: usize, + pub member: FailoverGroupMemberDto, + pub health: Option, +} + +/// Pairs every visible member with its health entry, when health is known. +pub fn member_rows(members: &[FailoverGroupMemberDto], health: Option<&FailoverGroupHealthDto>) -> Vec> { + members + .iter() + .enumerate() + .map(|(index, member)| { + let health = health + .and_then(|health| health.members.iter().find(|entry| entry.member_ref == member.member_ref)) + .cloned(); + Rc::new(MemberRow { index, member: member.clone(), health }) + }) + .collect() +} + +type DragState = Rc>>; + +/// Every cell of a row accepts a dragged member, so the whole row is a drop target. +fn drop_cell(content: Html, index: usize, drag: &DragState, on_move: &Callback<(usize, usize)>, enabled: bool) -> Html { + if !enabled { + return content; + } + let ondragover = Callback::from(|event: DragEvent| event.prevent_default()); + let ondrop = { + let drag = drag.clone(); + let on_move = on_move.clone(); + Callback::from(move |event: DragEvent| { + event.prevent_default(); + if let Some(from) = drag.borrow_mut().take() { + on_move.emit((from, index)); + } + }) + }; + html! {
{ content }
} +} + +#[derive(Properties, PartialEq)] +pub struct MemberTableProps { + pub group: GroupRef, + pub members: Rc>, + pub health: Option>, + pub read_only: bool, + /// The shown members are out of date with the draft; actions wait for the new preview. + pub busy: bool, + pub on_op: Callback, + pub on_move_to: Callback, +} + +#[component] +pub fn MemberTable(props: &MemberTableProps) -> Html { + let translate = use_translation(); + let columns = use_memo((), |()| MemberColumn::columns()); + let drag: DragState = use_mut_ref(|| None); + let rows = use_memo((props.members.clone(), props.health.clone()), |(members, health)| { + member_rows(members, health.as_deref()) + }); + + let on_action = { + let group = props.group.clone(); + let members = props.members.clone(); + let on_op = props.on_op.clone(); + let on_move_to = props.on_move_to.clone(); + let busy = props.busy; + Callback::from(move |(index, action): (usize, Option)| { + if busy { + return; + } + let refs: Vec = members.iter().map(|member| member.member_ref.clone()).collect(); + match action { + Some(action) => { + if let Some(op) = member_action_op(&group, &refs, index, action) { + on_op.emit(op); + } + } + None => { + if let Some(member) = refs.get(index) { + on_move_to.emit(member.clone()); + } + } + } + }) + }; + + let render_header_cell = make_translated_header_callback(translate.clone(), |index| { + MemberColumn::from_index(index).map(MemberColumn::header_label) + }); + + let definition = { + let columns = columns.clone(); + let translate = translate.clone(); + let read_only = props.read_only; + use_memo((rows.clone(), read_only, on_action.clone()), move |(rows, read_only, on_action)| { + let read_only = *read_only; + let last = rows.len().saturating_sub(1); + let on_move = { + let on_action = on_action.clone(); + Callback::from(move |(from, to): (usize, usize)| { + on_action.emit((from, Some(MemberAction::MoveTo(to)))); + }) + }; + let render_data_cell = { + let drag = drag.clone(); + let on_action = on_action.clone(); + let translate = translate.clone(); + Callback::<(usize, usize, Rc), Html>::from( + move |(_, col, row): (usize, usize, Rc)| { + let index = row.index; + let health = row.health.as_ref(); + let content = match MemberColumn::from_index(col) { + Some(MemberColumn::Handle) if !read_only => { + let ondragstart = { + let drag = drag.clone(); + Callback::from(move |event: DragEvent| { + *drag.borrow_mut() = Some(index); + if let Some(transfer) = event.data_transfer() { + transfer.set_effect_allowed("move"); + let _ = transfer.set_data("text/plain", &index.to_string()); + } + }) + }; + let ondragend = { + let drag = drag.clone(); + Callback::from(move |_: DragEvent| *drag.borrow_mut() = None) + }; + html! { + + + + } + } + Some(MemberColumn::Position) => { + let step = + |name: &'static str, icon: &'static str, hint: &str, action, disabled: bool| { + let on_action = on_action.clone(); + html! { + + } + }; + html! { + + { index + 1 } + if !read_only { + { step("up", "ArrowUp", &translate.t("LABEL.FAILOVER_GROUPS_MOVE_UP"), + MemberAction::Up, index == 0) } + { step("down", "ArrowDown", &translate.t("LABEL.FAILOVER_GROUPS_MOVE_DOWN"), + MemberAction::Down, index == last) } + } + + } + } + Some(MemberColumn::Input) => html! { &row.member.input_name }, + Some(MemberColumn::Title) => html! { &row.member.title }, + Some(MemberColumn::Resolution) => { + html! { or_dash(health.and_then(|h| h.resolution.as_ref().map(ToString::to_string))) } + } + Some(MemberColumn::Codec) => { + html! { or_dash(health.and_then(|h| h.codec.as_ref().map(ToString::to_string))) } + } + Some(MemberColumn::Bitrate) => { + html! { or_dash(health.and_then(|h| bitrate_label(h.bitrate_kbps))) } + } + Some(MemberColumn::Qos) => { + html! { health.map_or_else(|| NO_VALUE.to_string(), |h| qos_label(h.qos_24h, h.qos_7d)) } + } + Some(MemberColumn::Circuit) => match health { + Some(h) => html! { + + + { translate.t(circuit_label_key(&h.circuit)) } + + }, + None => html! { NO_VALUE }, + }, + Some(MemberColumn::Slots) => { + html! { health.map_or_else(|| NO_VALUE.to_string(), |h| slots_label(h.slots_used, h.slots_max)) } + } + Some(MemberColumn::Actions) if !read_only => { + let options = Rc::new( + [ + ("up", "LABEL.FAILOVER_GROUPS_MOVE_UP"), + ("down", "LABEL.FAILOVER_GROUPS_MOVE_DOWN"), + ("first", "LABEL.FAILOVER_GROUPS_MOVE_FIRST"), + ("move_to", "LABEL.FAILOVER_GROUPS_MOVE_TO"), + ("remove", "LABEL.FAILOVER_GROUPS_REMOVE_MEMBER"), + ("exclude", "LABEL.FAILOVER_GROUPS_EXCLUDE_MEMBER"), + ] + .iter() + .map(|(id, key)| DropDownOption::new(id, html! { translate.t(key) }, false)) + .collect::>(), + ); + let on_select = { + let on_action = on_action.clone(); + Callback::from( + move |(_, selection): (String, crate::app::components::DropDownSelection)| { + let action = match selection_first(&selection) { + Some("up") => Some(MemberAction::Up), + Some("down") => Some(MemberAction::Down), + Some("first") => Some(MemberAction::First), + Some("remove") => Some(MemberAction::Remove), + Some("exclude") => Some(MemberAction::Exclude), + Some("move_to") => None, + _ => return, + }; + on_action.emit((index, action)); + }, + ) + }; + html! { + + } + } + _ => html! {}, + }; + drop_cell(content, index, &drag, &on_move, !read_only) + }, + ) + }; + Rc::new(TableDefinition:: { + table_id: "failover_groups.members".into(), + columns: columns.clone(), + row_key: Callback::from(|(_, row): (usize, Rc)| { + format!("{}/{}", row.member.member_ref.input, row.member.member_ref.stream_id).into() + }), + items: if rows.is_empty() { None } else { Some(rows.clone()) }, + on_sort: Callback::noop(), + render_header_cell: render_header_cell.clone(), + render_data_cell, + }) + }) + }; + + html! { +
+ definition={(*definition).clone()} /> +
+ } +} diff --git a/frontend/src/app/components/failover_groups/mod.rs b/frontend/src/app/components/failover_groups/mod.rs new file mode 100644 index 000000000..155f6fcdd --- /dev/null +++ b/frontend/src/app/components/failover_groups/mod.rs @@ -0,0 +1,13 @@ +mod candidate_panel; +mod diagnostics_panel; +mod dialogs; +mod draft; +mod failover_groups_view; +mod group_detail; +mod group_list; +mod health; +mod member_table; +mod navigation; +mod state; + +pub use self::{failover_groups_view::FailoverGroupsView, navigation::FailoverGroupsPreselect}; diff --git a/frontend/src/app/components/failover_groups/navigation.rs b/frontend/src/app/components/failover_groups/navigation.rs new file mode 100644 index 000000000..85d34a64d --- /dev/null +++ b/frontend/src/app/components/failover_groups/navigation.rs @@ -0,0 +1,75 @@ +//! What a deep link into the "Failover groups" page asks for: the target, optionally a group and a channel. + +use super::draft::GroupRef; +use shared::model::ChannelMemberRefDto; + +/// A channel the page should offer in the candidate panel; its title seeds the candidate search. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChannelPreselect { + pub member_ref: ChannelMemberRefDto, + pub title: String, +} + +impl ChannelPreselect { + pub fn query(&self) -> &str { self.title.trim() } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FailoverGroupsPreselect { + pub target: String, + pub group: Option, + /// Seeds the search of the group list when the group itself is not known. + pub group_search: Option, + pub channel: Option, +} + +impl FailoverGroupsPreselect { + pub fn for_target(target: &str) -> Self { + Self { target: target.to_string(), group: None, group_search: None, channel: None } + } + + #[must_use] + pub fn with_group_search(mut self, search: &str) -> Self { + self.group_search = Some(search.to_string()); + self + } + + #[must_use] + pub fn with_channel(mut self, member_ref: ChannelMemberRefDto, title: &str) -> Self { + self.channel = Some(ChannelPreselect { member_ref, title: title.to_string() }); + self + } +} + +/// The channel of a request, tagged like [`NumberedPreselect`] so the candidate panel reacts to every request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NumberedChannel { + pub seq: u32, + pub channel: ChannelPreselect, +} + +/// A request together with the number that tells a target editor whether it applied the request already. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NumberedPreselect { + pub seq: u32, + pub preselect: FailoverGroupsPreselect, +} + +/// The requested target when the page can edit it. +pub fn preselected_target<'a>(preselect: &'a FailoverGroupsPreselect, editable: &[String]) -> Option<&'a str> { + editable.contains(&preselect.target).then_some(preselect.target.as_str()) +} + +/// The request unless the editor applied it already; remembers the applied request in `last_applied`. +pub fn take_unapplied<'a>( + last_applied: &mut u32, + current: Option<&'a NumberedPreselect>, +) -> Option<&'a FailoverGroupsPreselect> { + let current = current.filter(|numbered| numbered.seq != *last_applied)?; + *last_applied = current.seq; + Some(¤t.preselect) +} + +#[cfg(test)] +#[path = "navigation.test.rs"] +mod tests; diff --git a/frontend/src/app/components/failover_groups/navigation.test.rs b/frontend/src/app/components/failover_groups/navigation.test.rs new file mode 100644 index 000000000..4cf2d35b7 --- /dev/null +++ b/frontend/src/app/components/failover_groups/navigation.test.rs @@ -0,0 +1,50 @@ +use super::*; +use crate::app::components::failover_groups::state::{FailoverGroupsEditorAction, FailoverGroupsEditorState}; +use std::rc::Rc; +use yew::Reducible; + +fn editable() -> Vec { vec!["Main TV".to_string(), "Sport".to_string()] } + +#[test] +fn navigation_preselects_target_and_group() { + let preselect = FailoverGroupsPreselect { + group: Some(GroupRef::Manual("zdf".to_string())), + ..FailoverGroupsPreselect::for_target("Sport").with_group_search("ZDF") + }; + + assert_eq!(preselected_target(&preselect, &editable()), Some("Sport")); + assert_eq!(preselect.group_search.as_deref(), Some("ZDF")); + + let state = Rc::new(FailoverGroupsEditorState::new("Sport")) + .reduce(FailoverGroupsEditorAction::Select(preselect.group.clone())); + assert_eq!(state.selected, Some(GroupRef::Manual("zdf".to_string()))); +} + +#[test] +fn navigation_ignores_targets_that_cannot_be_edited() { + let preselect = FailoverGroupsPreselect::for_target("Gone"); + assert_eq!(preselected_target(&preselect, &editable()), None); + assert_eq!(preselected_target(&preselect, &[]), None); +} + +#[test] +fn navigation_is_applied_once_per_request() { + let first = NumberedPreselect { seq: 1, preselect: FailoverGroupsPreselect::for_target("Sport") }; + let second = NumberedPreselect { seq: 2, preselect: FailoverGroupsPreselect::for_target("Sport") }; + let mut applied = 0; + + assert!(take_unapplied(&mut applied, None).is_none()); + assert!(take_unapplied(&mut applied, Some(&first)).is_some()); + assert!(take_unapplied(&mut applied, Some(&first)).is_none(), "a re-render must not apply it again"); + assert!(take_unapplied(&mut applied, Some(&second)).is_some(), "the same target again is a new request"); +} + +#[test] +fn channel_preselect_prefills_the_candidate_search_with_its_title() { + let member = ChannelMemberRefDto { input: "prov-a".to_string(), stream_id: "42".to_string() }; + let preselect = FailoverGroupsPreselect::for_target("Sport").with_channel(member.clone(), " ZDF HD "); + + let channel = preselect.channel.as_ref().expect("channel preselected"); + assert_eq!(channel.member_ref, member); + assert_eq!(channel.query(), "ZDF HD"); +} diff --git a/frontend/src/app/components/failover_groups/state.rs b/frontend/src/app/components/failover_groups/state.rs new file mode 100644 index 000000000..facecaa8f --- /dev/null +++ b/frontend/src/app/components/failover_groups/state.rs @@ -0,0 +1,205 @@ +//! Reducer of the failover groups editor: the draft section, its undo stack and the preview bookkeeping. + +use super::draft::{DraftOp, GroupRef}; +use shared::model::{ + FailoverGroupListDto, FailoverGroupSaveResultDto, TargetFailoverOverridesDto, TargetOverridesResponseDto, +}; +use std::rc::Rc; +use yew::Reducible; + +const MAX_UNDO_STEPS: usize = 100; + +#[derive(Debug, Clone, PartialEq)] +pub enum FailoverGroupsEditorAction { + /// The section as the server holds it; replaces the draft. + Loaded(TargetOverridesResponseDto), + /// One user action on the draft, a single undo step. + Edit(DraftOp), + Undo, + /// A preview for a draft at least as new as `seq` is wanted; responses for older drafts become stale. + PreviewRequested(u64), + /// The preview of the draft that carried `seq`; ignored when the draft changed since. + PreviewLoaded(u64, FailoverGroupListDto), + /// The preview for the draft that carried `seq` could not be produced; the page stops waiting for it. + PreviewFailed(u64), + Select(Option), + /// A save request is on its way; change notifications are ignored until it is answered. + SaveStarted, + /// The save request was answered (stored, refused or failed). + SaveFinished, + /// The draft `saved` was stored; edits made since stay in the draft. + Saved(FailoverGroupSaveResultDto, TargetFailoverOverridesDto), + /// The server section changed since the draft was based on it (`current_etag`); the draft is kept. + Conflict(Option), + /// Reverts the draft to the server section. + Discard, + /// The section on the server as read after a change notification. + ExternalChange(TargetOverridesResponseDto), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct FailoverGroupsEditorState { + pub server: Option, + pub draft: TargetFailoverOverridesDto, + pub undo: Vec, + pub dirty: bool, + /// Increments with every change of the draft; tags preview requests. + pub preview_seq: u64, + /// The newest `preview_seq` whose preview request was answered, successfully or not. + pub settled_seq: u64, + pub preview: Option, + pub selected: Option, + /// The server section changed while the draft has unsaved changes, with the etag found on the server. + pub conflict: Option>, + /// A save request is in flight. + pub saving: bool, + /// The group whose metadata the latest undo step edited; further metadata edits of it extend that step. + meta_run: Option, +} + +impl FailoverGroupsEditorState { + pub fn new(target: &str) -> Self { + Self { + server: None, + draft: TargetFailoverOverridesDto::new(target), + undo: Vec::new(), + dirty: false, + preview_seq: 0, + settled_seq: 0, + preview: None, + selected: None, + conflict: None, + saving: false, + meta_run: None, + } + } + + /// The shown preview does not belong to the current draft yet. + pub const fn preview_stale(&self) -> bool { self.settled_seq != self.preview_seq } + + fn server_overrides(&self) -> TargetFailoverOverridesDto { + self.server + .as_ref() + .map_or_else(|| TargetFailoverOverridesDto::new(&self.draft.target), |s| s.overrides.clone()) + } + + fn refresh_dirty(&mut self) { + self.dirty = match &self.server { + Some(server) => self.draft != server.overrides, + None => !self.draft.is_empty(), + }; + } + + fn load(&mut self, response: TargetOverridesResponseDto) { + self.draft = response.overrides.clone(); + self.server = Some(response); + self.undo.clear(); + self.meta_run = None; + self.dirty = false; + self.conflict = None; + self.preview_seq += 1; + } + + fn edit(&mut self, op: &DraftOp) { + let before = self.draft.clone(); + let created = op.apply(&mut self.draft); + if self.draft == before { + return; + } + let meta_group = match op { + DraftOp::SetMeta { group, .. } => Some(group.clone()), + _ => None, + }; + // Typing in the metadata form is one undo step per run of edits of the same group. + if meta_group.is_none() || meta_group != self.meta_run { + if self.undo.len() >= MAX_UNDO_STEPS { + self.undo.remove(0); + } + self.undo.push(before); + } + self.meta_run = meta_group; + self.preview_seq += 1; + if created.is_some() { + self.selected = created; + } else if let Some(GroupRef::Manual(id)) = &self.selected { + if !self.draft.groups.iter().any(|group| group.id == *id) { + self.selected = None; + } + } + self.refresh_dirty(); + } + + fn apply(&mut self, action: FailoverGroupsEditorAction) { + match action { + FailoverGroupsEditorAction::Loaded(response) => self.load(response), + FailoverGroupsEditorAction::Edit(op) => self.edit(&op), + FailoverGroupsEditorAction::Undo => { + if let Some(previous) = self.undo.pop() { + self.meta_run = None; + self.draft = previous; + self.preview_seq += 1; + self.refresh_dirty(); + } + } + FailoverGroupsEditorAction::PreviewRequested(seq) => self.preview_seq = self.preview_seq.max(seq), + FailoverGroupsEditorAction::PreviewLoaded(seq, preview) => { + if seq >= self.preview_seq { + self.preview = Some(preview); + self.settled_seq = seq; + } + } + FailoverGroupsEditorAction::PreviewFailed(seq) => { + if seq >= self.preview_seq { + self.settled_seq = seq; + } + } + FailoverGroupsEditorAction::SaveStarted => self.saving = true, + FailoverGroupsEditorAction::SaveFinished => self.saving = false, + FailoverGroupsEditorAction::Select(selected) => self.selected = selected, + FailoverGroupsEditorAction::Saved(result, saved) => { + self.server = + Some(TargetOverridesResponseDto { etag: result.etag, file: result.file, overrides: saved }); + self.conflict = None; + self.meta_run = None; + self.refresh_dirty(); + } + FailoverGroupsEditorAction::Conflict(current_etag) => self.conflict = Some(current_etag), + FailoverGroupsEditorAction::Discard => { + self.draft = self.server_overrides(); + self.undo.clear(); + self.meta_run = None; + self.dirty = false; + self.conflict = None; + self.preview_seq += 1; + } + FailoverGroupsEditorAction::ExternalChange(response) => { + // The notification of our own write can arrive before the answer of the save. + if self.saving { + return; + } + if self.server.as_ref().is_some_and(|server| server.etag == response.etag) { + return; + } + if self.dirty { + self.conflict = Some(response.etag); + } else { + self.load(response); + } + } + } + } +} + +impl Reducible for FailoverGroupsEditorState { + type Action = FailoverGroupsEditorAction; + + fn reduce(self: Rc, action: Self::Action) -> Rc { + let mut next = (*self).clone(); + next.apply(action); + Rc::new(next) + } +} + +#[cfg(test)] +#[path = "state.test.rs"] +mod tests; diff --git a/frontend/src/app/components/failover_groups/state.test.rs b/frontend/src/app/components/failover_groups/state.test.rs new file mode 100644 index 000000000..c621502bc --- /dev/null +++ b/frontend/src/app/components/failover_groups/state.test.rs @@ -0,0 +1,357 @@ +use super::*; +use crate::app::components::failover_groups::draft::GroupMetaPatch; +use shared::model::ChannelMemberRefDto; + +fn member(stream_id: &str) -> ChannelMemberRefDto { + ChannelMemberRefDto { input: "p".to_string(), stream_id: stream_id.to_string() } +} + +fn response(etag: &str, overrides: TargetFailoverOverridesDto) -> TargetOverridesResponseDto { + TargetOverridesResponseDto { etag: Some(etag.to_string()), file: Some("failover.yml".to_string()), overrides } +} + +fn dispatch(state: Rc, action: FailoverGroupsEditorAction) -> Rc { + state.reduce(action) +} + +fn loaded_state() -> Rc { + dispatch( + Rc::new(FailoverGroupsEditorState::new("Main TV")), + FailoverGroupsEditorAction::Loaded(response("e1", TargetFailoverOverridesDto::new("Main TV"))), + ) +} + +fn add_op(auto_key: &str, stream_id: &str) -> FailoverGroupsEditorAction { + FailoverGroupsEditorAction::Edit(DraftOp::AddMembers { + group: GroupRef::Auto(auto_key.to_string()), + members: vec![(member(stream_id), None)], + }) +} + +#[test] +fn loaded_replaces_draft_and_resets_edit_state() { + let mut overrides = TargetFailoverOverridesDto::new("Main TV"); + overrides.excluded.push(member("9")); + let state = dispatch(loaded_state(), add_op("zdf", "1")); + let state = dispatch(state, FailoverGroupsEditorAction::Loaded(response("e2", overrides.clone()))); + assert_eq!(state.draft, overrides); + assert!(state.undo.is_empty()); + assert!(!state.dirty); + assert_eq!(state.server.as_ref().and_then(|s| s.etag.as_deref()), Some("e2")); +} + +#[test] +fn edit_marks_dirty() { + let state = dispatch(loaded_state(), add_op("zdf", "1")); + assert!(state.dirty); + assert_eq!(state.undo.len(), 1); + assert_eq!(state.draft.auto_overrides.len(), 1); +} + +#[test] +fn edit_without_effect_changes_nothing() { + let before = loaded_state(); + let after = + dispatch(before.clone(), FailoverGroupsEditorAction::Edit(DraftOp::ResetAuto { auto_key: "none".to_string() })); + assert_eq!(*after, *before); +} + +#[test] +fn edit_bumps_preview_seq() { + let before = loaded_state(); + let after = dispatch(before.clone(), add_op("zdf", "1")); + assert!(after.preview_seq > before.preview_seq); +} + +#[test] +fn move_member_is_single_undo_step() { + let state = dispatch(loaded_state(), add_op("zdf", "1")); + let undo_len = state.undo.len(); + let state = dispatch( + state, + FailoverGroupsEditorAction::Edit(DraftOp::MoveMember { + from: GroupRef::Auto("zdf".to_string()), + to: GroupRef::Auto("ard".to_string()), + member: member("1"), + }), + ); + assert_eq!(state.undo.len(), undo_len + 1); + let state = dispatch(state, FailoverGroupsEditorAction::Undo); + assert_eq!(state.undo.len(), undo_len); + assert!(state.draft.auto_overrides.iter().any(|entry| entry.auto_key == "zdf" && entry.add == vec![member("1")])); + assert!(!state.draft.auto_overrides.iter().any(|entry| entry.auto_key == "ard")); +} + +#[test] +fn undo_back_to_server_state_is_clean() { + let state = dispatch(loaded_state(), add_op("zdf", "1")); + let state = dispatch(state, FailoverGroupsEditorAction::Undo); + assert!(!state.dirty); + assert!(state.undo.is_empty()); + let again = dispatch(state.clone(), FailoverGroupsEditorAction::Undo); + assert_eq!(*again, *state); +} + +#[test] +fn undo_stack_is_bounded() { + let mut state = loaded_state(); + for index in 0..(MAX_UNDO_STEPS + 20) { + state = dispatch(state, add_op("zdf", &index.to_string())); + } + assert_eq!(state.undo.len(), MAX_UNDO_STEPS); +} + +#[test] +fn stale_preview_response_is_ignored() { + let state = dispatch(loaded_state(), add_op("zdf", "1")); + let seq_of_first_edit = state.preview_seq; + let state = dispatch(state, add_op("zdf", "2")); + assert!(state.preview_seq > seq_of_first_edit); + + let stale = FailoverGroupListDto { etag: Some("stale".to_string()), ..Default::default() }; + let state = dispatch(state, FailoverGroupsEditorAction::PreviewLoaded(seq_of_first_edit, stale)); + assert!(state.preview.is_none()); + + let fresh = FailoverGroupListDto { etag: Some("fresh".to_string()), ..Default::default() }; + let seq = state.preview_seq; + let state = dispatch(state, FailoverGroupsEditorAction::PreviewLoaded(seq, fresh.clone())); + assert_eq!(state.preview, Some(fresh)); +} + +#[test] +fn preview_requested_invalidates_older_responses() { + let state = loaded_state(); + let seq = state.preview_seq; + let state = dispatch(state, FailoverGroupsEditorAction::PreviewRequested(seq + 1)); + let state = dispatch(state, FailoverGroupsEditorAction::PreviewLoaded(seq, FailoverGroupListDto::default())); + assert!(state.preview.is_none()); + let lower = dispatch(state.clone(), FailoverGroupsEditorAction::PreviewRequested(0)); + assert_eq!(lower.preview_seq, state.preview_seq); +} + +#[test] +fn conflict_keeps_draft() { + let state = dispatch(loaded_state(), add_op("zdf", "1")); + let draft = state.draft.clone(); + let state = dispatch(state, FailoverGroupsEditorAction::Conflict(Some("e9".to_string()))); + assert_eq!(state.draft, draft); + assert!(state.dirty); + assert_eq!(state.conflict, Some(Some("e9".to_string()))); +} + +#[test] +fn external_change_reloads_when_clean() { + let mut changed = TargetFailoverOverridesDto::new("Main TV"); + changed.excluded.push(member("7")); + let state = dispatch(loaded_state(), FailoverGroupsEditorAction::ExternalChange(response("e2", changed.clone()))); + assert_eq!(state.draft, changed); + assert!(!state.dirty); + assert!(state.conflict.is_none()); + assert_eq!(state.server.as_ref().and_then(|s| s.etag.as_deref()), Some("e2")); +} + +#[test] +fn external_change_sets_conflict_when_dirty() { + let state = dispatch(loaded_state(), add_op("zdf", "1")); + let draft = state.draft.clone(); + let mut changed = TargetFailoverOverridesDto::new("Main TV"); + changed.excluded.push(member("7")); + let state = dispatch(state, FailoverGroupsEditorAction::ExternalChange(response("e2", changed))); + assert_eq!(state.draft, draft); + assert_eq!(state.conflict, Some(Some("e2".to_string()))); + assert_eq!(state.server.as_ref().and_then(|s| s.etag.as_deref()), Some("e1")); +} + +#[test] +fn external_change_with_known_etag_is_ignored() { + let state = dispatch(loaded_state(), add_op("zdf", "1")); + let echo = response("e1", TargetFailoverOverridesDto::new("Main TV")); + let after = dispatch(state.clone(), FailoverGroupsEditorAction::ExternalChange(echo)); + assert_eq!(*after, *state); +} + +#[test] +fn saved_makes_draft_the_server_state_and_keeps_later_edits_dirty() { + let state = dispatch(loaded_state(), add_op("zdf", "1")); + let saved = state.draft.clone(); + let result = FailoverGroupSaveResultDto { etag: Some("e2".to_string()), file: None, hot_applied: false }; + let state = dispatch(state, FailoverGroupsEditorAction::Conflict(Some("x".to_string()))); + let state = dispatch(state, FailoverGroupsEditorAction::Saved(result.clone(), saved.clone())); + assert!(!state.dirty); + assert!(state.conflict.is_none()); + assert_eq!(state.server.as_ref().map(|s| s.overrides.clone()), Some(saved.clone())); + assert_eq!(state.server.as_ref().and_then(|s| s.etag.as_deref()), Some("e2")); + + let edited_during_save = dispatch(dispatch(loaded_state(), add_op("zdf", "1")), add_op("zdf", "2")); + let state = dispatch(edited_during_save, FailoverGroupsEditorAction::Saved(result, saved)); + assert!(state.dirty); +} + +#[test] +fn discard_reverts_to_server_state() { + let state = dispatch(loaded_state(), add_op("zdf", "1")); + let state = dispatch(state, FailoverGroupsEditorAction::Conflict(None)); + let state = dispatch(state, FailoverGroupsEditorAction::Discard); + assert!(state.draft.is_empty()); + assert!(!state.dirty); + assert!(state.undo.is_empty()); + assert!(state.conflict.is_none()); +} + +#[test] +fn creating_a_group_selects_it_and_dissolving_clears_the_selection() { + let state = dispatch( + loaded_state(), + FailoverGroupsEditorAction::Edit(DraftOp::NewGroupFromSelection { + name: "Sport".to_string(), + members: vec![(member("1"), None)], + id_suffix: "ab12".to_string(), + }), + ); + assert_eq!(state.selected, Some(GroupRef::Manual("sport-ab12".to_string()))); + let state = dispatch( + state, + FailoverGroupsEditorAction::Edit(DraftOp::Dissolve { group: GroupRef::Manual("sport-ab12".to_string()) }), + ); + assert_eq!(state.selected, None); +} + +#[test] +fn select_sets_selection() { + let state = dispatch(loaded_state(), FailoverGroupsEditorAction::Select(Some(GroupRef::Auto("zdf".to_string())))); + assert_eq!(state.selected, Some(GroupRef::Auto("zdf".to_string()))); +} + +fn meta_op(auto_key: &str, name: &str) -> FailoverGroupsEditorAction { + FailoverGroupsEditorAction::Edit(DraftOp::SetMeta { + group: GroupRef::Auto(auto_key.to_string()), + patch: GroupMetaPatch { name: Some(name.to_string()), ..Default::default() }, + }) +} + +#[test] +fn consecutive_meta_edits_share_one_undo_step() { + let state = dispatch(loaded_state(), meta_op("zdf", "Z")); + let seq = state.preview_seq; + let state = dispatch(state, meta_op("zdf", "ZD")); + let state = dispatch(state, meta_op("zdf", "ZDF")); + assert_eq!(state.undo.len(), 1); + assert_eq!(state.preview_seq, seq + 2, "every edit still asks for a new preview"); + assert_eq!(state.draft.auto_overrides[0].name.as_deref(), Some("ZDF")); + + let state = dispatch(state, FailoverGroupsEditorAction::Undo); + assert!(state.draft.auto_overrides.is_empty()); + assert!(!state.dirty); +} + +#[test] +fn meta_edit_after_other_op_starts_new_undo_step() { + let state = dispatch(loaded_state(), meta_op("zdf", "Z")); + let state = dispatch(state, add_op("zdf", "1")); + let state = dispatch(state, meta_op("zdf", "ZD")); + assert_eq!(state.undo.len(), 3); + + let other_group = dispatch(dispatch(loaded_state(), meta_op("zdf", "Z")), meta_op("rtl", "R")); + assert_eq!(other_group.undo.len(), 2); + + let after_undo = dispatch(dispatch(state, FailoverGroupsEditorAction::Undo), meta_op("zdf", "ZDF")); + assert_eq!(after_undo.undo.len(), 3, "undo ends the run"); +} + +#[test] +fn preview_answer_settles_the_sequence() { + let state = dispatch(loaded_state(), add_op("zdf", "1")); + assert!(state.preview_stale()); + let seq = state.preview_seq; + let state = dispatch(state, FailoverGroupsEditorAction::PreviewLoaded(seq, FailoverGroupListDto::default())); + assert!(!state.preview_stale()); + let state = dispatch(state, add_op("zdf", "2")); + assert!(state.preview_stale(), "a new edit makes the preview stale again"); +} + +#[test] +fn failed_preview_stops_the_page_from_waiting() { + let state = dispatch(loaded_state(), add_op("zdf", "1")); + assert!(state.preview_stale()); + let seq = state.preview_seq; + let state = dispatch(state, FailoverGroupsEditorAction::PreviewFailed(seq)); + assert!(!state.preview_stale()); + assert!(state.preview.is_none(), "no preview is invented"); +} + +#[test] +fn failed_preview_of_an_older_draft_is_ignored() { + let state = dispatch(loaded_state(), add_op("zdf", "1")); + let old_seq = state.preview_seq; + let state = dispatch(state, add_op("zdf", "2")); + let state = dispatch(state, FailoverGroupsEditorAction::PreviewFailed(old_seq)); + assert!(state.preview_stale()); +} + +#[test] +fn failed_preview_keeps_the_previous_preview() { + let state = loaded_state(); + let seq = state.preview_seq; + let preview = FailoverGroupListDto { etag: Some("p".to_string()), ..Default::default() }; + let state = dispatch(state, FailoverGroupsEditorAction::PreviewLoaded(seq, preview.clone())); + let state = dispatch(state, add_op("zdf", "1")); + let seq = state.preview_seq; + let state = dispatch(state, FailoverGroupsEditorAction::PreviewFailed(seq)); + assert_eq!(state.preview, Some(preview)); + assert!(!state.preview_stale()); +} + +#[test] +fn external_change_is_ignored_while_a_save_is_in_flight() { + let state = dispatch(loaded_state(), add_op("zdf", "1")); + let state = dispatch(state, FailoverGroupsEditorAction::SaveStarted); + assert!(state.saving); + let mut changed = TargetFailoverOverridesDto::new("Main TV"); + changed.excluded.push(member("7")); + let after = dispatch(state.clone(), FailoverGroupsEditorAction::ExternalChange(response("e2", changed.clone()))); + assert_eq!(*after, *state); + assert!(after.conflict.is_none()); + + let finished = dispatch(after, FailoverGroupsEditorAction::SaveFinished); + assert!(!finished.saving); + let after = dispatch(finished, FailoverGroupsEditorAction::ExternalChange(response("e2", changed))); + assert_eq!(after.conflict, Some(Some("e2".to_string())), "a later change is a conflict again"); +} + +#[test] +fn own_save_does_not_raise_a_conflict() { + let state = dispatch(loaded_state(), add_op("zdf", "1")); + let saved = state.draft.clone(); + let state = dispatch(state, FailoverGroupsEditorAction::SaveStarted); + let result = FailoverGroupSaveResultDto { etag: Some("e2".to_string()), file: None, hot_applied: false }; + let state = dispatch(state, FailoverGroupsEditorAction::Saved(result, saved.clone())); + let state = dispatch(state, add_op("zdf", "2")); + let state = dispatch(state, FailoverGroupsEditorAction::SaveFinished); + assert!(state.dirty); + let echo = dispatch(state.clone(), FailoverGroupsEditorAction::ExternalChange(response("e2", saved))); + assert_eq!(*echo, *state, "the notification of the own write changes nothing"); + assert!(echo.conflict.is_none()); +} + +#[test] +fn adding_several_members_is_one_undo_step() { + let state = dispatch( + loaded_state(), + FailoverGroupsEditorAction::Edit(DraftOp::AddMembers { + group: GroupRef::Auto("zdf".to_string()), + members: vec![ + (member("1"), None), + (member("2"), None), + (member("3"), Some(GroupRef::Auto("ard".to_string()))), + ], + }), + ); + assert_eq!(state.undo.len(), 1); + assert_eq!( + state.draft.auto_overrides.iter().find(|entry| entry.auto_key == "zdf").map(|entry| entry.add.len()), + Some(3) + ); + let state = dispatch(state, FailoverGroupsEditorAction::Undo); + assert!(state.draft.is_empty()); + assert!(!state.dirty); +} diff --git a/frontend/src/app/components/home.rs b/frontend/src/app/components/home.rs index 5864ae051..4a0b547ce 100644 --- a/frontend/src/app/components/home.rs +++ b/frontend/src/app/components/home.rs @@ -2,6 +2,7 @@ use crate::{ app::{ components::{ config::{ConfigView, PlansView}, + failover_groups::FailoverGroupsView, loading_indicator::BusyIndicator, map_sources_to_playlist_rows, recording::{RecordingLibraryView, RecordingRulesView}, @@ -11,7 +12,7 @@ use crate::{ Sidebar, SourceEditor, StatsView, StreamHistoryView, StreamsView, ThemePicker, ToastrView, UserlistView, WebsocketStatus, }, - context::{ConfigContext, PlaylistContext, StatusContext}, + context::{ConfigContext, FailoverGroupsNavigation, PlaylistContext, StatusContext}, }, hooks::{use_server_status, use_service_context}, html_if, @@ -96,6 +97,7 @@ fn is_allowed_home_view(view: ViewType, access: HomeViewAccess) -> bool { ViewType::PlaylistSettings => access.can_read_sources || access.can_read_playlist, ViewType::PlaylistExplorer => access.can_read_playlist, ViewType::PlaylistEpg => access.can_read_epg, + ViewType::FailoverGroups => access.can_read_playlist, ViewType::Rbac => access.is_admin, ViewType::RecordingLibrary | ViewType::RecordingRules | ViewType::RecordingRuleForm => { access.show_recording && access.can_read_recordings @@ -117,6 +119,7 @@ fn first_allowed_home_view(access: HomeViewAccess) -> ViewType { ViewType::PlaylistSettings, ViewType::PlaylistExplorer, ViewType::PlaylistEpg, + ViewType::FailoverGroups, ViewType::Rbac, ViewType::RecordingLibrary, ViewType::RecordingRules, @@ -179,6 +182,7 @@ pub fn Home() -> Html { get_location_hash().and_then(|hash| ViewType::from_str(&hash).ok()) } }); + let failover_groups_pending = use_state(|| None); let theme = use_state(Theme::get_current_theme); let force_update = use_force_update(); @@ -495,10 +499,12 @@ pub fn Home() -> Html { resolve_visible_home_view(*view_visible, configured_landing_page, stored_last_home_view, home_access) .unwrap_or(ViewType::Dashboard); let view_page = resolved_view.intern(); + let failover_groups_navigation = FailoverGroupsNavigation { pending: failover_groups_pending }; html! { context={config_context}> context={status_context}> context={playlist_context}> + context={failover_groups_navigation}>
@@ -629,6 +635,13 @@ pub fn Home() -> Html { })} + { html_if!(can_read_playlist, { + + + + + + })} { html_if!(can_read_epg, { @@ -665,6 +678,7 @@ pub fn Home() -> Html {
+ > > > > diff --git a/frontend/src/app/components/mod.rs b/frontend/src/app/components/mod.rs index 84944415b..a8308fc67 100644 --- a/frontend/src/app/components/mod.rs +++ b/frontend/src/app/components/mod.rs @@ -19,6 +19,7 @@ mod date_input_action; mod datetime_input; mod drop_down_icon_button; mod error_boundary; +pub mod failover_groups; mod health_banner; mod hide_content; mod home; diff --git a/frontend/src/app/components/playlist/playlist_explorer.rs b/frontend/src/app/components/playlist/playlist_explorer.rs index 7945a0f6e..4029a900c 100644 --- a/frontend/src/app/components/playlist/playlist_explorer.rs +++ b/frontend/src/app/components/playlist/playlist_explorer.rs @@ -1,14 +1,16 @@ use crate::{ app::{ components::{ + failover_groups::FailoverGroupsPreselect, menu_item::MenuItem, popup_menu::PopupMenu, recording::{ - ensure_recording_available, target_name_for_id, PaddingBounds, RecordingForm, RecordingFormPrefill, + ensure_recording_available, target_for_id, target_name_for_id, PaddingBounds, RecordingForm, + RecordingFormPrefill, }, AppIcon, Chip, DropDownOption, IconButton, NoContent, Panel, Search, }, - context::{ConfigContext, PlaylistExplorerContext}, + context::{ConfigContext, FailoverGroupsNavigation, PlaylistExplorerContext}, }, hooks::{use_clipboard_copy, use_service_context}, html_if, @@ -19,9 +21,9 @@ use crate::{ use shared::{ error::TuliproxError, model::{ - recording::RecordingVisibility, Permission, PlaylistRequest, PlaylistUrlResolveRequest, SearchRequest, - SeriesStreamDetailEpisodeProperties, SeriesStreamProperties, UiPlaylistGroup, UiPlaylistItem, VirtualId, - XtreamCluster, + recording::RecordingVisibility, ChannelMemberRefDto, Permission, PlaylistRequest, PlaylistUrlResolveRequest, + SearchRequest, SeriesStreamDetailEpisodeProperties, SeriesStreamProperties, SourcesConfigDto, UiPlaylistGroup, + UiPlaylistItem, VirtualId, XtreamCluster, }, utils::{format_float_localized, Internable}, }; @@ -33,6 +35,8 @@ const COPY_LINK_TULIPROX_VIRTUAL_ID: &str = "copy_link_tuliprox_virtual_id"; const COPY_LINK_TULIPROX_WEBPLAYER_URL: &str = "copy_link_tuliprox_webplayer_url"; const COPY_LINK_PROVIDER_URL: &str = "copy_link_provider_url"; const RECORD_ITEM: &str = "record_item"; +const OPEN_FAILOVER_GROUP: &str = "open_failover_group"; +const ADD_TO_FAILOVER_GROUP: &str = "add_to_failover_group"; const TP_EXPLORER_SEARCH_FIELDS_KEY: &str = "tp-explorer-search-fields"; #[derive(Clone)] @@ -43,6 +47,7 @@ struct ChannelSelection { url: String, title: String, input_name: String, + input_stream_id: String, } #[allow(clippy::enum_variant_names)] @@ -52,6 +57,8 @@ enum ExplorerAction { CopyLinkTuliproxWebPlayerUrl, CopyLinkProviderUrl, Record, + OpenFailoverGroup, + AddToFailoverGroup, } impl Display for ExplorerAction { @@ -64,6 +71,8 @@ impl Display for ExplorerAction { Self::CopyLinkTuliproxWebPlayerUrl => COPY_LINK_TULIPROX_WEBPLAYER_URL, Self::CopyLinkProviderUrl => COPY_LINK_PROVIDER_URL, Self::Record => RECORD_ITEM, + Self::OpenFailoverGroup => OPEN_FAILOVER_GROUP, + Self::AddToFailoverGroup => ADD_TO_FAILOVER_GROUP, } ) } @@ -81,6 +90,10 @@ impl FromStr for ExplorerAction { Ok(Self::CopyLinkProviderUrl) } else if s.eq(RECORD_ITEM) { Ok(Self::Record) + } else if s.eq(OPEN_FAILOVER_GROUP) { + Ok(Self::OpenFailoverGroup) + } else if s.eq(ADD_TO_FAILOVER_GROUP) { + Ok(Self::AddToFailoverGroup) } else { Err(TuliproxError::Config(format!("Unknown ExplorerAction: {s}"))) } @@ -92,6 +105,24 @@ fn can_show_record_action(can_write_recordings: bool, selected_channel: Option<& && selected_channel.is_some_and(|item| item.cluster == XtreamCluster::Live || item.downloadable) } +/// Name of the browsed target when it has failover groups enabled. +fn failover_group_target( + request: Option<&PlaylistRequest>, + sources: Option<&SourcesConfigDto>, + input_name: &str, +) -> Option { + let PlaylistRequest::Target(target_id) = request? else { return None }; + let target = target_for_id(sources?, *target_id, Some(input_name))?; + target.options.as_ref()?.failover_groups.as_ref()?.enabled.then(|| target.name.clone()) +} + +/// Live channels of a target with failover groups can be opened in, or added to, a failover group; the channel +/// reference needs the id the provider gave the stream. +fn can_show_failover_group_actions(selected_channel: Option<&ChannelSelection>, failover_target: Option<&str>) -> bool { + failover_target.is_some() + && selected_channel.is_some_and(|item| item.cluster == XtreamCluster::Live && !item.input_stream_id.is_empty()) +} + enum ExplorerLevel { Categories, Group(Rc), @@ -101,6 +132,7 @@ enum ExplorerLevel { #[component] pub fn PlaylistExplorer() -> Html { let explorer_ctx = use_context::(); + let failover_navigation = use_context::(); let cfg_ctx = use_context::(); let dialog_ctx = use_context::(); let translate = use_translation(); @@ -197,6 +229,7 @@ pub fn PlaylistExplorer() -> Html { url: dto.url.to_string(), title: dto.title.to_string(), input_name: dto.input_name.to_string(), + input_stream_id: dto.input_stream_id.to_string(), })); set_anchor_ref.set(Some(target)); set_is_open.set(true); @@ -287,9 +320,29 @@ pub fn PlaylistExplorer() -> Html { let translate_clone = translate.clone(); let copy_to_clipboard = copy_to_clipboard.clone(); let config = config_ctx.config.clone(); + let failover_navigation = failover_navigation.clone(); Callback::from(move |(name, _): (String, _)| { if let Ok(action) = ExplorerAction::from_str(&name) { match action { + ExplorerAction::OpenFailoverGroup | ExplorerAction::AddToFailoverGroup => { + let (Some(navigation), Some(dto)) = (failover_navigation.as_ref(), selected_channel.as_ref()) + else { + return; + }; + let request = playlist_ctx.playlist_request.as_ref(); + let sources = config.as_ref().map(|app_config| &app_config.sources); + let Some(target) = failover_group_target(request, sources, &dto.input_name) else { return }; + let preselect = FailoverGroupsPreselect::for_target(&target); + navigation.open(if action == ExplorerAction::OpenFailoverGroup { + preselect.with_group_search(&dto.title) + } else { + let member = ChannelMemberRefDto { + input: dto.input_name.clone(), + stream_id: dto.input_stream_id.clone(), + }; + preselect.with_channel(member, &dto.title) + }); + } ExplorerAction::CopyLinkTuliproxVirtualId => { if let Some(dto) = &*selected_channel { copy_to_clipboard.emit(dto.virtual_id.to_string()); @@ -695,6 +748,7 @@ pub fn PlaylistExplorer() -> Html { url: chan.direct_source.to_string(), title: chan.title.to_string(), input_name: String::new(), + input_stream_id: String::new(), }; let popup_onclick = handle_episode_popup_onclick.clone(); let rating = chan.rating.unwrap_or_default(); @@ -835,6 +889,13 @@ pub fn PlaylistExplorer() -> Html { } }; + let failover_target = selected_channel.as_ref().and_then(|channel| { + failover_group_target( + context.playlist_request.as_ref(), + config_ctx.config.as_ref().map(|app_config| &app_config.sources), + &channel.input_name, + ) + }); html! {
@@ -873,6 +934,14 @@ pub fn PlaylistExplorer() -> Html { }) } + { html_if!( + can_show_failover_group_actions(selected_channel.as_ref(), failover_target.as_deref()), + { + <> + + + + })} { html_if!( can_show_record_action(can_write_recordings, selected_channel.as_ref()), { @@ -885,8 +954,11 @@ pub fn PlaylistExplorer() -> Html { #[cfg(test)] mod tests { - use super::{can_show_record_action, ChannelSelection}; - use shared::model::{VirtualId, XtreamCluster}; + use super::{can_show_failover_group_actions, can_show_record_action, failover_group_target, ChannelSelection}; + use shared::model::{ + ConfigSourceDto, ConfigTargetDto, ConfigTargetOptions, FailoverGroupsTargetConfig, PlaylistRequest, + SourcesConfigDto, VirtualId, XtreamCluster, + }; #[test] fn record_action_covers_live_vod_and_episode_with_recording_write() { @@ -897,6 +969,7 @@ mod tests { url: String::new(), title: "Live".to_string(), input_name: String::new(), + input_stream_id: String::new(), }; let vod = ChannelSelection { virtual_id: VirtualId::default(), @@ -905,6 +978,7 @@ mod tests { url: String::new(), title: "VOD".to_string(), input_name: String::new(), + input_stream_id: String::new(), }; let series_container = ChannelSelection { virtual_id: VirtualId::default(), @@ -913,6 +987,7 @@ mod tests { url: String::new(), title: "Series".to_string(), input_name: String::new(), + input_stream_id: String::new(), }; let episode = ChannelSelection { virtual_id: VirtualId::default(), @@ -921,6 +996,7 @@ mod tests { url: String::new(), title: "Episode".to_string(), input_name: String::new(), + input_stream_id: String::new(), }; assert!(!can_show_record_action(false, Some(&live))); @@ -930,4 +1006,60 @@ mod tests { assert!(!can_show_record_action(true, Some(&series_container))); assert!(can_show_record_action(true, Some(&episode))); } + + fn selection(cluster: XtreamCluster, input_stream_id: &str) -> ChannelSelection { + ChannelSelection { + virtual_id: VirtualId::default(), + cluster, + downloadable: cluster == XtreamCluster::Video, + url: String::new(), + title: "Channel".to_string(), + input_name: "input-a".to_string(), + input_stream_id: input_stream_id.to_string(), + } + } + + fn sources_with_failover(enabled: Option) -> SourcesConfigDto { + let options = enabled.map(|enabled| ConfigTargetOptions { + failover_groups: Some(FailoverGroupsTargetConfig { enabled, ..FailoverGroupsTargetConfig::default() }), + ..ConfigTargetOptions::default() + }); + SourcesConfigDto { + sources: vec![ConfigSourceDto { + inputs: vec!["input-a".to_string().into()], + targets: vec![ConfigTargetDto { id: 7, name: "Main TV".to_string(), options, ..Default::default() }], + }], + ..Default::default() + } + } + + #[test] + fn explorer_failover_group_actions_only_for_live_target_items() { + let live = selection(XtreamCluster::Live, "42"); + let vod = selection(XtreamCluster::Video, "42"); + let series = selection(XtreamCluster::Series, "42"); + let no_stream_id = selection(XtreamCluster::Live, ""); + + assert!(can_show_failover_group_actions(Some(&live), Some("Main TV"))); + assert!(!can_show_failover_group_actions(Some(&live), None), "target without failover groups"); + assert!(!can_show_failover_group_actions(Some(&vod), Some("Main TV"))); + assert!(!can_show_failover_group_actions(Some(&series), Some("Main TV"))); + assert!(!can_show_failover_group_actions(Some(&no_stream_id), Some("Main TV")), "no channel reference"); + assert!(!can_show_failover_group_actions(None, Some("Main TV"))); + + let enabled = sources_with_failover(Some(true)); + let disabled = sources_with_failover(Some(false)); + let unset = sources_with_failover(None); + let target = PlaylistRequest::Target(7); + let other_target = PlaylistRequest::Target(8); + let input = PlaylistRequest::Input("input-a".to_string()); + + assert_eq!(failover_group_target(Some(&target), Some(&enabled), "input-a").as_deref(), Some("Main TV")); + assert!(failover_group_target(Some(&target), Some(&disabled), "input-a").is_none()); + assert!(failover_group_target(Some(&target), Some(&unset), "input-a").is_none()); + assert!(failover_group_target(Some(&other_target), Some(&enabled), "input-a").is_none()); + assert!(failover_group_target(Some(&input), Some(&enabled), "input-a").is_none(), "only target playlists"); + assert!(failover_group_target(Some(&target), None, "input-a").is_none()); + assert!(failover_group_target(None, Some(&enabled), "input-a").is_none()); + } } diff --git a/frontend/src/app/components/playlist/target/target_options.rs b/frontend/src/app/components/playlist/target/target_options.rs index 222429cd6..8a6946fcd 100644 --- a/frontend/src/app/components/playlist/target/target_options.rs +++ b/frontend/src/app/components/playlist/target/target_options.rs @@ -20,7 +20,7 @@ pub fn TargetOptions(props: &TargetOptionsProps) -> Html { None => ( vec![false, false, false, false, false, false], vec![ - (false, "LABEL.CHANNEL_GROUPS"), + (false, "LABEL.FAILOVER_GROUPS"), (false, "LABEL.IGNORE_LOGO"), (false, "LABEL.SHARE_LIVE_STREAMS"), (false, "LABEL.HLS"), @@ -38,10 +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); + let failover_groups = options.failover_groups.as_ref().is_some_and(|groups| groups.enabled); ( vec![ - channel_groups, + failover_groups, options.ignore_logo, options.share_live_any_enabled(), options.share_live_hls_enabled(), @@ -52,7 +52,7 @@ pub fn TargetOptions(props: &TargetOptionsProps) -> Html { force_redirect[2].0, ], vec![ - (channel_groups, "LABEL.CHANNEL_GROUPS"), + (failover_groups, "LABEL.FAILOVER_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/recording/recording_form.rs b/frontend/src/app/components/recording/recording_form.rs index 2ebaad071..febe9ff08 100644 --- a/frontend/src/app/components/recording/recording_form.rs +++ b/frontend/src/app/components/recording/recording_form.rs @@ -182,17 +182,25 @@ pub fn can_pick_shared(has_recording_manage: bool, is_admin_role: bool) -> bool has_recording_manage && is_admin_role } +pub fn target_for_id<'a>( + sources: &'a shared::model::SourcesConfigDto, + target_id: u16, + input_name: Option<&str>, +) -> Option<&'a shared::model::ConfigTargetDto> { + sources.sources.iter().find_map(|source| { + if input_name.is_some_and(|name| !source.inputs.iter().any(|configured| configured.as_ref() == name)) { + return None; + } + source.targets.iter().find(|target| target.id == target_id) + }) +} + pub fn target_name_for_id( sources: &shared::model::SourcesConfigDto, target_id: u16, input_name: Option<&str>, ) -> Option { - sources.sources.iter().find_map(|source| { - if input_name.is_some_and(|name| !source.inputs.iter().any(|configured| configured.as_ref() == name)) { - return None; - } - source.targets.iter().find(|target| target.id == target_id).map(|target| target.name.clone()) - }) + target_for_id(sources, target_id, input_name).map(|target| target.name.clone()) } /// Translate the user's form choice into the wire enum. The server's diff --git a/frontend/src/app/components/setup/setup_helpers.rs b/frontend/src/app/components/setup/setup_helpers.rs index b77ef1d7a..35ad9e202 100644 --- a/frontend/src/app/components/setup/setup_helpers.rs +++ b/frontend/src/app/components/setup/setup_helpers.rs @@ -195,8 +195,8 @@ 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::FailoverGroups(_, failover_groups_cfg) => { + config.failover_groups = (!failover_groups_cfg.is_default()).then_some(failover_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 caf20eb1c..44fe494df 100644 --- a/frontend/src/app/components/setup/step_config.rs +++ b/frontend/src/app/components/setup/step_config.rs @@ -76,7 +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(), + ConfigForm::FailoverGroups(_, _) => setup_ctx.config_forms.slots.failover_groups.as_ref(), }; if current_slot == Some(&form_data) { return; diff --git a/frontend/src/app/components/sidebar.rs b/frontend/src/app/components/sidebar.rs index e54e9674d..26d5eb50f 100644 --- a/frontend/src/app/components/sidebar.rs +++ b/frontend/src/app/components/sidebar.rs @@ -3,6 +3,7 @@ use crate::{ hooks::use_service_context, i18n::use_translation, model::ViewType, + services::AuthService, utils::{get_local_storage_item, html_if, set_local_storage_item, MOBILE_BREAKPOINT_PX}, }; use shared::model::permission::Permission; @@ -72,6 +73,132 @@ pub const fn show_stream_history_nav(has_system_read: bool, stream_history_enabl has_system_read && stream_history_enabled } +/// What the signed-in user may open; the one input of the navigation, whether it is shown expanded or collapsed. +#[derive(Debug, Clone, Copy, Default)] +struct NavAccess { + admin: bool, + system_read: bool, + config_read: bool, + source_read: bool, + user_read: bool, + user_write: bool, + playlist_read: bool, + playlist_write: bool, + epg_read: bool, + show_streams_page: bool, + show_stream_history: bool, + show_recording: bool, +} + +impl NavAccess { + fn new(auth: &AuthService, props: &SidebarProps) -> Self { + Self { + admin: auth.is_admin(), + system_read: auth.has_permission(Permission::SystemRead), + config_read: auth.has_permission(Permission::ConfigRead), + source_read: auth.has_permission(Permission::SourceRead), + user_read: auth.has_permission(Permission::UserRead), + user_write: auth.has_permission(Permission::UserWrite), + playlist_read: auth.has_permission(Permission::PlaylistRead), + playlist_write: auth.has_permission(Permission::PlaylistWrite), + epg_read: auth.has_permission(Permission::EpgRead), + show_streams_page: props.show_streams_page, + show_stream_history: show_stream_history_nav( + auth.has_permission(Permission::SystemRead), + props.show_stream_history, + ), + show_recording: show_recording_nav(auth.has_permission(Permission::RecordingRead), props.show_recording), + } + } +} + +/// One navigation entry; the collapsed sidebar may show another icon than the expanded one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct NavEntry { + view: ViewType, + icon: &'static str, + collapsed_icon: &'static str, + label_key: &'static str, +} + +impl NavEntry { + const fn new(view: ViewType, icon: &'static str, label_key: &'static str) -> Self { + Self { view, icon, collapsed_icon: icon, label_key } + } + + const fn collapsed_icon(self, collapsed_icon: &'static str) -> Self { Self { collapsed_icon, ..self } } +} + +/// Entries the user may see. A titled section is a collapsible panel when expanded and is preceded by a spacer +/// when collapsed; the untitled section is always shown as is. +#[derive(Debug, Clone, PartialEq, Eq)] +struct NavSection { + title_key: Option<&'static str>, + entries: Vec, +} + +fn nav_section(title_key: Option<&'static str>, entries: &[(bool, NavEntry)]) -> NavSection { + NavSection { title_key, entries: entries.iter().filter_map(|&(visible, entry)| visible.then_some(entry)).collect() } +} + +/// The navigation of the sidebar in display order: the sections and entries the user may see, titled sections +/// without any visible entry left out. +fn nav_sections(access: &NavAccess) -> Vec { + let sections = [ + nav_section( + None, + &[ + (true, NavEntry::new(ViewType::Dashboard, "DashboardOutline", "LABEL.DASHBOARD")), + (access.system_read, NavEntry::new(ViewType::Stats, "Stats", "LABEL.STATS")), + ( + access.show_streams_page && access.system_read, + NavEntry::new(ViewType::Streams, "Streams", "LABEL.STREAMS"), + ), + (access.show_stream_history, NavEntry::new(ViewType::StreamHistory, "Log", "LABEL.STREAM_HISTORY")), + ], + ), + nav_section( + Some("LABEL.SETTINGS"), + &[ + (access.admin, NavEntry::new(ViewType::Rbac, "Shield", "LABEL.RBAC")), + (access.config_read, NavEntry::new(ViewType::Config, "Config", "LABEL.CONFIG")), + (access.user_read || access.user_write, NavEntry::new(ViewType::Users, "UserOutline", "LABEL.USER")), + (access.config_read, NavEntry::new(ViewType::Plans, "Group", "LABEL.PLANS")), + (access.source_read, NavEntry::new(ViewType::SourceEditor, "SourceEditor", "LABEL.SOURCE_EDITOR")), + ( + access.source_read || access.playlist_read, + NavEntry::new(ViewType::PlaylistSettings, "PlayArrowOutline", "LABEL.PLAYLIST"), + ), + ], + ), + nav_section( + Some("LABEL.PLAYLIST"), + &[ + (access.playlist_write, NavEntry::new(ViewType::PlaylistUpdate, "Refresh", "LABEL.UPDATE")), + (access.playlist_read, NavEntry::new(ViewType::PlaylistExplorer, "Live", "LABEL.PLAYLIST_VIEWER")), + (access.playlist_read, NavEntry::new(ViewType::FailoverGroups, "Group", "LABEL.FAILOVER_GROUPS")), + (access.epg_read, NavEntry::new(ViewType::PlaylistEpg, "Epg", "LABEL.PLAYLIST_EPG")), + ], + ), + nav_section( + Some("LABEL.RECORDING"), + &[ + ( + access.show_recording, + NavEntry::new(ViewType::RecordingLibrary, "DVR", "LABEL.RECORDING_LIBRARY") + .collapsed_icon("LibraryOutline"), + ), + ( + access.show_recording, + NavEntry::new(ViewType::RecordingRules, "DVR", "LABEL.RECORDING_RULES") + .collapsed_icon("ScheduleOutline"), + ), + ], + ), + ]; + sections.into_iter().filter(|section| section.title_key.is_none() || !section.entries.is_empty()).collect() +} + #[component] pub fn Sidebar(props: &SidebarProps) -> Html { let services = use_service_context(); @@ -84,10 +211,7 @@ pub fn Sidebar(props: &SidebarProps) -> Html { let is_mobile = use_state(|| false); let resolved_state = resolved_sidebar_state(*collapsed, *is_mobile); let active_menu = props.active_page; - let show_recording = - show_recording_nav(services.auth.has_permission(Permission::RecordingRead), props.show_recording); - let show_stream_history = - show_stream_history_nav(services.auth.has_permission(Permission::SystemRead), props.show_stream_history); + let access = NavAccess::new(&services.auth, props); let handle_menu_click = { let viewchange = props.onview.clone(); @@ -198,150 +322,43 @@ pub fn Sidebar(props: &SidebarProps) -> Html { }); } + let sections = nav_sections(&access); + let render_expanded = || { - let auth = &services.auth; html! {
- - {html_if!(auth.has_permission(Permission::SystemRead), { - - })} - {html_if!(props.show_streams_page && auth.has_permission(Permission::SystemRead), { - - })} - {html_if!(show_stream_history, { - - })} - {html_if!( - auth.has_any_permissions( - Permission::ConfigRead - | Permission::SourceRead - | Permission::UserRead - | Permission::UserWrite - | Permission::PlaylistRead, - ), - { - - {html_if!(auth.is_admin(), { - - })} - {html_if!(auth.has_permission(Permission::ConfigRead), { - - })} - {html_if!(auth.has_any_permissions(Permission::UserRead | Permission::UserWrite), { - - })} - {html_if!(auth.has_permission(Permission::ConfigRead), { - - })} - {html_if!(auth.has_permission(Permission::SourceRead), { - - })} - {html_if!(auth.has_any_permissions(Permission::SourceRead | Permission::PlaylistRead), { - - })} - + { for sections.iter().map(|section| { + let items = html! { + { for section.entries.iter().map(|entry| html! { + + }) } + }; + match section.title_key { + Some(title_key) => html! { { items } }, + None => items, } - )} - {html_if!( - auth.has_any_permissions(Permission::PlaylistRead | Permission::PlaylistWrite | Permission::EpgRead), - { - - {html_if!(auth.has_permission(Permission::PlaylistWrite), { - - })} - {html_if!(auth.has_permission(Permission::PlaylistRead), { - <> - - - })} - {html_if!(auth.has_permission(Permission::EpgRead), { - - })} - - } - )} - {html_if!( - show_recording, - { - - - - - } - )} + }) }
} }; let render_collapsed = || { - let auth = &services.auth; html! {
- - {html_if!(auth.has_permission(Permission::SystemRead), { - - })} - {html_if!(props.show_streams_page && auth.has_permission(Permission::SystemRead), { - - })} - {html_if!(show_stream_history, { - - })} - {html_if!( - auth.has_any_permissions( - Permission::ConfigRead - | Permission::SourceRead - | Permission::UserRead - | Permission::UserWrite - | Permission::PlaylistRead, - ), - { - - } - )} - {html_if!(auth.is_admin(), { - - })} - {html_if!(auth.has_permission(Permission::ConfigRead), { - - })} - {html_if!(auth.has_any_permissions(Permission::UserRead | Permission::UserWrite), { - - })} - {html_if!(auth.has_permission(Permission::ConfigRead), { - - })} - {html_if!(auth.has_permission(Permission::SourceRead), { - - })} - {html_if!(auth.has_any_permissions(Permission::SourceRead | Permission::PlaylistRead), { - - })} - {html_if!( - auth.has_any_permissions(Permission::PlaylistRead | Permission::PlaylistWrite | Permission::EpgRead), - { - - } - )} - {html_if!(auth.has_permission(Permission::PlaylistWrite), { - - })} - {html_if!(auth.has_permission(Permission::PlaylistRead), { - <> - - - })} - {html_if!(auth.has_permission(Permission::EpgRead), { - - })} - {html_if!(show_recording, { + { for sections.iter().map(|section| html! { <> - - - + { html_if!(section.title_key.is_some(), { }) } + { for section.entries.iter().map(|entry| { + let label = translate.t(entry.label_key); + html! { + + } + }) } - })} + }) }
} }; @@ -404,8 +421,32 @@ pub fn Sidebar(props: &SidebarProps) -> Html { #[cfg(test)] mod tests { - use super::{is_sidebar_expanded, resolved_sidebar_state, sidebar_variant_class, CollapseState}; - use crate::app::components::show_recording_nav; + use super::{ + is_sidebar_expanded, nav_sections, resolved_sidebar_state, sidebar_variant_class, CollapseState, NavAccess, + NavSection, + }; + use crate::{app::components::show_recording_nav, model::ViewType}; + + fn full_access() -> NavAccess { + NavAccess { + admin: true, + system_read: true, + config_read: true, + source_read: true, + user_read: true, + user_write: true, + playlist_read: true, + playlist_write: true, + epg_read: true, + show_streams_page: true, + show_stream_history: true, + show_recording: true, + } + } + + fn views(sections: &[NavSection]) -> Vec { + sections.iter().flat_map(|section| section.entries.iter().map(|entry| entry.view)).collect() + } #[test] fn sidebar_variant_class_reports_collapsed_variants() { @@ -449,4 +490,77 @@ mod tests { assert!(!super::show_stream_history_nav(false, true)); assert!(!super::show_stream_history_nav(false, false)); } + + #[test] + fn nav_without_any_access_offers_only_the_dashboard() { + let sections = nav_sections(&NavAccess::default()); + assert_eq!(views(§ions), vec![ViewType::Dashboard]); + assert_eq!(sections.len(), 1); + assert_eq!(sections[0].title_key, None); + } + + #[test] + fn nav_with_full_access_keeps_the_display_order() { + let sections = nav_sections(&full_access()); + assert_eq!( + views(§ions), + vec![ + ViewType::Dashboard, + ViewType::Stats, + ViewType::Streams, + ViewType::StreamHistory, + ViewType::Rbac, + ViewType::Config, + ViewType::Users, + ViewType::Plans, + ViewType::SourceEditor, + ViewType::PlaylistSettings, + ViewType::PlaylistUpdate, + ViewType::PlaylistExplorer, + ViewType::FailoverGroups, + ViewType::PlaylistEpg, + ViewType::RecordingLibrary, + ViewType::RecordingRules, + ] + ); + let titles: Vec<_> = sections.iter().map(|section| section.title_key).collect(); + assert_eq!(titles, vec![None, Some("LABEL.SETTINGS"), Some("LABEL.PLAYLIST"), Some("LABEL.RECORDING")]); + } + + #[test] + fn nav_entries_follow_their_permissions() { + let playlist_reader = NavAccess { playlist_read: true, ..NavAccess::default() }; + assert_eq!( + views(&nav_sections(&playlist_reader)), + vec![ViewType::Dashboard, ViewType::PlaylistSettings, ViewType::PlaylistExplorer, ViewType::FailoverGroups] + ); + + let epg_reader = NavAccess { epg_read: true, ..NavAccess::default() }; + assert_eq!(views(&nav_sections(&epg_reader)), vec![ViewType::Dashboard, ViewType::PlaylistEpg]); + + let streams_without_system_read = NavAccess { show_streams_page: true, ..NavAccess::default() }; + assert_eq!(views(&nav_sections(&streams_without_system_read)), vec![ViewType::Dashboard]); + + let user_writer = NavAccess { user_write: true, ..NavAccess::default() }; + assert_eq!(views(&nav_sections(&user_writer)), vec![ViewType::Dashboard, ViewType::Users]); + + let config_reader = NavAccess { config_read: true, ..NavAccess::default() }; + assert_eq!(views(&nav_sections(&config_reader)), vec![ViewType::Dashboard, ViewType::Config, ViewType::Plans]); + } + + #[test] + fn nav_recording_entries_use_other_icons_when_collapsed() { + let sections = nav_sections(&NavAccess { show_recording: true, ..NavAccess::default() }); + let recording = sections.last().expect("recording section"); + assert_eq!(recording.title_key, Some("LABEL.RECORDING")); + assert_eq!( + recording.entries.iter().map(|entry| (entry.icon, entry.collapsed_icon)).collect::>(), + vec![("DVR", "LibraryOutline"), ("DVR", "ScheduleOutline")] + ); + assert!(nav_sections(&full_access()) + .iter() + .flat_map(|section| §ion.entries) + .filter(|entry| !matches!(entry.view, ViewType::RecordingLibrary | ViewType::RecordingRules)) + .all(|entry| entry.icon == entry.collapsed_icon)); + } } diff --git a/frontend/src/app/components/source_editor/editor_view.rs b/frontend/src/app/components/source_editor/editor_view.rs index 3e9810864..a7d50d529 100644 --- a/frontend/src/app/components/source_editor/editor_view.rs +++ b/frontend/src/app/components/source_editor/editor_view.rs @@ -8,7 +8,7 @@ use crate::{ }, ConfigContext, PlaylistContext, }, - hooks::{is_text_input_focused, use_key_down, use_service_context}, + hooks::{is_text_input_focused, use_key_down, use_service_context, use_unsaved_changes_guard}, i18n::use_translation, model::DialogResult, services::DialogService, @@ -28,9 +28,7 @@ use std::{ rc::Rc, }; use wasm_bindgen::{prelude::Closure, JsCast}; -use web_sys::{ - window, BeforeUnloadEvent, Element, Event, HtmlElement, KeyboardEvent, MouseEvent, TouchEvent, WheelEvent, -}; +use web_sys::{window, Element, Event, HtmlElement, KeyboardEvent, MouseEvent, TouchEvent, WheelEvent}; use yew::{platform::spawn_local, prelude::*}; const PENDING_LINE: &str = "pending-line"; @@ -727,28 +725,7 @@ pub fn SourceEditor(props: &SourceEditorProps) -> Html { }; // Warn before the browser unloads while the editor holds unsaved changes. - { - let dirty = *is_dirty && !is_local_mode; - use_effect_with(dirty, move |&dirty| { - let closure = Closure::::wrap(Box::new(move |event: BeforeUnloadEvent| { - event.prevent_default(); - event.set_return_value(""); - })); - if dirty { - if let Some(win) = window() { - let _ = win.add_event_listener_with_callback("beforeunload", closure.as_ref().unchecked_ref()); - } - } - move || { - if dirty { - if let Some(win) = window() { - let _ = - win.remove_event_listener_with_callback("beforeunload", closure.as_ref().unchecked_ref()); - } - } - } - }); - } + use_unsaved_changes_guard(*is_dirty && !is_local_mode); { let playlists = playlist_ctx.clone(); diff --git a/frontend/src/app/components/source_editor/channel_groups_form.rs b/frontend/src/app/components/source_editor/failover_groups_form.rs similarity index 68% rename from frontend/src/app/components/source_editor/channel_groups_form.rs rename to frontend/src/app/components/source_editor/failover_groups_form.rs index a5156aec4..e1817036d 100644 --- a/frontend/src/app/components/source_editor/channel_groups_form.rs +++ b/frontend/src/app/components/source_editor/failover_groups_form.rs @@ -1,4 +1,4 @@ -//! Channel group page of a target: the enable switch and the overrides of the global settings. +//! Failover group page of a target: the enable switch and the overrides of the global settings. use crate::{ app::{ @@ -8,33 +8,33 @@ use crate::{ i18n::use_translation, }; use shared::model::{ - ChannelGroupPolicy, ChannelGroupsConfig, ChannelGroupsTargetConfig, ChannelMatchBy, EpgConflictMode, + ChannelMatchBy, EpgConflictMode, FailoverGroupPolicy, FailoverGroupsConfig, FailoverGroupsTargetConfig, }; 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"; +const LABEL_GLOBAL: &str = "LABEL.FAILOVER_GROUPS_GLOBAL"; +const INFO_TARGET: &str = "INFO.FAILOVER_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>, +pub(super) fn toggle_failover_groups( + current: Option<&FailoverGroupsTargetConfig>, enabled: bool, -) -> Option { - edit_channel_groups(current, |config| config.enabled = enabled) +) -> Option { + edit_failover_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 { +pub(super) fn edit_failover_groups( + current: Option<&FailoverGroupsTargetConfig>, + edit: impl FnOnce(&mut FailoverGroupsTargetConfig), +) -> Option { let mut config = current.copied().unwrap_or_default(); edit(&mut config); (!config.is_default()).then_some(config) @@ -54,26 +54,26 @@ 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, + get: fn(&FailoverGroupsTargetConfig) -> Option, + set: fn(&mut FailoverGroupsTargetConfig, Option), + global: fn(&FailoverGroupsConfig) -> T, } #[derive(Properties, PartialEq)] -pub(super) struct TargetChannelGroupsViewProps { - pub config: Option, +pub(super) struct TargetFailoverGroupsViewProps { + pub config: Option, pub readonly: bool, - pub on_change: Callback>, + pub on_change: Callback>, } #[component] -pub(super) fn TargetChannelGroupsView(props: &TargetChannelGroupsViewProps) -> Html { +pub(super) fn TargetFailoverGroupsView(props: &TargetFailoverGroupsViewProps) -> 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()) + .and_then(|app_config| app_config.config.failover_groups.clone()) .unwrap_or_default(); let current = props.config.unwrap_or_default(); let readonly = props.readonly; @@ -81,7 +81,7 @@ pub(super) fn TargetChannelGroupsView(props: &TargetChannelGroupsViewProps) -> H 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))) + Callback::from(move |enabled: bool| on_change.emit(toggle_failover_groups(config.as_ref(), enabled))) }; let bool_label = |value: bool| translate.t(if value { LABEL_ENABLED } else { LABEL_DISABLED }); @@ -129,7 +129,7 @@ pub(super) fn TargetChannelGroupsView(props: &TargetChannelGroupsViewProps) -> H Callback::from(move |(_, selection): (String, DropDownSelection)| { let Some(id) = selection_first(&selection) else { return }; let value = parse_override(id); - on_change.emit(edit_channel_groups(config.as_ref(), |config| set(config, value))); + on_change.emit(edit_failover_groups(config.as_ref(), |config| set(config, value))); }) }; render(field.label, field.field_id, options, shown, on_select) @@ -140,56 +140,56 @@ pub(super) fn TargetChannelGroupsView(props: &TargetChannelGroupsViewProps) -> H let rows = html! { <> { override_row!(OverrideField:: { - label: "LABEL.CHANNEL_GROUPS_MATCH_BY", - field_id: "CHANNEL_GROUPS_CONFIG.MATCH_BY", + label: "LABEL.FAILOVER_GROUPS_MATCH_BY", + field_id: "FAILOVER_GROUPS_CONFIG.MATCH_BY", values: ChannelMatchBy::ALL, get: |config| config.match_by, set: |config, value| config.match_by = value, global: |global| global.match_by, }, |value: ChannelMatchBy| config_name(&value)) } { override_row!(OverrideField:: { - label: "LABEL.CHANNEL_GROUPS_MATCH_AS_ASCII", - field_id: "CHANNEL_GROUPS_CONFIG.MATCH_AS_ASCII", + label: "LABEL.FAILOVER_GROUPS_MATCH_AS_ASCII", + field_id: "FAILOVER_GROUPS_CONFIG.MATCH_AS_ASCII", values: &[true, false], get: |config| config.match_as_ascii, set: |config, value| config.match_as_ascii = value, global: |global| global.match_as_ascii, }, bool_label) } { override_row!(OverrideField:: { - label: "LABEL.CHANNEL_GROUPS_EPG_CONFLICT", - field_id: "CHANNEL_GROUPS_CONFIG.EPG_CONFLICT", + label: "LABEL.FAILOVER_GROUPS_EPG_CONFLICT", + field_id: "FAILOVER_GROUPS_CONFIG.EPG_CONFLICT", values: EpgConflictMode::ALL, get: |config| config.epg_conflict, set: |config, value| config.epg_conflict = value, global: |global| global.epg_conflict, }, |value: EpgConflictMode| config_name(&value)) } { override_row!(OverrideField:: { - label: "LABEL.CHANNEL_GROUPS_NAME_STRIP_QUALITY", - field_id: "CHANNEL_GROUPS_CONFIG.NAME_STRIP_QUALITY", + label: "LABEL.FAILOVER_GROUPS_NAME_STRIP_QUALITY", + field_id: "FAILOVER_GROUPS_CONFIG.NAME_STRIP_QUALITY", values: &[true, false], get: |config| config.name_strip_quality, set: |config, value| config.name_strip_quality = value, global: |global| global.name_strip_quality, }, bool_label) } - { override_row!(OverrideField:: { - label: "LABEL.CHANNEL_GROUPS_POLICY", - field_id: "CHANNEL_GROUPS_CONFIG.POLICY", - values: ChannelGroupPolicy::ALL, + { override_row!(OverrideField:: { + label: "LABEL.FAILOVER_GROUPS_POLICY", + field_id: "FAILOVER_GROUPS_CONFIG.POLICY", + values: FailoverGroupPolicy::ALL, get: |config| config.policy, set: |config, value| config.policy = value, global: |global| global.policy, - }, |value: ChannelGroupPolicy| config_name(&value)) } + }, |value: FailoverGroupPolicy| config_name(&value)) } { override_row!(OverrideField:: { - label: "LABEL.CHANNEL_GROUPS_ALLOW_PROVIDER_GRACE", - field_id: "CHANNEL_GROUPS_CONFIG.ALLOW_PROVIDER_GRACE", + label: "LABEL.FAILOVER_GROUPS_ALLOW_PROVIDER_GRACE", + field_id: "FAILOVER_GROUPS_CONFIG.ALLOW_PROVIDER_GRACE", values: &[true, false], get: |config| config.allow_provider_grace, set: |config, value| config.allow_provider_grace = value, global: |global| global.allow_provider_grace, }, bool_label) } { override_row!(OverrideField:: { - label: "LABEL.CHANNEL_GROUPS_FAILOVER", - field_id: "CHANNEL_GROUP_FAILOVER_CONFIG.ENABLED", + label: "LABEL.FAILOVER_GROUPS_RUNTIME", + field_id: "FAILOVER_GROUP_RUNTIME_CONFIG.ENABLED", values: &[true, false], get: |config| config.failover, set: |config, value| config.failover = value, @@ -203,7 +203,7 @@ pub(super) fn TargetChannelGroupsView(props: &TargetChannelGroupsViewProps) -> H
{ translate.t(INFO_TARGET) }
- +
{ rows }
@@ -212,39 +212,42 @@ pub(super) fn TargetChannelGroupsView(props: &TargetChannelGroupsViewProps) -> H #[cfg(test)] mod tests { - use super::{edit_channel_groups, parse_override, toggle_channel_groups, GLOBAL}; - use shared::model::{ChannelGroupsTargetConfig, ChannelMatchBy}; + use super::{edit_failover_groups, parse_override, toggle_failover_groups, GLOBAL}; + use shared::model::{ChannelMatchBy, FailoverGroupsTargetConfig}; #[test] fn enabling_creates_a_block_without_overrides() { - let config = toggle_channel_groups(None, true).expect("block"); - assert_eq!(config, ChannelGroupsTargetConfig { enabled: true, ..ChannelGroupsTargetConfig::default() }); + let config = toggle_failover_groups(None, true).expect("block"); + assert_eq!(config, FailoverGroupsTargetConfig { enabled: true, ..FailoverGroupsTargetConfig::default() }); } #[test] fn disabling_drops_a_block_without_overrides() { - let enabled = ChannelGroupsTargetConfig { enabled: true, ..ChannelGroupsTargetConfig::default() }; - assert_eq!(toggle_channel_groups(Some(&enabled), false), None); + let enabled = FailoverGroupsTargetConfig { enabled: true, ..FailoverGroupsTargetConfig::default() }; + assert_eq!(toggle_failover_groups(Some(&enabled), false), None); } #[test] fn disabling_keeps_overrides() { - let customized = ChannelGroupsTargetConfig { + let customized = FailoverGroupsTargetConfig { enabled: true, match_by: Some(ChannelMatchBy::Name), - ..ChannelGroupsTargetConfig::default() + ..FailoverGroupsTargetConfig::default() }; - let disabled = toggle_channel_groups(Some(&customized), false).expect("overrides kept"); + let disabled = toggle_failover_groups(Some(&customized), false).expect("overrides kept"); assert!(!disabled.enabled); assert_eq!(disabled.match_by, Some(ChannelMatchBy::Name)); } #[test] fn editing_changes_only_the_edited_override() { - let current = - ChannelGroupsTargetConfig { enabled: true, failover: Some(false), ..ChannelGroupsTargetConfig::default() }; - let edited = - edit_channel_groups(Some(¤t), |config| config.match_by = Some(ChannelMatchBy::Title)).expect("block"); + let current = FailoverGroupsTargetConfig { + enabled: true, + failover: Some(false), + ..FailoverGroupsTargetConfig::default() + }; + let edited = edit_failover_groups(Some(¤t), |config| config.match_by = Some(ChannelMatchBy::Title)) + .expect("block"); assert_eq!((edited.failover, edited.match_by), (Some(false), Some(ChannelMatchBy::Title))); assert!(edited.enabled); } diff --git a/frontend/src/app/components/source_editor/mod.rs b/frontend/src/app/components/source_editor/mod.rs index 973483b43..8e0b5a61b 100644 --- a/frontend/src/app/components/source_editor/mod.rs +++ b/frontend/src/app/components/source_editor/mod.rs @@ -1,11 +1,11 @@ mod alias_item_form; mod block_view; -mod channel_groups_form; mod editor_form; mod editor_model; mod editor_view; mod epg_smart_match_form; mod epg_source_item_form; +mod failover_groups_form; mod input_form; mod layout; mod output_form; diff --git a/frontend/src/app/components/source_editor/target_form.rs b/frontend/src/app/components/source_editor/target_form.rs index d9a5acf86..c19ff1b79 100644 --- a/frontend/src/app/components/source_editor/target_form.rs +++ b/frontend/src/app/components/source_editor/target_form.rs @@ -1,9 +1,12 @@ -use super::{channel_groups_form::TargetChannelGroupsView, WatchEntriesEditor}; +use super::{failover_groups_form::TargetFailoverGroupsView, WatchEntriesEditor}; use crate::{ - app::components::{ - build_options, config::HasFormData, dto_field_id, select::Select, selection_parse_first, AppIcon, BlockId, - BlockInstance, Card, ClusterFlagsInput, ClusterFlagsInputMode, DropDownSelection, EditMode, FilterInput, - IconButton, Panel, SourceEditorContext, TextButton, ToggleSwitch, + app::{ + components::{ + build_options, config::HasFormData, dto_field_id, failover_groups::FailoverGroupsPreselect, select::Select, + selection_parse_first, AppIcon, BlockId, BlockInstance, Card, ClusterFlagsInput, ClusterFlagsInputMode, + DropDownSelection, EditMode, FilterInput, IconButton, Panel, SourceEditorContext, TextButton, ToggleSwitch, + }, + context::FailoverGroupsNavigation, }, config_field, config_field_bool, config_field_child, config_field_custom, edit_field_bool, edit_field_list_option, edit_field_text, generate_form_reducer, @@ -14,8 +17,8 @@ use crate::{ use shared::{ error::TuliproxError, model::{ - permission::Permission, ChannelGroupsTargetConfig, ClusterFlags, ConfigTargetDto, ConfigTargetFilterDto, - ConfigTargetOptions, ConfigWatchDto, CurationCatalogSelection, CurationConfigDto, ProcessingOrder, + permission::Permission, ClusterFlags, ConfigTargetDto, ConfigTargetFilterDto, ConfigTargetOptions, + ConfigWatchDto, CurationCatalogSelection, CurationConfigDto, FailoverGroupsTargetConfig, ProcessingOrder, TargetBouquetMode, TargetBouquetStatusDto, }, utils::Internable, @@ -49,7 +52,7 @@ const LABEL_LOWERCASE_XMLTV_DISPLAY_NAMES: &str = "LABEL.LOWERCASE_XMLTV_DISPLAY const LABEL_MAIN: &str = "LABEL.MAIN_CONFIG"; const LABEL_OPTIONS: &str = "LABEL.OPTIONS"; const LABEL_BOUQUETS: &str = "LABEL.BOUQUETS"; -const LABEL_CHANNEL_GROUPS: &str = "LABEL.CHANNEL_GROUPS"; +const LABEL_FAILOVER_GROUPS: &str = "LABEL.FAILOVER_GROUPS"; const LABEL_CURATION_CATALOG_SELECTION_FULL: &str = "LABEL.TRAKT_CATALOG_SELECTION_FULL"; const LABEL_CURATION_CATALOG_SELECTION_CURATED: &str = "LABEL.TRAKT_CATALOG_SELECTION_CURATED"; @@ -79,7 +82,7 @@ fn curation_summary(curation: &CurationConfigDto, catalog_selection: &str) -> St enum TargetFormPage { Main, Options, - ChannelGroups, + FailoverGroups, Mapping, Watch, } @@ -87,7 +90,7 @@ enum TargetFormPage { impl TargetFormPage { const MAIN: &str = "Main"; const OPTIONS: &str = "Options"; - const CHANNEL_GROUPS: &str = "ChannelGroups"; + const FAILOVER_GROUPS: &str = "FailoverGroups"; const MAPPING: &str = "Mapping"; const WATCH: &str = "Watch"; } @@ -99,7 +102,7 @@ impl FromStr for TargetFormPage { match s { Self::MAIN => Ok(TargetFormPage::Main), Self::OPTIONS => Ok(TargetFormPage::Options), - Self::CHANNEL_GROUPS => Ok(TargetFormPage::ChannelGroups), + Self::FAILOVER_GROUPS => Ok(TargetFormPage::FailoverGroups), Self::MAPPING => Ok(TargetFormPage::Mapping), Self::WATCH => Ok(TargetFormPage::Watch), _ => Err(TuliproxError::Config(format!("Unknown target form page: {s}"))), @@ -112,7 +115,7 @@ impl Display for TargetFormPage { match *self { TargetFormPage::Main => write!(f, "{}", TargetFormPage::MAIN), TargetFormPage::Options => write!(f, "{}", TargetFormPage::OPTIONS), - TargetFormPage::ChannelGroups => write!(f, "{}", TargetFormPage::CHANNEL_GROUPS), + TargetFormPage::FailoverGroups => write!(f, "{}", TargetFormPage::FAILOVER_GROUPS), TargetFormPage::Mapping => write!(f, "{}", TargetFormPage::MAPPING), TargetFormPage::Watch => write!(f, "{}", TargetFormPage::WATCH), } @@ -124,7 +127,7 @@ impl Internable for TargetFormPage { match self { Self::Main => TargetFormPage::MAIN, Self::Options => TargetFormPage::OPTIONS, - Self::ChannelGroups => TargetFormPage::CHANNEL_GROUPS, + Self::FailoverGroups => TargetFormPage::FAILOVER_GROUPS, Self::Mapping => TargetFormPage::MAPPING, Self::Watch => TargetFormPage::WATCH, } @@ -162,7 +165,7 @@ pub enum ConfigTargetOptionsFormAction { LowercaseEpgIds(bool), LowercaseXmltvDisplayNames(bool), ForceRedirect(Option), - ChannelGroups(Option), + FailoverGroups(Option), SetAll(ConfigTargetOptions), } @@ -210,8 +213,8 @@ impl yew::prelude::Reducible for ConfigTargetOptionsFormState { form.force_redirect = value; modified = true; } - ConfigTargetOptionsFormAction::ChannelGroups(value) => { - form.channel_groups = value; + ConfigTargetOptionsFormAction::FailoverGroups(value) => { + form.failover_groups = value; modified = true; } ConfigTargetOptionsFormAction::SetAll(value) => { @@ -251,6 +254,7 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html { let translate = use_translation(); let services = use_service_context(); let source_editor_ctx = use_context::().expect("SourceEditorContext not found"); + let failover_navigation = use_context::(); let target_form_state: UseReducerHandle = use_reducer(|| ConfigTargetFormState { form: ConfigTargetDto::default(), modified: false }); @@ -552,6 +556,30 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html { } }; + let render_failover_groups_link = || { + let enabled = target_options_state.form.failover_groups.is_some_and(|config| config.enabled); + if !can_read_bouquets || !enabled || bouquet_target_name.is_empty() { + return html! {}; + } + let on_open = { + let navigation = failover_navigation.clone(); + let target_name = bouquet_target_name.clone(); + Callback::from(move |_: String| { + if let Some(navigation) = navigation.as_ref() { + navigation.open(FailoverGroupsPreselect::for_target(&target_name)); + } + }) + }; + html! { + { config_field_child!(translate.t(LABEL_FAILOVER_GROUPS), "TARGET_FORM.FAILOVER_GROUPS_LINK", { + html! { + + } + }) } + } + }; + let render_target = || { let target_form_state_1 = target_form_state.clone(); let target_form_state_2 = target_form_state.clone(); @@ -574,6 +602,7 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html { } })} { render_bouquet_input() } + { render_failover_groups_link() } { config_field_child!(translate.t(LABEL_PERSIST_FILTER), "TARGET_FORM.PERSIST_FILTER", { html! { | { @@ -617,6 +646,7 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html { target_form_state.form.filter.persist.clone().unwrap_or_default() ) } { render_bouquet_input() } + { render_failover_groups_link() } { config_field_custom!( translate.t(LABEL_PROCESSING_ORDER), target_form_state.form.processing_order.to_string() @@ -626,17 +656,17 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html { } }; - let render_channel_groups = || { + let render_failover_groups = || { let on_change = { let target_options_state = target_options_state.clone(); Callback::from(move |value| { - target_options_state.dispatch(ConfigTargetOptionsFormAction::ChannelGroups(value)); + target_options_state.dispatch(ConfigTargetOptionsFormAction::FailoverGroups(value)); }) }; html! { - @@ -707,8 +737,8 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html { {render_options()} - - {render_channel_groups()} + + {render_failover_groups()} {render_mapping()} @@ -726,7 +756,7 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html {
- +
diff --git a/frontend/src/app/components/table/columns_panel.rs b/frontend/src/app/components/table/columns_panel.rs index b6e38d014..c00826374 100644 --- a/frontend/src/app/components/table/columns_panel.rs +++ b/frontend/src/app/components/table/columns_panel.rs @@ -732,11 +732,7 @@ pub fn TableColumnsPanel(props: &TableColumnsPanelProps) -> Html { } else {

{translate.t("TABLE_COLUMNS.LIMIT")}

} }; - if let Some(body) = web_sys::window().and_then(|window| window.document()).and_then(|document| document.body()) { - yew::create_portal(content, body.into()) - } else { - content - } + crate::utils::in_body(content) } #[cfg(test)] diff --git a/frontend/src/app/context.rs b/frontend/src/app/context.rs index 0a6df301e..34e93f4ac 100644 --- a/frontend/src/app/context.rs +++ b/frontend/src/app/context.rs @@ -1,4 +1,8 @@ -use crate::app::components::{InputRow, UserlistPage}; +use crate::{ + app::components::{failover_groups::FailoverGroupsPreselect, InputRow, UserlistPage}, + model::ViewType, + utils::set_location_hash, +}; use shared::model::{ ApiProxyConfigDto, AppConfigDto, ConfigTargetDto, PlaylistRequest, ProxyUserCredentialsDto, SearchRequest, StatusCheck, SystemInfo, TargetUserDto, UiPlaylistCategories, @@ -28,6 +32,23 @@ impl PartialEq for PlaylistExplorerContext { fn eq(&self, _other: &Self) -> bool { false } } +/// Hands a deep link request to the "Failover groups" page, which takes it over once. +#[derive(Clone)] +pub struct FailoverGroupsNavigation { + pub pending: UseStateHandle>>, +} + +impl PartialEq for FailoverGroupsNavigation { + fn eq(&self, _other: &Self) -> bool { false } +} + +impl FailoverGroupsNavigation { + pub fn open(&self, preselect: FailoverGroupsPreselect) { + self.pending.set(Some(Rc::new(preselect))); + set_location_hash(ViewType::FailoverGroups.as_str()); + } +} + #[derive(Debug, Clone, PartialEq)] pub struct TargetUser { pub target: String, diff --git a/frontend/src/hooks/mod.rs b/frontend/src/hooks/mod.rs index a2ac3d2c6..36bd23e6b 100644 --- a/frontend/src/hooks/mod.rs +++ b/frontend/src/hooks/mod.rs @@ -4,6 +4,7 @@ mod use_key_down; mod use_log_stream; mod use_server_status; mod use_service_context; +mod use_unsaved_changes_guard; mod use_websocket_status; pub use use_clipboard_copy::*; @@ -12,4 +13,5 @@ pub use use_key_down::*; pub use use_log_stream::*; pub use use_server_status::*; pub use use_service_context::*; +pub use use_unsaved_changes_guard::*; pub use use_websocket_status::*; diff --git a/frontend/src/hooks/use_unsaved_changes_guard.rs b/frontend/src/hooks/use_unsaved_changes_guard.rs new file mode 100644 index 000000000..01ad153db --- /dev/null +++ b/frontend/src/hooks/use_unsaved_changes_guard.rs @@ -0,0 +1,27 @@ +use wasm_bindgen::{closure::Closure, JsCast}; +use web_sys::BeforeUnloadEvent; +use yew::prelude::*; + +/// Asks the browser for confirmation before it unloads the page (refresh, close, navigation) while `dirty`. +#[hook] +pub fn use_unsaved_changes_guard(dirty: bool) { + use_effect_with(dirty, move |&dirty| { + let listener = dirty + .then(|| { + let window = web_sys::window()?; + let closure = + Closure::::wrap(Box::new(move |event: BeforeUnloadEvent| { + event.prevent_default(); + event.set_return_value(""); + })); + window.add_event_listener_with_callback("beforeunload", closure.as_ref().unchecked_ref()).ok()?; + Some((window, closure)) + }) + .flatten(); + move || { + if let Some((window, closure)) = listener { + let _ = window.remove_event_listener_with_callback("beforeunload", closure.as_ref().unchecked_ref()); + } + } + }); +} diff --git a/frontend/src/model/event_message.rs b/frontend/src/model/event_message.rs index 86b23d2e9..84c75cd23 100644 --- a/frontend/src/model/event_message.rs +++ b/frontend/src/model/event_message.rs @@ -30,6 +30,8 @@ pub enum EventMessage { }, RecordingRulesChanged, MaintenanceChanged(Rc), + /// Failover override sections of these targets changed on the server. + FailoverOverridesChanged(Rc>), /// The socket refused to serve recordings for an actionable reason. /// Carries the same stable code the REST routes use, so views map /// both through one i18n table. diff --git a/frontend/src/model/pipeline_transparency_model.rs b/frontend/src/model/pipeline_transparency_model.rs index 33b19a458..62e2405ed 100644 --- a/frontend/src/model/pipeline_transparency_model.rs +++ b/frontend/src/model/pipeline_transparency_model.rs @@ -754,6 +754,7 @@ mod tests { run_id: Some(run.into()), execution_order: Some(order.into()), state, + targets: Vec::new(), }, }; for terminal in [PlaylistUpdateState::Success, PlaylistUpdateState::Partial, PlaylistUpdateState::Failure] { diff --git a/frontend/src/model/playlist_update_card_status.rs b/frontend/src/model/playlist_update_card_status.rs index 3fd329825..3b8689df6 100644 --- a/frontend/src/model/playlist_update_card_status.rs +++ b/frontend/src/model/playlist_update_card_status.rs @@ -1568,6 +1568,7 @@ mod tests { run_id: Some(run_id("r1")), execution_order: None, state: PlaylistUpdateState::Success, + targets: Vec::new(), }, }, ); diff --git a/frontend/src/services/failover_service.rs b/frontend/src/services/failover_service.rs new file mode 100644 index 000000000..1afb0f9ba --- /dev/null +++ b/frontend/src/services/failover_service.rs @@ -0,0 +1,259 @@ +use crate::{ + error::{Error, ErrorInfo}, + services::{ + get_base_href, request_delete_raw, request_get, request_post, request_post_raw, request_put_raw, Encoding, + RawResponse, + }, + utils::encoding_for_query, +}; +use serde::de::DeserializeOwned; +use shared::{ + model::{ + CandidatePageDto, FailoverCandidateFilter, FailoverGroupConflictDto, FailoverGroupHealthDto, + FailoverGroupListDto, FailoverGroupSaveResultDto, FailoverGroupTargetsDto, OperationRunAccepted, + OverrideValidationError, ReassignOverridesRequestDto, SaveOverridesRequestDto, TargetFailoverOverridesDto, + TargetOverridesResponseDto, VirtualId, + }, + utils::concat_path_leading_slash, +}; + +/// Result of a write of the failover overrides; the answers a caller has to handle, not just report. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SaveOutcome { + Saved(FailoverGroupSaveResultDto), + /// The section changed since `expected_etag`; carries the etag found on the server. + Conflict(Option), + Invalid(Vec), + /// The failover file cannot be written (broken or read-only); carries the reason. + Locked(String), +} + +fn parse_body(body: &str) -> Result { + serde_json::from_str(body).map_err(|_| Error::DeserializeError) +} + +fn error_message(body: &str) -> String { + serde_json::from_str::(body).map_or_else(|_| body.trim().to_string(), |info| info.error) +} + +fn non_empty_or(message: String, fallback: &str) -> String { + if message.is_empty() { + fallback.to_string() + } else { + message + } +} + +/// Maps the answer of a write route to its outcome; statuses outside the contract become errors. +fn write_outcome(response: &RawResponse) -> Result { + let body = response.body.as_str(); + match response.status { + 200 => parse_body(body).map(SaveOutcome::Saved), + 409 => Ok(SaveOutcome::Conflict( + serde_json::from_str::(body).ok().and_then(|conflict| conflict.current_etag), + )), + 422 => parse_body(body).map(SaveOutcome::Invalid), + 423 => Ok(SaveOutcome::Locked(non_empty_or(error_message(body), "Failover file is locked"))), + 400 => Err(Error::BadRequest(non_empty_or(error_message(body), "400"))), + 401 => Err(Error::Unauthorized), + 403 => Err(Error::Forbidden(non_empty_or(error_message(body), "Forbidden"))), + 404 => Err(Error::NotFound), + 500 => Err(Error::InternalServerError(non_empty_or(error_message(body), "Internal Server Error"))), + _ => { + let message = error_message(body); + Err(if message.is_empty() { Error::RequestError } else { Error::HttpResponse(message) }) + } + } +} + +#[derive(Default)] +pub struct FailoverService; + +impl FailoverService { + pub fn new() -> Self { Self } + + fn collection_url_from_base(base_href: &str) -> String { + concat_path_leading_slash(base_href.trim_end_matches('/'), "api/v1/failover") + } + + /// `endpoint` below the failover routes with the given query parameters, values URL-encoded. + fn url(endpoint: &str, params: &[(&str, &str)]) -> String { + let collection = Self::collection_url_from_base(&get_base_href()); + let mut url = concat_path_leading_slash(&collection, endpoint); + for (index, (key, value)) in params.iter().enumerate() { + url.push(if index == 0 { '?' } else { '&' }); + url.push_str(key); + url.push('='); + url.push_str(&encoding_for_query(value)); + } + url + } + + async fn get(url: &str) -> Result + where + T: DeserializeOwned + 'static + std::fmt::Debug, + { + request_get::(url, None, Some(Encoding::Json)).await?.ok_or(Error::NotFound) + } + + pub async fn fetch_targets() -> Result { + Self::get(&Self::url("targets", &[])).await + } + + pub async fn fetch_groups(target: &str) -> Result { + Self::get(&Self::url("groups", &[("target", target)])).await + } + + pub async fn fetch_overrides(target: &str) -> Result { + Self::get(&Self::url("overrides", &[("target", target)])).await + } + + /// The groups the draft would produce; nothing is written. + pub async fn preview(target: &str, draft: &TargetFailoverOverridesDto) -> Result { + request_post::<_, FailoverGroupListDto>( + &Self::url("preview", &[("target", target)]), + draft, + Some(Encoding::Json), + Some(Encoding::Json), + ) + .await? + .ok_or(Error::NotFound) + } + + pub async fn search_candidates( + target: &str, + query: &str, + filter: FailoverCandidateFilter, + offset: usize, + limit: usize, + ) -> Result { + let filter = serde_json::to_value(filter).ok().and_then(|value| value.as_str().map(str::to_string)); + let offset = offset.to_string(); + let limit = limit.to_string(); + let url = Self::url( + "candidates", + &[ + ("target", target), + ("q", query), + ("filter", filter.as_deref().unwrap_or("all")), + ("offset", &offset), + ("limit", &limit), + ], + ); + Self::get(&url).await + } + + pub async fn fetch_health(target: &str, group_vid: VirtualId) -> Result { + let group = group_vid.get().to_string(); + Self::get(&Self::url("health", &[("target", target), ("group", &group)])).await + } + + /// Stores the section; `expected_etag` is the etag the draft is based on (`None` for a new section). + pub async fn save( + target: &str, + expected_etag: Option, + overrides: TargetFailoverOverridesDto, + ) -> Result { + let request = SaveOverridesRequestDto { expected_etag, overrides }; + write_outcome(&request_put_raw(&Self::url("overrides", &[("target", target)]), &request).await?) + } + + /// Removes the whole section of the target. + pub async fn delete(target: &str, expected_etag: &str) -> Result { + let url = Self::url("overrides", &[("target", target), ("expected_etag", expected_etag)]); + write_outcome(&request_delete_raw(&url).await?) + } + + /// Moves the section of `from` (typically an orphan) to the configured target `to`. + pub async fn reassign(from: &str, to: &str, expected_etag: &str) -> Result { + let request = ReassignOverridesRequestDto { + from: from.to_string(), + to: to.to_string(), + expected_etag: expected_etag.to_string(), + }; + write_outcome(&request_post_raw(&Self::url("overrides/reassign", &[]), &request).await?) + } + + /// Queues a playlist update for the target; a busy queue answers with `Error::Conflict`. + pub async fn apply(target: &str) -> Result { + Ok(request_post::<_, OperationRunAccepted>( + &Self::url("apply", &[("target", target)]), + (), + None, + Some(Encoding::Json), + ) + .await? + .unwrap_or_default()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn raw(status: u16, body: &str) -> RawResponse { RawResponse { status, body: body.to_string() } } + + #[test] + fn collection_url_never_becomes_protocol_relative() { + assert_eq!(FailoverService::collection_url_from_base("/"), "/api/v1/failover"); + assert_eq!(FailoverService::collection_url_from_base("/tuliprox/"), "/tuliprox/api/v1/failover"); + } + + #[test] + fn success_is_parsed_as_save_result() { + let outcome = write_outcome(&raw(200, r#"{"etag":"abc","file":"f.yml","hot_applied":true}"#)).expect("outcome"); + assert_eq!( + outcome, + SaveOutcome::Saved(FailoverGroupSaveResultDto { + etag: Some("abc".to_string()), + file: Some("f.yml".to_string()), + hot_applied: true, + }) + ); + } + + #[test] + fn conflict_carries_current_etag() { + assert_eq!( + write_outcome(&raw(409, r#"{"current_etag":"e7"}"#)).expect("outcome"), + SaveOutcome::Conflict(Some("e7".to_string())) + ); + assert_eq!(write_outcome(&raw(409, r#"{"current_etag":null}"#)).expect("outcome"), SaveOutcome::Conflict(None)); + assert_eq!(write_outcome(&raw(409, r#"{"error":"busy"}"#)).expect("outcome"), SaveOutcome::Conflict(None)); + } + + #[test] + fn unprocessable_carries_findings() { + let outcome = + write_outcome(&raw(422, r#"[{"error":"empty_manual_id"},{"error":"duplicate_manual_id","detail":"a"}]"#)) + .expect("outcome"); + assert_eq!( + outcome, + SaveOutcome::Invalid(vec![ + OverrideValidationError::EmptyManualId, + OverrideValidationError::DuplicateManualId("a".to_string()), + ]) + ); + } + + #[test] + fn locked_carries_reason() { + assert_eq!( + write_outcome(&raw(423, r#"{"error":"file has a parse error"}"#)).expect("outcome"), + SaveOutcome::Locked("file has a parse error".to_string()) + ); + assert_eq!( + write_outcome(&raw(423, "")).expect("outcome"), + SaveOutcome::Locked("Failover file is locked".to_string()) + ); + } + + #[test] + fn other_statuses_are_errors() { + assert_eq!(write_outcome(&raw(401, "")), Err(Error::Unauthorized)); + assert_eq!(write_outcome(&raw(404, r#"{"error":"x"}"#)), Err(Error::NotFound)); + assert_eq!(write_outcome(&raw(500, r#"{"error":"io"}"#)), Err(Error::InternalServerError("io".to_string()))); + assert_eq!(write_outcome(&raw(503, "")), Err(Error::RequestError)); + assert_eq!(write_outcome(&raw(200, "not json")), Err(Error::DeserializeError)); + } +} diff --git a/frontend/src/services/mod.rs b/frontend/src/services/mod.rs index e86aaf7d6..74b597204 100644 --- a/frontend/src/services/mod.rs +++ b/frontend/src/services/mod.rs @@ -2,6 +2,7 @@ mod auth_service; mod config_service; mod dialog_service; mod event_service; +mod failover_service; mod flags_service; mod maintenance_service; mod playlist_service; @@ -18,8 +19,8 @@ mod user_service; mod websocket_service; pub use self::{ - auth_service::*, config_service::*, dialog_service::*, event_service::*, flags_service::*, maintenance_service::*, - playlist_service::*, rbac_service::*, recording_service::*, requests::*, status_service::*, + auth_service::*, config_service::*, dialog_service::*, event_service::*, failover_service::*, flags_service::*, + maintenance_service::*, playlist_service::*, rbac_service::*, recording_service::*, requests::*, status_service::*, stream_history_service::*, streams_service::*, target_bouquet_service::*, toastr_service::*, user_api_service::*, user_service::*, websocket_service::*, }; diff --git a/frontend/src/services/requests.rs b/frontend/src/services/requests.rs index aa3337487..5a9d42b19 100644 --- a/frontend/src/services/requests.rs +++ b/frontend/src/services/requests.rs @@ -144,6 +144,15 @@ async fn extract_error_message(response: Response) -> String { extract_error_message_checked(response).await.unwrap_or_default() } +/// The one status every request path turns into `Error::Unauthorized`, before any other status handling. +fn reject_unauthorized(status: u16) -> Result<(), Error> { + if status == 401 { + Err(Error::Unauthorized) + } else { + Ok(()) + } +} + fn unexpected_response_error(message: String) -> Error { if message.trim().is_empty() { Error::RequestError @@ -175,18 +184,16 @@ pub fn check_dummy_token() { } } -/// build all kinds of http request: post/get/delete etc. -async fn request( +/// Builds and sends a request; the payload is attached last, after all headers are set. +async fn send_request( method: RequestMethod, url: &str, body: B, content_type: Option, response_type: Option, request_headers: Option<&[(String, String)]>, - response_header_keys: Option<&[&str]>, -) -> Result, Error> +) -> Result where - T: DeserializeOwned + 'static + std::fmt::Debug, B: Serialize + std::fmt::Debug, { let c_type = content_type.unwrap_or(Encoding::Json); @@ -216,10 +223,33 @@ where Some(encoded) => apply_encoded_body(request, encoded, c_type.as_content_type())?.send().await, None => request.send().await, }; + sent.map_err(|e| { + error!("{e}"); + Error::RequestError + }) +} + +/// build all kinds of http request: post/get/delete etc. +async fn request( + method: RequestMethod, + url: &str, + body: B, + content_type: Option, + response_type: Option, + request_headers: Option<&[(String, String)]>, + response_header_keys: Option<&[&str]>, +) -> Result, Error> +where + T: DeserializeOwned + 'static + std::fmt::Debug, + B: Serialize + std::fmt::Debug, +{ + let r_type = response_type.unwrap_or(Encoding::Json); + let sent = send_request(method, url, body, content_type, response_type, request_headers).await; match sent { Ok(response) => { let status = response.status(); + reject_unauthorized(status)?; let mut response_headers = HashMap::new(); if let Some(header_keys) = response_header_keys { for key in header_keys { @@ -248,7 +278,6 @@ where Err(Error::BadRequest(message)) } } - 401 => Err(Error::Unauthorized), 403 => { let message = extract_error_message(response).await; if message.trim().is_empty() { @@ -305,13 +334,50 @@ where _ => Err(unexpected_response_error(extract_error_message(response).await)), } } - Err(e) => { - error!("{e}"); - Err(Error::RequestError) - } + Err(e) => Err(e), } } +/// Status and text body of a response, for callers that handle non-success statuses themselves. +/// A 401 is not handed back: it is the same `Error::Unauthorized` every other request path reports. +#[derive(Debug, Clone)] +pub struct RawResponse { + pub status: u16, + pub body: String, +} + +async fn request_raw(method: RequestMethod, url: &str, body: B) -> Result +where + B: Serialize + std::fmt::Debug, +{ + let response = send_request(method, url, body, Some(Encoding::Json), Some(Encoding::Json), None).await?; + let status = response.status(); + reject_unauthorized(status)?; + let body = response.text().await.map_err(|_| Error::RequestError)?; + Ok(RawResponse { status, body }) +} + +/// Put request that hands back every status with its body instead of mapping errors. +pub async fn request_put_raw(url: &str, body: B) -> Result +where + B: Serialize + std::fmt::Debug, +{ + request_raw(RequestMethod::Put, url, body).await +} + +/// Post request that hands back every status with its body instead of mapping errors. +pub async fn request_post_raw(url: &str, body: B) -> Result +where + B: Serialize + std::fmt::Debug, +{ + request_raw(RequestMethod::Post, url, body).await +} + +/// Delete request that hands back every status with its body instead of mapping errors. +pub async fn request_delete_raw(url: &str) -> Result { + request_raw(RequestMethod::Delete, url, ()).await +} + /// Delete request pub async fn request_delete( url: &str, @@ -347,6 +413,7 @@ pub async fn request_get_binary(url: &str) -> Result, Error> { Error::RequestError })?; + reject_unauthorized(response.status())?; match response.status() { 200 | 205 | 206 => response.binary().await.map_err(|err| { error!("Failed to read binary response body: {err}"); @@ -360,7 +427,6 @@ pub async fn request_get_binary(url: &str) -> Result, Error> { Err(Error::BadRequest(message)) } } - 401 => Err(Error::Unauthorized), 403 => { let message = extract_error_message(response).await; if message.trim().is_empty() { @@ -549,7 +615,7 @@ where #[cfg(test)] mod tests { - use super::{should_decode_success_body, unexpected_response_error}; + use super::{reject_unauthorized, should_decode_success_body, unexpected_response_error}; use crate::error::Error; #[test] @@ -568,6 +634,14 @@ mod tests { assert!(!should_decode_success_body::<()>(202, "application/json")); } + #[test] + fn only_401_is_rejected_as_unauthorized() { + assert_eq!(reject_unauthorized(401), Err(Error::Unauthorized)); + for status in [200, 204, 400, 403, 404, 409, 422, 423, 500] { + assert_eq!(reject_unauthorized(status), Ok(())); + } + } + #[test] fn unexpected_http_status_preserves_recording_error_code() { assert_eq!( diff --git a/frontend/src/services/websocket_service.rs b/frontend/src/services/websocket_service.rs index ac8f951e6..b4ef3be0f 100644 --- a/frontend/src/services/websocket_service.rs +++ b/frontend/src/services/websocket_service.rs @@ -485,6 +485,9 @@ impl WebSocketService { ProtocolMessage::MaintenanceChangedResponse(entries) => { event_service.broadcast(EventMessage::MaintenanceChanged(Rc::new(entries))); } + ProtocolMessage::FailoverOverridesChanged(targets) => { + event_service.broadcast(EventMessage::FailoverOverridesChanged(Rc::new(targets))); + } ProtocolMessage::RecordingWsError { code } => { event_service.broadcast(EventMessage::RecordingUnavailable { code }); } diff --git a/frontend/src/utils/mod.rs b/frontend/src/utils/mod.rs index ccc9cb546..34dad7b67 100644 --- a/frontend/src/utils/mod.rs +++ b/frontend/src/utils/mod.rs @@ -1,12 +1,14 @@ mod content_cluster; mod css; mod format; +mod portal; mod storage; use crate::i18n::YewI18n; pub(crate) use content_cluster::content_cluster_presentation; pub use css::*; pub use format::*; +pub use portal::*; use shared::model::{PlaylistItemType, StreamInfo}; pub use storage::*; use wasm_bindgen::{prelude::Closure, JsCast}; diff --git a/frontend/src/utils/portal.rs b/frontend/src/utils/portal.rs new file mode 100644 index 000000000..ab426791e --- /dev/null +++ b/frontend/src/utils/portal.rs @@ -0,0 +1,10 @@ +use yew::{create_portal, Html}; + +/// Renders `content` below ``, so the backdrop of a dialog is not trapped in the stacking context of the +/// panel or card that opens it. Falls back to inline rendering without a document body. +pub fn in_body(content: Html) -> Html { + match web_sys::window().and_then(|window| window.document()).and_then(|document| document.body()) { + Some(body) => create_portal(content, body.into()), + None => content, + } +} diff --git a/shared/src/defaults/paths.rs b/shared/src/defaults/paths.rs index 096d1ac7a..b38ced797 100644 --- a/shared/src/defaults/paths.rs +++ b/shared/src/defaults/paths.rs @@ -6,6 +6,8 @@ pub const CONFIG_FILE: &str = "config.yml"; pub const SOURCE_FILE: &str = "source.yml"; pub const MAPPING_FILE: &str = "mapping.yml"; pub const TEMPLATE_FILE: &str = "template.yml"; +pub const FAILOVER_FILE: &str = "failover.yml"; +pub const FAILOVER_DIR: &str = "failover.d"; pub const API_PROXY_FILE: &str = "api-proxy.yml"; pub const USER_FILE: &str = "user.txt"; pub const USER_GROUP_FILE: &str = "groups.txt"; diff --git a/shared/src/error/tuliprox_error.rs b/shared/src/error/tuliprox_error.rs index 4682c7eb8..253acb3c1 100644 --- a/shared/src/error/tuliprox_error.rs +++ b/shared/src/error/tuliprox_error.rs @@ -110,7 +110,7 @@ error_kinds! { ConfigVideoDownload => "config video download error", ConfigTarget => "config target error", TargetBouquet => "target bouquet error", - ChannelGroup => "channel group error", + FailoverGroup => "failover group error", ConfigWebUi => "config web UI error", RepositoryEpg => "repository EPG error", RepositoryXtream => "repository XTream error", diff --git a/shared/src/model/channel_group.rs b/shared/src/model/channel_group.rs deleted file mode 100644 index d94e8e9f0..000000000 --- a/shared/src/model/channel_group.rs +++ /dev/null @@ -1,162 +0,0 @@ -use crate::model::ChannelGroupPolicy; -use std::collections::HashSet; - -pub const CHANNEL_GROUP_OVERRIDES_VERSION: u32 = 1; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] -pub enum CapacityTier { - Free, - OwnRelease, - Grace, - Preempt, - Exhausted, -} - -/// Why a running group stream replaced its member. -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SwitchReason { - UpstreamError, - UpstreamEof, - Stall, - Underrun, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CircuitState { - Closed, - OpenUntil(std::time::Instant), - HalfOpenInFlight, -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] -pub struct ChannelMemberRefDto { - pub input: String, - pub stream_id: String, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct ManualChannelGroupDto { - pub id: String, - pub name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub logo: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub epg_channel_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub playlist_group: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub policy: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub members: Vec, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct AutoGroupOverrideDto { - pub auto_key: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub policy: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub order: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub add: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub remove: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub logo: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub epg_channel_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub playlist_group: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct ChannelGroupOverridesDto { - pub version: u32, - pub target: String, - #[serde(default)] - pub revision: u64, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub groups: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub auto_overrides: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub excluded: Vec, -} - -impl ChannelGroupOverridesDto { - pub fn new(target: &str) -> Self { - Self { - version: CHANNEL_GROUP_OVERRIDES_VERSION, - target: target.to_string(), - revision: 0, - groups: Vec::new(), - auto_overrides: Vec::new(), - excluded: Vec::new(), - } - } - - pub fn canonicalize(&mut self) { - self.target = self.target.trim().to_string(); - for group in &mut self.groups { - group.id = group.id.trim().to_string(); - group.name = group.name.trim().to_string(); - group.logo = group.logo.as_ref().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); - group.epg_channel_id = - group.epg_channel_id.as_ref().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); - group.playlist_group = - group.playlist_group.as_ref().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); - for m in &mut group.members { - m.input = m.input.trim().to_string(); - m.stream_id = m.stream_id.trim().to_string(); - } - let mut seen = HashSet::new(); - group.members.retain(|m| { - !m.input.is_empty() && !m.stream_id.is_empty() && seen.insert((m.input.clone(), m.stream_id.clone())) - }); - } - for auto in &mut self.auto_overrides { - auto.auto_key = auto.auto_key.trim().to_string(); - auto.name = auto.name.as_ref().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); - auto.logo = auto.logo.as_ref().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); - auto.epg_channel_id = auto.epg_channel_id.as_ref().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); - auto.playlist_group = auto.playlist_group.as_ref().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); - for m in &mut auto.order { - m.input = m.input.trim().to_string(); - m.stream_id = m.stream_id.trim().to_string(); - } - let mut seen = HashSet::new(); - auto.order.retain(|m| { - !m.input.is_empty() && !m.stream_id.is_empty() && seen.insert((m.input.clone(), m.stream_id.clone())) - }); - - for m in &mut auto.add { - m.input = m.input.trim().to_string(); - m.stream_id = m.stream_id.trim().to_string(); - } - seen.clear(); - auto.add.retain(|m| { - !m.input.is_empty() && !m.stream_id.is_empty() && seen.insert((m.input.clone(), m.stream_id.clone())) - }); - - for m in &mut auto.remove { - m.input = m.input.trim().to_string(); - m.stream_id = m.stream_id.trim().to_string(); - } - seen.clear(); - auto.remove.retain(|m| { - !m.input.is_empty() && !m.stream_id.is_empty() && seen.insert((m.input.clone(), m.stream_id.clone())) - }); - } - for m in &mut self.excluded { - m.input = m.input.trim().to_string(); - m.stream_id = m.stream_id.trim().to_string(); - } - let mut seen = HashSet::new(); - self.excluded.retain(|m| { - !m.input.is_empty() && !m.stream_id.is_empty() && seen.insert((m.input.clone(), m.stream_id.clone())) - }); - } -} diff --git a/shared/src/model/config/base.rs b/shared/src/model/config/base.rs index 368280d88..7a26b1f70 100644 --- a/shared/src/model/config/base.rs +++ b/shared/src/model/config/base.rs @@ -10,7 +10,7 @@ use crate::{ is_false, is_none_or_empty_metadata_update, is_true, is_zero_u32, }, model::{ - ChannelGroupsConfig, ConfigApiDto, HdHomeRunConfigDto, IpCheckConfigDto, LibraryConfigDto, LogConfigDto, + ConfigApiDto, FailoverGroupsConfig, HdHomeRunConfigDto, IpCheckConfigDto, LibraryConfigDto, LogConfigDto, MessagingConfigDto, MetadataUpdateConfigDto, ProxyConfigDto, ReverseProxyConfigDto, ScheduleConfigDto, VideoConfigDto, WebUiConfigDto, }, @@ -35,6 +35,8 @@ pub struct ConfigDto { pub mapping_path: Option, #[serde(default, skip_serializing_if = "is_blank_or_default_template_path")] pub template_path: Option, + #[serde(default, skip_serializing_if = "is_blank_optional_string")] + pub failover_path: Option, #[serde( default = "default_custom_stream_response_path", skip_serializing_if = "is_blank_or_default_custom_stream_response_path" @@ -108,7 +110,7 @@ pub struct ConfigDto { #[serde(default, skip_serializing_if = "Option::is_none")] pub library: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub channel_groups: Option, + pub failover_groups: Option, } impl Default for ConfigDto { @@ -122,6 +124,7 @@ impl Default for ConfigDto { user_config_dir: None, mapping_path: None, template_path: None, + failover_path: None, custom_stream_response_path: None, custom_stream_response_timeout_secs: 0, custom_stream_response_enabled: true, @@ -147,7 +150,7 @@ impl Default for ConfigDto { proxy: None, ipcheck: None, library: None, - channel_groups: None, + failover_groups: None, } } } @@ -174,6 +177,8 @@ impl<'de> serde::Deserialize<'de> for ConfigDto { mapping_path: Option, #[serde(default)] template_path: Option, + #[serde(default)] + failover_path: Option, #[serde(default = "default_custom_stream_response_path")] custom_stream_response_path: Option, #[serde(default)] @@ -225,7 +230,7 @@ impl<'de> serde::Deserialize<'de> for ConfigDto { #[serde(default)] library: Option, #[serde(default)] - channel_groups: Option, + failover_groups: Option, } let raw = Raw::deserialize(deserializer)?; @@ -239,6 +244,7 @@ impl<'de> serde::Deserialize<'de> for ConfigDto { user_config_dir: raw.user_config_dir, mapping_path: raw.mapping_path, template_path: raw.template_path, + failover_path: raw.failover_path, custom_stream_response_path: raw.custom_stream_response_path, custom_stream_response_timeout_secs: raw.custom_stream_response_timeout_secs, custom_stream_response_enabled: raw.custom_stream_response_enabled, @@ -264,7 +270,7 @@ impl<'de> serde::Deserialize<'de> for ConfigDto { proxy: raw.proxy, ipcheck: raw.ipcheck, library: raw.library, - channel_groups: raw.channel_groups, + failover_groups: raw.failover_groups, }) } } diff --git a/shared/src/model/config/base/prepare.rs b/shared/src/model/config/base/prepare.rs index cf28d8080..13cef4735 100644 --- a/shared/src/model/config/base/prepare.rs +++ b/shared/src/model/config/base/prepare.rs @@ -78,8 +78,8 @@ impl ConfigDto { if let Some(library) = &mut self.library { library.playlist.prepare(); } - if let Some(channel_groups) = &mut self.channel_groups { - channel_groups.prepare()?; + if let Some(failover_groups) = &mut self.failover_groups { + failover_groups.prepare()?; } Ok(()) @@ -172,6 +172,7 @@ impl ConfigDto { self.user_config_dir = normalize_optional_dir(&main_config.user_config_dir, DEFAULT_USER_CONFIG_DIR); self.mapping_path = normalize_optional_config_file_path(&main_config.mapping_path, MAPPING_FILE); self.template_path = normalize_optional_config_file_path(&main_config.template_path, TEMPLATE_FILE); + self.failover_path = main_config.failover_path.as_deref().filter(|p| !p.trim().is_empty()).map(str::to_owned); self.custom_stream_response_path = normalize_optional_dir(&main_config.custom_stream_response_path, DEFAULT_CUSTOM_STREAM_RESPONSE_PATH); self.custom_stream_response_timeout_secs = main_config.custom_stream_response_timeout_secs; diff --git a/shared/src/model/config/base/tests.rs b/shared/src/model/config/base/tests.rs index daffd03c8..eef41dbaa 100644 --- a/shared/src/model/config/base/tests.rs +++ b/shared/src/model/config/base/tests.rs @@ -145,6 +145,52 @@ fn prepare_sets_default_optional_paths() { assert_eq!(cfg.template_path.as_deref(), Some(format!("./{CONFIG_PATH}/{TEMPLATE_FILE}").as_str())); } +#[test] +fn failover_path_is_optional_and_keeps_auto_detection_after_prepare() { + let raw = r" +api: + host: 127.0.0.1 + port: 8901 + web_root: ./web +storage_dir: . +failover_path: failover.d/ +"; + let mut cfg: ConfigDto = serde_saphyr::from_str(raw).expect("config should deserialize"); + assert_eq!(cfg.failover_path.as_deref(), Some("failover.d/")); + + cfg.failover_path = None; + cfg.prepare(false).expect("prepare should succeed"); + // no default is injected: an unset path lets the resolver auto-detect failover.yml / failover.d + assert!(cfg.failover_path.is_none()); +} + +#[test] +fn update_from_main_config_keeps_failover_path_none_when_blank() { + let mut cfg = ConfigDto { failover_path: Some("failover.d/".to_string()), ..ConfigDto::default() }; + for blank in [None, Some(String::new()), Some(" ".to_string())] { + let main = MainConfigDto { failover_path: blank, ..MainConfigDto::default() }; + cfg.update_from_main_config(&main); + assert!(cfg.failover_path.is_none()); + } +} + +#[test] +fn update_from_main_config_round_trips_failover_path() { + let mut cfg = ConfigDto::default(); + let main = MainConfigDto { failover_path: Some("failover.d/".to_string()), ..MainConfigDto::default() }; + cfg.update_from_main_config(&main); + assert_eq!(cfg.failover_path.as_deref(), Some("failover.d/")); + assert_eq!(MainConfigDto::from(&cfg).failover_path.as_deref(), Some("failover.d/")); +} + +#[test] +fn explicit_default_failover_path_survives_serialization() { + let cfg = ConfigDto { failover_path: Some("failover.yml".to_string()), ..ConfigDto::default() }; + let value = serde_json::to_value(&cfg).expect("config should serialize"); + let restored: ConfigDto = serde_json::from_value(value).expect("config should deserialize"); + assert_eq!(restored.failover_path.as_deref(), Some("failover.yml")); +} + #[test] fn deserializing_rejects_legacy_video_ffprobe_fields() { let raw = json!({ diff --git a/shared/src/model/config/base/views.rs b/shared/src/model/config/base/views.rs index 565348e43..be30eaa8c 100644 --- a/shared/src/model/config/base/views.rs +++ b/shared/src/model/config/base/views.rs @@ -37,6 +37,8 @@ pub struct MainConfigDto { #[serde(default = "default_main_template_path", skip_serializing_if = "is_blank_or_default_template_path")] pub template_path: Option, #[serde(default, skip_serializing_if = "is_blank_optional_string")] + pub failover_path: Option, + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub custom_stream_response_path: Option, #[serde(default, skip_serializing_if = "is_zero_u32")] pub custom_stream_response_timeout_secs: u32, @@ -86,6 +88,7 @@ impl Default for MainConfigDto { user_config_dir: default_main_user_config_dir(), mapping_path: default_main_mapping_path(), template_path: default_main_template_path(), + failover_path: None, custom_stream_response_path: None, custom_stream_response_timeout_secs: 0, custom_stream_response_enabled: true, @@ -116,6 +119,7 @@ impl From<&ConfigDto> for MainConfigDto { user_config_dir: config.user_config_dir.clone(), mapping_path: config.mapping_path.clone(), template_path: config.template_path.clone(), + failover_path: config.failover_path.clone(), custom_stream_response_path: config.custom_stream_response_path.clone(), custom_stream_response_timeout_secs: config.custom_stream_response_timeout_secs, custom_stream_response_enabled: config.custom_stream_response_enabled, diff --git a/shared/src/model/config/paths.rs b/shared/src/model/config/paths.rs index 1a1f69d9c..e5fdbebec 100644 --- a/shared/src/model/config/paths.rs +++ b/shared/src/model/config/paths.rs @@ -17,6 +17,8 @@ pub struct ConfigPaths { pub template_file_path: Option, #[serde(default, skip_serializing_if = "is_empty_optional_vec")] pub template_files_used: Option>, + #[serde(default, skip_serializing_if = "is_blank_optional_string")] + pub failover_file_path: Option, pub api_proxy_file_path: String, #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub custom_stream_response_path: Option, diff --git a/shared/src/model/config/target.rs b/shared/src/model/config/target.rs index 1e1186161..e54c48bcb 100644 --- a/shared/src/model/config/target.rs +++ b/shared/src/model/config/target.rs @@ -253,14 +253,14 @@ impl ConfigTargetDto { #[cfg(test)] mod tests; -mod channel_groups; +mod failover_groups; mod filter; mod options; mod output; -pub use channel_groups::{ - ChannelGroupCircuitConfig, ChannelGroupFailoverConfig, ChannelGroupPlayback, ChannelGroupPolicy, - ChannelGroupStartConfig, ChannelGroupsConfig, ChannelGroupsTargetConfig, ChannelMatchBy, ChannelMatchNormalize, - EpgConflictMode, RuntimeAudioLanguage, MIN_GROUP_MEMBERS, +pub use failover_groups::{ + ChannelMatchBy, ChannelMatchNormalize, EpgConflictMode, FailoverGroupCircuitConfig, FailoverGroupPlayback, + FailoverGroupPolicy, FailoverGroupRuntimeConfig, FailoverGroupStartConfig, FailoverGroupsConfig, + FailoverGroupsTargetConfig, RuntimeAudioLanguage, MIN_GROUP_MEMBERS, }; pub use filter::ConfigTargetFilterDto; pub use options::{ diff --git a/shared/src/model/config/target/channel_groups.rs b/shared/src/model/config/target/failover_groups.rs similarity index 81% rename from shared/src/model/config/target/channel_groups.rs rename to shared/src/model/config/target/failover_groups.rs index e6ef87521..7b3bb45d2 100644 --- a/shared/src/model/config/target/channel_groups.rs +++ b/shared/src/model/config/target/failover_groups.rs @@ -38,7 +38,7 @@ macro_rules! config_enum { .iter() .copied() .find(|variant| variant.as_str() == value) - .ok_or_else(|| TuliproxError::ConfigBase(format!("unknown channel_groups.{}: {value}", $key))) + .ok_or_else(|| TuliproxError::ConfigBase(format!("unknown failover_groups.{}: {value}", $key))) } } }; @@ -53,7 +53,7 @@ config_enum!(ChannelMatchBy, "match_by" { config_enum!( /// Member order of a group: `auto` ranks by health and quality, `priority` keeps the list order. - ChannelGroupPolicy, "policy" { Auto => "auto", Priority => "priority" } + FailoverGroupPolicy, "policy" { Auto => "auto", Priority => "priority" } ); config_enum!(RuntimeAudioLanguage, "failover.audio_language" { Prefer => "prefer", Require => "require" }); @@ -95,7 +95,7 @@ impl ChannelMatchNormalize { .as_deref() .map(|pattern| { Regex::new(pattern).map(Arc::new).map_err(|err| { - TuliproxError::RegexCompile(format!("channel_groups.match_normalize {pattern} {err}")) + TuliproxError::RegexCompile(format!("failover_groups.match_normalize {pattern} {err}")) }) }) .transpose()?; @@ -111,21 +111,21 @@ const fn default_true() -> bool { true } /// How a client start picks a member. #[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields, default)] -pub struct ChannelGroupStartConfig { +pub struct FailoverGroupStartConfig { /// Longest time a start may try members; 0 means no limit. pub timeout_secs: u64, /// Most members tried at start. pub max_attempts: u8, } -impl Default for ChannelGroupStartConfig { +impl Default for FailoverGroupStartConfig { fn default() -> Self { Self { timeout_secs: 15, max_attempts: 4 } } } /// Switching to another member while a client watches an MPEG-TS stream. #[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields, default)] -pub struct ChannelGroupFailoverConfig { +pub struct FailoverGroupRuntimeConfig { pub enabled: bool, /// Seconds without media before the member is replaced; 0 disables the check. pub no_data_timeout_secs: u64, @@ -145,7 +145,7 @@ pub struct ChannelGroupFailoverConfig { pub audio_language: RuntimeAudioLanguage, } -impl Default for ChannelGroupFailoverConfig { +impl Default for FailoverGroupRuntimeConfig { fn default() -> Self { Self { enabled: true, @@ -165,7 +165,7 @@ impl Default for ChannelGroupFailoverConfig { /// When failing members and inputs are skipped for a while. #[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields, default)] -pub struct ChannelGroupCircuitConfig { +pub struct FailoverGroupCircuitConfig { /// Consecutive failures that put a member on hold. pub source_failures: u8, /// First hold time; doubles while the member keeps failing. @@ -177,7 +177,7 @@ pub struct ChannelGroupCircuitConfig { pub input_window_secs: u64, } -impl Default for ChannelGroupCircuitConfig { +impl Default for FailoverGroupCircuitConfig { fn default() -> Self { Self { source_failures: 2, @@ -189,25 +189,25 @@ impl Default for ChannelGroupCircuitConfig { } } -/// `config.yml` `channel_groups`: the settings every target with channel groups uses. +/// `config.yml` `failover_groups`: the settings every target with failover groups uses. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields, default)] -pub struct ChannelGroupsConfig { +pub struct FailoverGroupsConfig { pub match_by: ChannelMatchBy, pub match_as_ascii: bool, #[serde(skip_serializing_if = "ChannelMatchNormalize::is_empty")] pub match_normalize: ChannelMatchNormalize, pub epg_conflict: EpgConflictMode, pub name_strip_quality: bool, - pub policy: ChannelGroupPolicy, + pub policy: FailoverGroupPolicy, #[serde(default = "default_true")] pub allow_provider_grace: bool, - pub start: ChannelGroupStartConfig, - pub failover: ChannelGroupFailoverConfig, - pub circuit: ChannelGroupCircuitConfig, + pub start: FailoverGroupStartConfig, + pub failover: FailoverGroupRuntimeConfig, + pub circuit: FailoverGroupCircuitConfig, } -impl Default for ChannelGroupsConfig { +impl Default for FailoverGroupsConfig { fn default() -> Self { Self { match_by: ChannelMatchBy::default(), @@ -215,28 +215,28 @@ impl Default for ChannelGroupsConfig { match_normalize: ChannelMatchNormalize::default(), epg_conflict: EpgConflictMode::default(), name_strip_quality: false, - policy: ChannelGroupPolicy::default(), + policy: FailoverGroupPolicy::default(), allow_provider_grace: true, - start: ChannelGroupStartConfig::default(), - failover: ChannelGroupFailoverConfig::default(), - circuit: ChannelGroupCircuitConfig::default(), + start: FailoverGroupStartConfig::default(), + failover: FailoverGroupRuntimeConfig::default(), + circuit: FailoverGroupCircuitConfig::default(), } } } /// The settings a group playback of one target uses. #[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub struct ChannelGroupPlayback { - pub start: ChannelGroupStartConfig, - pub failover: ChannelGroupFailoverConfig, +pub struct FailoverGroupPlayback { + pub start: FailoverGroupStartConfig, + pub failover: FailoverGroupRuntimeConfig, pub allow_provider_grace: bool, } -impl ChannelGroupsConfig { +impl FailoverGroupsConfig { pub fn is_default(&self) -> bool { *self == Self::default() } pub fn prepare(&mut self) -> Result<(), TuliproxError> { - let invalid = |msg: &str| Err(TuliproxError::ConfigBase(format!("`channel_groups.{msg}"))); + let invalid = |msg: &str| Err(TuliproxError::ConfigBase(format!("`failover_groups.{msg}"))); if self.start.max_attempts == 0 { return invalid("start.max_attempts` must be > 0"); } @@ -260,7 +260,7 @@ impl ChannelGroupsConfig { /// The grouping settings of `target`: these settings with the target's overrides applied. #[must_use] - pub fn for_target(&self, target: &ChannelGroupsTargetConfig) -> Self { + pub fn for_target(&self, target: &FailoverGroupsTargetConfig) -> Self { let mut config = self.clone(); config.match_by = target.match_by.unwrap_or(config.match_by); config.match_as_ascii = target.match_as_ascii.unwrap_or(config.match_as_ascii); @@ -273,10 +273,10 @@ impl ChannelGroupsConfig { } /// The playback settings of a target, without copying the grouping settings. - pub fn playback(&self, target: Option<&ChannelGroupsTargetConfig>) -> ChannelGroupPlayback { + pub fn playback(&self, target: Option<&FailoverGroupsTargetConfig>) -> FailoverGroupPlayback { let mut failover = self.failover; failover.enabled = target.and_then(|target| target.failover).unwrap_or(failover.enabled); - ChannelGroupPlayback { + FailoverGroupPlayback { start: self.start, failover, allow_provider_grace: target @@ -286,11 +286,11 @@ impl ChannelGroupsConfig { } } -/// `source.yml` `targets[].options.channel_groups`: turns channel groups on for a target and +/// `source.yml` `targets[].options.failover_groups`: turns failover groups on for a target and /// overrides the global settings where a target needs it. Unset fields use `config.yml`. #[derive(Debug, Copy, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields, default)] -pub struct ChannelGroupsTargetConfig { +pub struct FailoverGroupsTargetConfig { pub enabled: bool, #[serde(skip_serializing_if = "Option::is_none")] pub match_by: Option, @@ -301,7 +301,7 @@ pub struct ChannelGroupsTargetConfig { #[serde(skip_serializing_if = "Option::is_none")] pub name_strip_quality: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub policy: Option, + pub policy: Option, #[serde(skip_serializing_if = "Option::is_none")] pub allow_provider_grace: Option, /// Switching members while watching; `None` uses `failover.enabled` of `config.yml`. @@ -309,7 +309,7 @@ pub struct ChannelGroupsTargetConfig { pub failover: Option, } -impl ChannelGroupsTargetConfig { +impl FailoverGroupsTargetConfig { pub fn is_default(&self) -> bool { *self == Self::default() } } @@ -329,12 +329,12 @@ mod tests { #[test] fn defaults_match_design() { - let cfg: ChannelGroupsConfig = serde_json::from_str("{}").expect("parses"); - assert_eq!(cfg, ChannelGroupsConfig::default()); + let cfg: FailoverGroupsConfig = serde_json::from_str("{}").expect("parses"); + assert_eq!(cfg, FailoverGroupsConfig::default()); assert_eq!(cfg.match_by, ChannelMatchBy::Caption); - assert_eq!(cfg.policy, ChannelGroupPolicy::Auto); + assert_eq!(cfg.policy, FailoverGroupPolicy::Auto); assert!(cfg.allow_provider_grace); - assert_eq!(cfg.start, ChannelGroupStartConfig { timeout_secs: 15, max_attempts: 4 }); + assert_eq!(cfg.start, FailoverGroupStartConfig { timeout_secs: 15, max_attempts: 4 }); assert!(cfg.failover.enabled); assert_eq!(cfg.failover.no_data_timeout_secs, 8); assert_eq!(cfg.failover.slow_source_pct, 90); @@ -347,7 +347,7 @@ mod tests { assert_eq!(cfg.failover.audio_language, RuntimeAudioLanguage::Prefer); assert_eq!( cfg.circuit, - ChannelGroupCircuitConfig { + FailoverGroupCircuitConfig { source_failures: 2, cooldown_secs: 60, cooldown_max_secs: 900, @@ -361,13 +361,13 @@ mod tests { #[test] fn match_normalize_compiles_regex_on_prepare() { - let mut cfg: ChannelGroupsConfig = serde_json::from_str( + let mut cfg: FailoverGroupsConfig = serde_json::from_str( r#"{"match_normalize":{"strip_regex":"^[A-Z]{2}:\\s*","ignore_tokens":["hevc"],"aliases":{"das erste":"ard"}}}"#, ) .expect("parses"); cfg.prepare().expect("valid"); assert!(cfg.match_normalize.t_strip_regex.is_some()); - let mut bad: ChannelGroupsConfig = + let mut bad: FailoverGroupsConfig = serde_json::from_str(r#"{"match_normalize":{"strip_regex":"("}}"#).expect("parses"); assert!(bad.prepare().is_err()); } @@ -381,22 +381,22 @@ mod tests { r#"{"circuit":{"source_failures":0}}"#, r#"{"start":{"max_attempts":0}}"#, ] { - let mut cfg: ChannelGroupsConfig = serde_json::from_str(json).expect("parses"); + let mut cfg: FailoverGroupsConfig = serde_json::from_str(json).expect("parses"); assert!(cfg.prepare().is_err(), "{json}"); } } #[test] fn rejects_unknown_and_removed_fields() { - assert!(serde_json::from_str::(r#"{"min_members":2}"#).is_err()); - assert!(serde_json::from_str::(r#"{"selection":{}}"#).is_err()); - assert!(serde_json::from_str::(r#"{"enabled":true,"min_members":2}"#).is_err()); - assert!(serde_json::from_str::(r#"{"enabled":true,"match_normalize":{}}"#).is_err()); + assert!(serde_json::from_str::(r#"{"min_members":2}"#).is_err()); + assert!(serde_json::from_str::(r#"{"selection":{}}"#).is_err()); + assert!(serde_json::from_str::(r#"{"enabled":true,"min_members":2}"#).is_err()); + assert!(serde_json::from_str::(r#"{"enabled":true,"match_normalize":{}}"#).is_err()); } #[test] fn cooldown_max_is_raised_to_cooldown() { - let mut cfg: ChannelGroupsConfig = + let mut cfg: FailoverGroupsConfig = serde_json::from_str(r#"{"circuit":{"cooldown_secs":120,"cooldown_max_secs":60}}"#).expect("parses"); cfg.prepare().expect("valid"); assert_eq!(cfg.circuit.cooldown_max_secs, 120); @@ -404,23 +404,23 @@ mod tests { #[test] fn target_overrides_replace_only_set_fields() { - let global = ChannelGroupsConfig { + let global = FailoverGroupsConfig { match_by: ChannelMatchBy::Name, name_strip_quality: true, - failover: ChannelGroupFailoverConfig { no_data_timeout_secs: 12, ..ChannelGroupFailoverConfig::default() }, - ..ChannelGroupsConfig::default() + failover: FailoverGroupRuntimeConfig { no_data_timeout_secs: 12, ..FailoverGroupRuntimeConfig::default() }, + ..FailoverGroupsConfig::default() }; - let unset = ChannelGroupsTargetConfig { enabled: true, ..ChannelGroupsTargetConfig::default() }; + let unset = FailoverGroupsTargetConfig { enabled: true, ..FailoverGroupsTargetConfig::default() }; assert_eq!(global.for_target(&unset), global); - let target: ChannelGroupsTargetConfig = serde_json::from_str( + let target: FailoverGroupsTargetConfig = serde_json::from_str( r#"{"enabled":true,"match_by":"epg_channel_id","name_strip_quality":false,"policy":"priority","allow_provider_grace":false,"failover":false}"#, ) .expect("parses"); let effective = global.for_target(&target); assert_eq!(effective.match_by, ChannelMatchBy::EpgChannelId); assert!(!effective.name_strip_quality); - assert_eq!(effective.policy, ChannelGroupPolicy::Priority); + assert_eq!(effective.policy, FailoverGroupPolicy::Priority); assert!(!effective.allow_provider_grace); assert!(!effective.failover.enabled); assert_eq!(effective.failover.no_data_timeout_secs, 12); @@ -429,12 +429,12 @@ mod tests { #[test] fn playback_applies_target_failover_and_grace() { - let global = ChannelGroupsConfig::default(); - let target = ChannelGroupsTargetConfig { + let global = FailoverGroupsConfig::default(); + let target = FailoverGroupsTargetConfig { enabled: true, allow_provider_grace: Some(false), failover: Some(false), - ..ChannelGroupsTargetConfig::default() + ..FailoverGroupsTargetConfig::default() }; let playback = global.playback(Some(&target)); assert!(!playback.allow_provider_grace); @@ -447,7 +447,7 @@ mod tests { #[test] fn target_serializes_only_overrides() { - let target = ChannelGroupsTargetConfig { enabled: true, failover: Some(false), ..Default::default() }; + let target = FailoverGroupsTargetConfig { enabled: true, failover: Some(false), ..Default::default() }; assert_eq!(serde_json::to_string(&target).expect("serializes"), r#"{"enabled":true,"failover":false}"#); } } diff --git a/shared/src/model/config/target/options.rs b/shared/src/model/config/target/options.rs index 1b895b8e9..beaa44e36 100644 --- a/shared/src/model/config/target/options.rs +++ b/shared/src/model/config/target/options.rs @@ -123,7 +123,7 @@ pub struct ConfigTargetOptions { #[serde(default, skip_serializing_if = "Option::is_none")] pub force_redirect: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub channel_groups: Option, + pub failover_groups: Option, } impl ConfigTargetOptions { @@ -134,7 +134,7 @@ impl ConfigTargetOptions { && self.deduplicate.is_none() && self.epg_output.is_empty() && self.force_redirect.is_none_or(|f| f.has_full_flags() || f.is_empty()) - && self.channel_groups.is_none() + && self.failover_groups.is_none() } pub fn prepare(&mut self) -> Result<(), crate::error::TuliproxError> { Ok(()) } diff --git a/shared/src/model/event/kind.rs b/shared/src/model/event/kind.rs index 8cf65c128..a411e9d11 100644 --- a/shared/src/model/event/kind.rs +++ b/shared/src/model/event/kind.rs @@ -62,10 +62,11 @@ pub enum EventKind { RecordingProgress, /// Appended last for the same reason. MaintenanceChanged, - ChannelGroupMemberSelected, - ChannelGroupExhausted, - ChannelGroupCircuitChanged, - ChannelGroupMemberSwitched, + FailoverGroupMemberSelected, + FailoverGroupExhausted, + FailoverGroupCircuitChanged, + FailoverGroupMemberSwitched, + FailoverOverridesChanged, } impl EventKind { @@ -73,7 +74,7 @@ impl EventKind { /// /// The mask type below indexes into this, so the order is load-bearing: /// it is the bit order, not just a listing. - pub const ALL: [Self; 47] = [ + pub const ALL: [Self; 48] = [ Self::ServerError, Self::ServerStarted, Self::ServerShutdown, @@ -117,10 +118,11 @@ impl EventKind { Self::AuthPermissionDenied, Self::RecordingProgress, Self::MaintenanceChanged, - Self::ChannelGroupMemberSelected, - Self::ChannelGroupExhausted, - Self::ChannelGroupCircuitChanged, - Self::ChannelGroupMemberSwitched, + Self::FailoverGroupMemberSelected, + Self::FailoverGroupExhausted, + Self::FailoverGroupCircuitChanged, + Self::FailoverGroupMemberSwitched, + Self::FailoverOverridesChanged, ]; /// This kind's bit position. @@ -186,11 +188,13 @@ impl EventKind { | Self::StreamProbeFailed | Self::NotificationDeadLettered | Self::ScheduledTaskFailed - | Self::ChannelGroupMemberSelected - | Self::ChannelGroupExhausted - | Self::ChannelGroupCircuitChanged - | Self::ChannelGroupMemberSwitched => Permission::SystemRead, + | Self::FailoverGroupMemberSelected + | Self::FailoverGroupExhausted + | Self::FailoverGroupCircuitChanged + | Self::FailoverGroupMemberSwitched => Permission::SystemRead, Self::MaintenanceChanged => Permission::MaintenanceRead, + // The failover files back the failover group editor, which is a playlist view. + Self::FailoverOverridesChanged => Permission::PlaylistRead, } } @@ -290,10 +294,11 @@ impl EventKind { Self::AuthSignInThrottled => "auth.sign_in.throttled", Self::AuthPermissionDenied => "auth.permission.denied", Self::MaintenanceChanged => "maintenance.changed", - Self::ChannelGroupMemberSelected => "channel_group.member_selected", - Self::ChannelGroupExhausted => "channel_group.exhausted", - Self::ChannelGroupCircuitChanged => "channel_group.circuit_changed", - Self::ChannelGroupMemberSwitched => "channel_group.member_switched", + Self::FailoverGroupMemberSelected => "failover_group.member_selected", + Self::FailoverGroupExhausted => "failover_group.exhausted", + Self::FailoverGroupCircuitChanged => "failover_group.circuit_changed", + Self::FailoverGroupMemberSwitched => "failover_group.member_switched", + Self::FailoverOverridesChanged => "failover.overrides_changed", } } diff --git a/shared/src/model/event/message.rs b/shared/src/model/event/message.rs index bfe9796e1..0179c42d0 100644 --- a/shared/src/model/event/message.rs +++ b/shared/src/model/event/message.rs @@ -102,29 +102,33 @@ pub enum EventMessage { /// without being woken by every successful sign-in. AuthAudit(AuthAuditEvent), - ChannelGroupMemberSelected { + FailoverGroupMemberSelected { target_id: u16, group_vid: u32, member_vid: u32, tier: CapacityTier, }, - ChannelGroupExhausted { + FailoverGroupExhausted { target_id: u16, group_vid: u32, }, - ChannelGroupCircuitChanged { + FailoverGroupCircuitChanged { input_name: Arc, input_stream_id: Arc, state: CircuitState, }, /// A running group stream continued on another member. - ChannelGroupMemberSwitched { + FailoverGroupMemberSwitched { target_id: u16, group_vid: u32, from_member_vid: u32, to_member_vid: u32, reason: SwitchReason, }, + /// Failover override files were reloaded and these targets' sections were added, changed or removed. + FailoverOverridesChanged { + targets: Vec, + }, } impl EventMessage { @@ -195,10 +199,11 @@ impl EventMessage { AuthAuditOutcome::SignInThrottled => EventKind::AuthSignInThrottled, AuthAuditOutcome::PermissionDenied => EventKind::AuthPermissionDenied, }, - Self::ChannelGroupMemberSelected { .. } => EventKind::ChannelGroupMemberSelected, - Self::ChannelGroupExhausted { .. } => EventKind::ChannelGroupExhausted, - Self::ChannelGroupCircuitChanged { .. } => EventKind::ChannelGroupCircuitChanged, - Self::ChannelGroupMemberSwitched { .. } => EventKind::ChannelGroupMemberSwitched, + Self::FailoverGroupMemberSelected { .. } => EventKind::FailoverGroupMemberSelected, + Self::FailoverGroupExhausted { .. } => EventKind::FailoverGroupExhausted, + Self::FailoverGroupCircuitChanged { .. } => EventKind::FailoverGroupCircuitChanged, + Self::FailoverGroupMemberSwitched { .. } => EventKind::FailoverGroupMemberSwitched, + Self::FailoverOverridesChanged { .. } => EventKind::FailoverOverridesChanged, } } @@ -299,10 +304,11 @@ impl EventMessage { Self::PlaylistUpdateProgress(_) | Self::SystemInfoUpdate(_) | Self::RecordingProgress - | Self::ChannelGroupMemberSelected { .. } - | Self::ChannelGroupExhausted { .. } - | Self::ChannelGroupCircuitChanged { .. } - | Self::ChannelGroupMemberSwitched { .. } => return None, + | Self::FailoverGroupMemberSelected { .. } + | Self::FailoverGroupExhausted { .. } + | Self::FailoverGroupCircuitChanged { .. } + | Self::FailoverGroupMemberSwitched { .. } + | Self::FailoverOverridesChanged { .. } => return None, }) } @@ -358,7 +364,7 @@ impl EventMessage { Self::NotificationDeadLettered(dead_letter) => encode(dead_letter), Self::ScheduledTaskFailed(failure) => encode(failure), Self::AuthAudit(event) => encode(event), - Self::ChannelGroupMemberSelected { target_id, group_vid, member_vid, tier } => { + Self::FailoverGroupMemberSelected { target_id, group_vid, member_vid, tier } => { serde_json::json!({ "target_id": target_id, "group_vid": group_vid, @@ -366,13 +372,13 @@ impl EventMessage { "tier": tier, }) } - Self::ChannelGroupExhausted { target_id, group_vid } => { + Self::FailoverGroupExhausted { target_id, group_vid } => { serde_json::json!({ "target_id": target_id, "group_vid": group_vid, }) } - Self::ChannelGroupMemberSwitched { target_id, group_vid, from_member_vid, to_member_vid, reason } => { + Self::FailoverGroupMemberSwitched { target_id, group_vid, from_member_vid, to_member_vid, reason } => { serde_json::json!({ "target_id": target_id, "group_vid": group_vid, @@ -381,7 +387,8 @@ impl EventMessage { "reason": reason, }) } - Self::ChannelGroupCircuitChanged { input_name, input_stream_id, state } => { + Self::FailoverOverridesChanged { targets } => serde_json::json!({ "targets": targets }), + Self::FailoverGroupCircuitChanged { input_name, input_stream_id, state } => { let state_str = match state { CircuitState::Closed => "closed", CircuitState::OpenUntil(_) => "open", @@ -431,6 +438,11 @@ pub struct PlaylistUpdateSummary { } impl PlaylistUpdateSummary { + /// Names of the targets the run processed, in report order. + pub fn processed_targets(&self) -> impl Iterator { + self.stats.iter().flat_map(|source| source.targets.iter()).map(|target| target.name.as_str()) + } + /// A summary carrying only an outcome - the timeout and panic paths, /// which have no statistics to report. #[must_use] diff --git a/shared/src/model/event/tests.rs b/shared/src/model/event/tests.rs index d36856c3f..e1d02b313 100644 --- a/shared/src/model/event/tests.rs +++ b/shared/src/model/event/tests.rs @@ -71,3 +71,12 @@ fn maintenance_changed_requires_maintenance_read() { ); assert_eq!(EventKind::MaintenanceChanged.as_wire_name(), "maintenance.changed"); } + +#[test] +fn failover_overrides_changed_needs_playlist_read() { + assert_eq!( + EventKind::FailoverOverridesChanged.required_permission(), + crate::model::permission::Permission::PlaylistRead + ); + assert_eq!(EventKind::FailoverOverridesChanged.as_wire_name(), "failover.overrides_changed"); +} diff --git a/shared/src/model/failover_group.rs b/shared/src/model/failover_group.rs new file mode 100644 index 000000000..aad86356f --- /dev/null +++ b/shared/src/model/failover_group.rs @@ -0,0 +1,794 @@ +use crate::model::{FailoverGroupPolicy, VideoCodec, VideoResolution, VirtualId}; +use std::collections::{HashMap, HashSet}; + +pub const FAILOVER_FILE_VERSION: u32 = 1; +pub const MAX_MANUAL_ID_LEN: usize = 64; +pub const MAX_GROUP_NAME_CHARS: usize = 200; +const ETAG_HEX_LEN: usize = 16; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] +pub enum CapacityTier { + Free, + OwnRelease, + Grace, + Preempt, + Exhausted, +} + +/// Why a running group stream replaced its member. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SwitchReason { + UpstreamError, + UpstreamEof, + Stall, + Underrun, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CircuitState { + Closed, + OpenUntil(std::time::Instant), + HalfOpenInFlight, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] +pub struct ChannelMemberRefDto { + pub input: String, + pub stream_id: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ManualFailoverGroupDto { + pub id: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub logo: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub epg_channel_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub playlist_group: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub members: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct AutoGroupOverrideDto { + pub auto_key: String, + /// Dissolves the auto group; its members stay ungrouped and keep their natural key. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub disabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub order: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub add: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub remove: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub logo: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub epg_channel_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub playlist_group: Option, +} + +/// A manual group that resolved too few members to be materialized. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct InactiveManualGroupDto { + pub id: String, + pub resolved: usize, +} + +/// A member reference of an override that matched no catalog entry. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct UnresolvedRefDto { + /// Manual group id or auto key the reference belongs to. + pub group: String, + pub member_ref: ChannelMemberRefDto, +} + +/// Diagnostics of one grouping resolution: corrections that had no effect and why. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct FailoverOverrideDiagnosticsDto { + /// Auto overrides whose `auto_key` matches no natural bucket. + #[serde(default)] + pub stale_auto_overrides: Vec, + #[serde(default)] + pub inactive_manual_groups: Vec, + /// `add` refs that could not be claimed because another group already holds them. + #[serde(default)] + pub conflicting_refs: Vec, + #[serde(default)] + pub unresolved_refs: Vec, + /// Auto keys dissolved through `disabled`. + #[serde(default)] + pub dissolved: Vec, +} + +impl FailoverOverrideDiagnosticsDto { + pub fn is_empty(&self) -> bool { + self.stale_auto_overrides.is_empty() + && self.inactive_manual_groups.is_empty() + && self.conflicting_refs.is_empty() + && self.unresolved_refs.is_empty() + && self.dissolved.is_empty() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct FailoverFileDto { + pub version: u32, + #[serde(default)] + pub targets: Vec, +} + +/// Override section of a single target inside a failover file. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct TargetFailoverOverridesDto { + pub target: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub groups: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub auto_overrides: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub excluded: Vec, +} + +/// Non-fatal finding of [`TargetFailoverOverridesDto::validate`]. +/// Group related variants carry the manual group id or the auto key of the offending entry. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(tag = "error", content = "detail", rename_all = "snake_case")] +pub enum OverrideValidationError { + EmptyManualId, + InvalidManualId(String), + DuplicateManualId(String), + EmptyName(String), + NameTooLong(String), + RefInMultipleGroups(ChannelMemberRefDto), + DuplicateAutoKey(String), + InvalidLogo(String), + /// An auto override `add` ref that is also excluded; the addition wins. + ExcludedAndAdded(ChannelMemberRefDto), +} + +impl std::fmt::Display for OverrideValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::EmptyManualId => write!(f, "manual group id is empty"), + Self::InvalidManualId(id) => { + write!(f, "manual group id '{id}' must match [a-z0-9-] and be at most {MAX_MANUAL_ID_LEN} characters") + } + Self::DuplicateManualId(id) => write!(f, "manual group id '{id}' is used more than once"), + Self::EmptyName(id) => write!(f, "manual group '{id}' has no name"), + Self::NameTooLong(id) => { + write!(f, "name of manual group '{id}' exceeds {MAX_GROUP_NAME_CHARS} characters") + } + Self::RefInMultipleGroups(member) => { + write!(f, "channel {}/{} is claimed by more than one group", member.input, member.stream_id) + } + Self::DuplicateAutoKey(key) => write!(f, "auto group override '{key}' is defined more than once"), + Self::InvalidLogo(id) => write!(f, "logo of '{id}' must be empty or start with http:// or https://"), + Self::ExcludedAndAdded(member) => write!( + f, + "channel {}/{} is excluded and added to a group; the addition wins", + member.input, member.stream_id + ), + } + } +} + +impl std::error::Error for OverrideValidationError {} + +fn trim_in_place(value: &mut String) { + let trimmed = value.trim(); + if trimmed.len() != value.len() { + *value = trimmed.to_string(); + } +} + +fn trim_opt(value: &mut Option) { + if let Some(inner) = value { + trim_in_place(inner); + if inner.is_empty() { + *value = None; + } + } +} + +fn canonicalize_refs(refs: &mut Vec) { + for member in refs.iter_mut() { + trim_in_place(&mut member.input); + trim_in_place(&mut member.stream_id); + } + let mut seen = HashSet::with_capacity(refs.len()); + refs.retain(|member| !member.input.is_empty() && !member.stream_id.is_empty() && seen.insert(member.clone())); +} + +fn is_valid_manual_id(id: &str) -> bool { + id.len() <= MAX_MANUAL_ID_LEN && id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') +} + +fn is_valid_logo(logo: Option<&str>) -> bool { + logo.is_none_or(|url| url.is_empty() || url.starts_with("http://") || url.starts_with("https://")) +} + +impl TargetFailoverOverridesDto { + pub fn new(target: &str) -> Self { + Self { target: target.to_string(), groups: Vec::new(), auto_overrides: Vec::new(), excluded: Vec::new() } + } + + pub fn canonicalize(&mut self) { + trim_in_place(&mut self.target); + for group in &mut self.groups { + trim_in_place(&mut group.id); + trim_in_place(&mut group.name); + trim_opt(&mut group.logo); + trim_opt(&mut group.epg_channel_id); + trim_opt(&mut group.playlist_group); + canonicalize_refs(&mut group.members); + } + for auto in &mut self.auto_overrides { + trim_in_place(&mut auto.auto_key); + trim_opt(&mut auto.name); + trim_opt(&mut auto.logo); + trim_opt(&mut auto.epg_channel_id); + trim_opt(&mut auto.playlist_group); + canonicalize_refs(&mut auto.order); + canonicalize_refs(&mut auto.add); + canonicalize_refs(&mut auto.remove); + } + canonicalize_refs(&mut self.excluded); + } + + pub fn is_empty(&self) -> bool { + self.groups.is_empty() && self.auto_overrides.is_empty() && self.excluded.is_empty() + } + + /// Reports every finding of a canonicalized section; findings are not fatal. + pub fn validate(&self) -> Vec { + let mut errors = Vec::new(); + + let mut ids = HashSet::with_capacity(self.groups.len()); + for group in &self.groups { + if group.id.is_empty() { + errors.push(OverrideValidationError::EmptyManualId); + } else { + if !is_valid_manual_id(&group.id) { + errors.push(OverrideValidationError::InvalidManualId(group.id.clone())); + } + if !ids.insert(group.id.as_str()) { + errors.push(OverrideValidationError::DuplicateManualId(group.id.clone())); + } + } + if group.name.is_empty() { + errors.push(OverrideValidationError::EmptyName(group.id.clone())); + } else if group.name.chars().count() > MAX_GROUP_NAME_CHARS { + errors.push(OverrideValidationError::NameTooLong(group.id.clone())); + } + if !is_valid_logo(group.logo.as_deref()) { + errors.push(OverrideValidationError::InvalidLogo(group.id.clone())); + } + } + + let mut auto_keys = HashSet::with_capacity(self.auto_overrides.len()); + for auto in &self.auto_overrides { + if !auto_keys.insert(auto.auto_key.as_str()) { + errors.push(OverrideValidationError::DuplicateAutoKey(auto.auto_key.clone())); + } + if !is_valid_logo(auto.logo.as_deref()) { + errors.push(OverrideValidationError::InvalidLogo(auto.auto_key.clone())); + } + } + + // A ref may be claimed by one manual group or one auto `add` list, never by two entries. + let manual_claims = + self.groups.iter().enumerate().flat_map(|(idx, group)| group.members.iter().map(move |m| (idx, m))); + let add_claims = self + .auto_overrides + .iter() + .enumerate() + .flat_map(|(idx, auto)| auto.add.iter().map(move |m| (self.groups.len() + idx, m))); + let mut owners: HashMap<&ChannelMemberRefDto, usize> = HashMap::new(); + let mut reported: HashSet<&ChannelMemberRefDto> = HashSet::new(); + for (owner, member) in manual_claims.chain(add_claims) { + let first_owner = *owners.entry(member).or_insert(owner); + if first_owner != owner && reported.insert(member) { + errors.push(OverrideValidationError::RefInMultipleGroups(member.clone())); + } + } + + // Exclusion only keeps a channel out of the natural buckets; an auto `add` still wins (E3). + if !self.excluded.is_empty() { + let excluded: HashSet<&ChannelMemberRefDto> = self.excluded.iter().collect(); + let mut reported: HashSet<&ChannelMemberRefDto> = HashSet::new(); + for member in self.auto_overrides.iter().flat_map(|auto| &auto.add) { + if excluded.contains(member) && reported.insert(member) { + errors.push(OverrideValidationError::ExcludedAndAdded(member.clone())); + } + } + } + + errors + } + + /// Short content hash of the serialized section. Callers pass a canonicalized section. + pub fn etag(&self) -> String { + let bytes = serde_json::to_vec(self).unwrap_or_default(); + blake3::hash(&bytes).to_hex()[..ETAG_HEX_LEN].to_string() + } +} + +/// How a persisted failover group came to be. +#[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum FailoverGroupOrigin { + Auto, + Manual, + AutoEdited, +} + +/// Selection score of a group member, as the ranking computes it. +#[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, +} + +/// Whether the target of an override section can be edited and reprocessed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FailoverTargetState { + /// The target exists and has failover groups enabled. + #[default] + Enabled, + /// The target exists, but failover groups are disabled for it. + Disabled, + /// No target of this name exists. + Orphan, +} + +/// Override section of one target as the targets overview shows it. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct FailoverGroupTargetStatusDto { + pub name: String, + /// Etag of the section in the current failover files. + pub etag: Option, + /// Etag of the section that produced the persisted groups. + pub applied_etag: Option, + pub file: Option, + pub findings: Vec, + pub group_count: usize, + pub problem_count: usize, + #[serde(default)] + pub state: FailoverTargetState, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct FailoverGroupTargetsDto { + /// File or directory the failover files are read from. + pub source: Option, + pub files_used: Vec, + pub load_error: Option, + pub targets: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct FailoverGroupMemberDto { + /// Virtual id of the member in the persisted group, `None` when the group is not persisted. + pub member_vid: Option, + pub member_ref: ChannelMemberRefDto, + pub title: String, + pub input_name: String, + pub provider_id: u32, + pub has_archive: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct FailoverGroupDto { + /// Virtual id of the persisted group, `None` for a group that only exists in a preview. + pub group_vid: Option, + /// Manual group id or auto key. + pub key: String, + pub origin: FailoverGroupOrigin, + pub policy: FailoverGroupPolicy, + pub name: String, + pub logo: Option, + pub epg_channel_id: Option, + pub playlist_group: Option, + pub members: Vec, + /// References of the overrides that matched no channel. + pub unresolved: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct FailoverGroupListDto { + /// Etag of the section in the current failover files, also for a preview of a draft. + pub etag: Option, + /// Etag of the section that produced the persisted groups. + pub applied_etag: Option, + pub groups: Vec, + pub diagnostics: FailoverOverrideDiagnosticsDto, + /// Keys of groups whose representative changed by a hot apply while the published playlist item still shows + /// the old one; cleared by the next playlist update. + #[serde(default)] + pub display_stale: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct TargetOverridesResponseDto { + pub etag: Option, + pub file: Option, + pub overrides: TargetFailoverOverridesDto, +} + +/// Body of the save route: the section the editor based on `expected_etag` (`None` = the section is new). +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct SaveOverridesRequestDto { + #[serde(default)] + pub expected_etag: Option, + pub overrides: TargetFailoverOverridesDto, +} + +/// Answer of a successful save. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct FailoverGroupSaveResultDto { + /// Etag of the stored section, `None` when the section was emptied and removed. + pub etag: Option, + pub file: Option, + /// The change was applied to the persisted groups without a playlist update. + pub hot_applied: bool, +} + +/// Answer of a write whose `expected_etag` no longer matches the failover files. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct FailoverGroupConflictDto { + pub current_etag: Option, +} + +/// Body of the route that moves a section to another target. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ReassignOverridesRequestDto { + pub from: String, + pub to: String, + pub expected_etag: String, +} + +/// Which channels of the catalog a candidate search returns. +#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FailoverCandidateFilter { + #[default] + All, + Ungrouped, + Grouped, + Excluded, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct CandidateDto { + pub member_ref: ChannelMemberRefDto, + pub title: String, + pub logo: String, + pub playlist_group: String, + pub provider_id: u32, + pub has_archive: bool, + /// Key of the persisted group the channel is a member of. + pub group_key: Option, + pub excluded: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct CandidatePageDto { + /// Number of matches over all pages. + pub total: usize, + pub items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct MemberHealthDto { + pub member_vid: VirtualId, + pub member_ref: ChannelMemberRefDto, + pub title: String, + /// Position in the selection order, starting at 1. + pub rank: usize, + pub qos_24h: Option, + pub qos_7d: Option, + pub confidence: Option, + pub failure_streak: u32, + pub resolution: Option, + pub codec: Option, + pub bitrate_kbps: Option, + /// `closed`, `counting`, `open` or `half_open`. + pub circuit: String, + pub circuit_remaining_secs: Option, + /// State of the circuit of the member's whole input, same values as `circuit`. + pub input_circuit: String, + /// Connections in use on the accounts of the member's input. + pub slots_used: u32, + /// Connection limit of the member's input accounts; accounts without a limit add nothing. + pub slots_max: u32, + pub score: ScoreBreakdown, +} + +/// Union of the account pools of all members of a group, counted once per account. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct GroupCapacityDto { + pub accounts: u32, + pub max_connections: u32, + pub in_use: u32, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct FailoverGroupHealthDto { + /// Members in selection order. + pub members: Vec, + pub capacity: GroupCapacityDto, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn member(input: &str, stream_id: &str) -> ChannelMemberRefDto { + ChannelMemberRefDto { input: input.to_string(), stream_id: stream_id.to_string() } + } + + #[test] + fn override_diagnostics_default_is_empty_and_roundtrips() { + let mut diagnostics = FailoverOverrideDiagnosticsDto::default(); + assert!(diagnostics.is_empty()); + diagnostics.unresolved_refs.push(UnresolvedRefDto { group: "zdf".to_string(), member_ref: member("a", "1") }); + assert!(!diagnostics.is_empty()); + let json = serde_json::to_string(&diagnostics).expect("serialize"); + assert_eq!(serde_json::from_str::(&json).expect("deserialize"), diagnostics); + } + + fn manual(id: &str, members: Vec) -> ManualFailoverGroupDto { + ManualFailoverGroupDto { id: id.to_string(), name: format!("Group {id}"), members, ..Default::default() } + } + + #[test] + fn canonicalize_trims_and_dedups_refs() { + let mut section = TargetFailoverOverridesDto::new(" Main TV "); + section.groups.push(ManualFailoverGroupDto { + id: " sport ".to_string(), + name: " Sport ".to_string(), + logo: Some(" ".to_string()), + epg_channel_id: Some(" epg ".to_string()), + members: vec![member(" a ", " 1 "), member("a", "1"), member("", "2"), member("b", " ")], + ..Default::default() + }); + section.auto_overrides.push(AutoGroupOverrideDto { + auto_key: " zdf ".to_string(), + name: Some(" ZDF ".to_string()), + order: vec![member("a", "1"), member(" a", "1 ")], + add: vec![member("b", "2"), member("b", "2")], + remove: vec![member(" ", "3")], + ..Default::default() + }); + section.excluded = vec![member(" c ", "9"), member("c", " 9"), member("c", "")]; + section.canonicalize(); + + assert_eq!(section.target, "Main TV"); + let group = §ion.groups[0]; + assert_eq!(group.id, "sport"); + assert_eq!(group.name, "Sport"); + assert_eq!(group.logo, None); + assert_eq!(group.epg_channel_id.as_deref(), Some("epg")); + assert_eq!(group.members, vec![member("a", "1")]); + let auto = §ion.auto_overrides[0]; + assert_eq!(auto.auto_key, "zdf"); + assert_eq!(auto.name.as_deref(), Some("ZDF")); + assert_eq!(auto.order, vec![member("a", "1")]); + assert_eq!(auto.add, vec![member("b", "2")]); + assert!(auto.remove.is_empty()); + assert_eq!(section.excluded, vec![member("c", "9")]); + } + + #[test] + fn validate_reports_duplicate_and_invalid_ids() { + let mut section = TargetFailoverOverridesDto::new("T"); + section.groups = vec![manual("Zdf", vec![]), manual("", vec![]), manual("a", vec![]), manual("a", vec![])]; + let errors = section.validate(); + assert_eq!( + errors, + vec![ + OverrideValidationError::InvalidManualId("Zdf".to_string()), + OverrideValidationError::EmptyManualId, + OverrideValidationError::DuplicateManualId("a".to_string()), + ] + ); + } + + #[test] + fn validate_reports_name_problems() { + let mut section = TargetFailoverOverridesDto::new("T"); + let mut empty = manual("a", vec![]); + empty.name = String::new(); + let mut long = manual("b", vec![]); + long.name = "x".repeat(MAX_GROUP_NAME_CHARS + 1); + let mut ok = manual("c", vec![]); + ok.name = "ä".repeat(MAX_GROUP_NAME_CHARS); + section.groups = vec![empty, long, ok]; + assert_eq!( + section.validate(), + vec![ + OverrideValidationError::EmptyName("a".to_string()), + OverrideValidationError::NameTooLong("b".to_string()), + ] + ); + } + + #[test] + fn validate_reports_ref_in_two_manual_groups() { + let mut section = TargetFailoverOverridesDto::new("T"); + section.groups = + vec![manual("a", vec![member("p", "1")]), manual("b", vec![member("p", "1"), member("p", "2")])]; + assert_eq!(section.validate(), vec![OverrideValidationError::RefInMultipleGroups(member("p", "1"))]); + } + + #[test] + fn validate_reports_ref_in_manual_group_and_auto_add() { + let mut section = TargetFailoverOverridesDto::new("T"); + section.groups = vec![manual("a", vec![member("p", "1")])]; + section.auto_overrides.push(AutoGroupOverrideDto { + auto_key: "zdf".to_string(), + add: vec![member("p", "1")], + ..Default::default() + }); + assert_eq!(section.validate(), vec![OverrideValidationError::RefInMultipleGroups(member("p", "1"))]); + } + + #[test] + fn validate_allows_same_ref_in_add_and_order_of_one_entry() { + let mut section = TargetFailoverOverridesDto::new("T"); + section.auto_overrides.push(AutoGroupOverrideDto { + auto_key: "zdf".to_string(), + add: vec![member("p", "1")], + order: vec![member("p", "1")], + ..Default::default() + }); + assert!(section.validate().is_empty()); + } + + #[test] + fn validate_reports_excluded_ref_that_is_added() { + let mut section = TargetFailoverOverridesDto::new("T"); + section.excluded = vec![member("p", "1"), member("p", "2")]; + section.auto_overrides.push(AutoGroupOverrideDto { + auto_key: "zdf".to_string(), + add: vec![member("p", "1"), member("p", "3")], + ..Default::default() + }); + let findings = section.validate(); + assert_eq!(findings, vec![OverrideValidationError::ExcludedAndAdded(member("p", "1"))]); + assert_eq!(findings[0].to_string(), "channel p/1 is excluded and added to a group; the addition wins"); + } + + #[test] + fn validate_allows_excluded_ref_in_manual_group() { + let mut section = TargetFailoverOverridesDto::new("T"); + section.excluded = vec![member("p", "1")]; + section.groups = vec![manual("a", vec![member("p", "1")])]; + assert!(section.validate().is_empty()); + } + + #[test] + fn validate_reports_duplicate_auto_key() { + let mut section = TargetFailoverOverridesDto::new("T"); + for _ in 0..2 { + section.auto_overrides.push(AutoGroupOverrideDto { auto_key: "zdf".to_string(), ..Default::default() }); + } + assert_eq!(section.validate(), vec![OverrideValidationError::DuplicateAutoKey("zdf".to_string())]); + } + + #[test] + fn validate_rejects_non_http_logo() { + let mut section = TargetFailoverOverridesDto::new("T"); + let mut bad = manual("a", vec![]); + bad.logo = Some("ftp://x".to_string()); + let mut good = manual("b", vec![]); + good.logo = Some("https://example.invalid/l.png".to_string()); + section.groups = vec![bad, good]; + section.auto_overrides.push(AutoGroupOverrideDto { + auto_key: "zdf".to_string(), + logo: Some("javascript:alert(1)".to_string()), + ..Default::default() + }); + assert_eq!( + section.validate(), + vec![ + OverrideValidationError::InvalidLogo("a".to_string()), + OverrideValidationError::InvalidLogo("zdf".to_string()), + ] + ); + } + + #[test] + fn disabled_defaults_false_and_is_not_serialized() { + let auto: AutoGroupOverrideDto = serde_json::from_str(r#"{"auto_key":"zdf"}"#).expect("parses"); + assert!(!auto.disabled); + let json = serde_json::to_string(&auto).expect("serializes"); + assert!(!json.contains("disabled")); + let disabled = AutoGroupOverrideDto { auto_key: "zdf".to_string(), disabled: true, ..Default::default() }; + assert!(serde_json::to_string(&disabled).expect("serializes").contains(r#""disabled":true"#)); + } + + #[test] + fn etag_is_stable_and_changes_on_any_field() { + let build = || { + let mut section = TargetFailoverOverridesDto::new("T"); + section.groups = vec![manual("a", vec![member("p", "1")])]; + section + }; + let a = build(); + let b = build(); + assert_eq!(a.etag(), b.etag()); + assert_eq!(a.etag().len(), 16); + + let mut renamed = build(); + renamed.groups[0].name = "Other".to_string(); + assert_ne!(a.etag(), renamed.etag()); + let mut excluded = build(); + excluded.excluded.push(member("p", "2")); + assert_ne!(a.etag(), excluded.etag()); + } + + #[test] + fn is_empty_ignores_target_name() { + let mut section = TargetFailoverOverridesDto::new("T"); + assert!(section.is_empty()); + section.excluded.push(member("p", "1")); + assert!(!section.is_empty()); + } + + #[test] + fn validation_error_serializes_with_snake_case_tag() { + let json = serde_json::to_string(&OverrideValidationError::InvalidManualId("Zdf".to_string())).expect("json"); + assert_eq!(json, r#"{"error":"invalid_manual_id","detail":"Zdf"}"#); + let json = serde_json::to_string(&OverrideValidationError::EmptyManualId).expect("json"); + assert_eq!(json, r#"{"error":"empty_manual_id"}"#); + let back: OverrideValidationError = serde_json::from_str(&json).expect("roundtrip"); + assert_eq!(back, OverrideValidationError::EmptyManualId); + } + + #[test] + fn failover_file_roundtrips_multiple_targets() { + let mut main = TargetFailoverOverridesDto::new("Main TV"); + main.groups = vec![manual("sport", vec![member("a", "1")])]; + main.auto_overrides.push(AutoGroupOverrideDto { + auto_key: "rtl|rtl.de".to_string(), + disabled: true, + ..Default::default() + }); + let kids = TargetFailoverOverridesDto::new("Kids"); + let file = FailoverFileDto { version: FAILOVER_FILE_VERSION, targets: vec![main, kids] }; + let json = serde_json::to_string(&file).expect("serializes"); + let back: FailoverFileDto = serde_json::from_str(&json).expect("parses"); + assert_eq!(back, file); + let empty: FailoverFileDto = serde_json::from_str(r#"{"version":1}"#).expect("parses"); + assert!(empty.targets.is_empty()); + } + + #[test] + fn target_state_serializes_snake_case_and_older_status_defaults_to_enabled() { + assert_eq!(serde_json::to_string(&FailoverTargetState::Orphan).expect("serialize"), "\"orphan\""); + assert_eq!(serde_json::to_string(&FailoverTargetState::Disabled).expect("serialize"), "\"disabled\""); + let status: FailoverGroupTargetStatusDto = serde_json::from_str( + r#"{"name":"t","etag":null,"applied_etag":null,"file":null,"findings":[],"group_count":0,"problem_count":0}"#, + ) + .expect("deserialize"); + assert_eq!(status.state, FailoverTargetState::Enabled); + let list: FailoverGroupListDto = + serde_json::from_str(r#"{"etag":null,"applied_etag":null,"groups":[],"diagnostics":{}}"#).expect("list"); + assert!(list.display_stale.is_empty()); + } +} diff --git a/shared/src/model/item_field.rs b/shared/src/model/item_field.rs index f33c061c1..b607e0be0 100644 --- a/shared/src/model/item_field.rs +++ b/shared/src/model/item_field.rs @@ -79,7 +79,7 @@ pub enum HeaderField { EpgChannelId, Chno, Genre, - ChannelGroup, + FailoverGroup, } impl HeaderField { @@ -111,7 +111,7 @@ impl HeaderField { ("epg_id", HeaderField::EpgChannelId), ("chno", HeaderField::Chno), ("genre", HeaderField::Genre), - ("channel_group", HeaderField::ChannelGroup), + ("failover_group", HeaderField::FailoverGroup), ]; TABLE.iter().find(|(key, _)| name.eq_ignore_ascii_case(key)).map(|(_, field)| *field) } @@ -137,7 +137,7 @@ impl HeaderField { Self::EpgChannelId => "epg_channel_id", Self::Chno => "chno", Self::Genre => "genre", - Self::ChannelGroup => "channel_group", + Self::FailoverGroup => "failover_group", } } } diff --git a/shared/src/model/mapping.rs b/shared/src/model/mapping.rs index ab4c70806..09d027520 100644 --- a/shared/src/model/mapping.rs +++ b/shared/src/model/mapping.rs @@ -31,7 +31,7 @@ pub const MAPPER_FIELDS: &[HeaderField] = &[ HeaderField::Rec, HeaderField::Url, HeaderField::EpgChannelId, - HeaderField::ChannelGroup, + HeaderField::FailoverGroup, ]; /// Whether `name` resolves to one of `allowed`. diff --git a/shared/src/model/mod.rs b/shared/src/model/mod.rs index c5a8f731e..3109606bf 100644 --- a/shared/src/model/mod.rs +++ b/shared/src/model/mod.rs @@ -1,11 +1,11 @@ mod active_user_connection_change; mod auth; mod auth_audit; -pub mod channel_group; mod cluster_flags; mod config; mod connection_denied; mod custom_video_stream_type; +pub mod failover_group; mod epg; mod epg_grid; @@ -79,8 +79,8 @@ mod xtream; pub mod xtream_const; pub use self::{ - active_user_connection_change::*, auth::*, auth_audit::*, channel_group::*, cluster_flags::*, config::*, - connection_denied::*, custom_video_stream_type::*, epg::*, epg_grid::*, epg_request::*, event::*, + active_user_connection_change::*, auth::*, auth_audit::*, cluster_flags::*, config::*, connection_denied::*, + custom_video_stream_type::*, epg::*, epg_grid::*, epg_request::*, event::*, failover_group::*, input_refresh_policy::*, input_update_action::*, ip_check::*, item_field::*, library_request::*, log::*, maintenance::*, mapping::*, media_properties::*, messaging::*, metadata_update_failure::*, notification::*, notification_dead_letter::*, pagination::*, playlist::*, playlist_categories::*, playlist_info_document::*, diff --git a/shared/src/model/playlist/header.rs b/shared/src/model/playlist/header.rs index 17e1d31c0..285d8b026 100644 --- a/shared/src/model/playlist/header.rs +++ b/shared/src/model/playlist/header.rs @@ -56,12 +56,12 @@ pub struct PlaylistItemHeader { pub input_stream_id: Arc, #[serde(default, rename = "source_user_agent", alias = "upstream_user_agent", with = "arc_str_option_serde")] pub upstream_user_agent: Option>, - /// Mapping override for channel grouping: `Some("")` opts out, `Some(key)` forces a key. + /// Mapping override for failover grouping: `Some("")` opts out, `Some(key)` forces a key. #[serde(default, with = "arc_str_option_serde")] - pub channel_group: Option>, + pub failover_group: Option>, /// Set on group items by the grouping stage; copies (favourites) inherit it. #[serde(default)] - pub channel_group_uuid: Option, + pub failover_group_uuid: Option, } impl Default for PlaylistItemHeader { @@ -90,8 +90,8 @@ impl Default for PlaylistItemHeader { source_ordinal: 0, input_stream_id: "".intern(), upstream_user_agent: None, - channel_group: None, - channel_group_uuid: None, + failover_group: None, + failover_group_uuid: None, } } } @@ -123,6 +123,11 @@ impl PlaylistItemHeader { #[inline] pub const fn get_uuid(&self) -> &UUIDType { &self.uuid } + /// Numeric provider id of the stream, `0` when neither the id nor the url carries one. + #[inline] + #[must_use] + pub fn provider_id_or_default(&self) -> u32 { get_provider_id(&self.id, &self.url).unwrap_or_default() } + pub fn get_provider_id(&mut self) -> Option { match get_provider_id(&self.id, &self.url) { None => None, @@ -174,7 +179,7 @@ impl crate::model::FieldGet for crate::model::PlaylistItemHeader { HeaderField::Genre => { self.additional_properties.as_ref().and_then(StreamProperties::genre).map(FieldRef::Shared) } - HeaderField::ChannelGroup => self.channel_group.as_ref().map(FieldRef::Shared), + HeaderField::FailoverGroup => self.failover_group.as_ref().map(FieldRef::Shared), // Not carried by the header. HeaderField::ProviderId => None, } @@ -201,7 +206,7 @@ impl crate::model::FieldSet for crate::model::PlaylistItemHeader { self.name = interned; } HeaderField::EpgChannelId => self.epg_channel_id = Some(value.intern()), - HeaderField::ChannelGroup => self.channel_group = Some(value.intern()), + HeaderField::FailoverGroup => self.failover_group = Some(value.intern()), HeaderField::Chno => match value.parse::() { Ok(parsed) => self.chno = parsed, Err(_) => return false, diff --git a/shared/src/model/playlist/item.rs b/shared/src/model/playlist/item.rs index ac7bf214b..aa79a7cdf 100644 --- a/shared/src/model/playlist/item.rs +++ b/shared/src/model/playlist/item.rs @@ -273,8 +273,8 @@ impl From<&XtreamPlaylistItem> for PlaylistItem { source_ordinal: item.source_ordinal, input_stream_id: input_stream_id.unwrap_or_else(|| "".intern()), upstream_user_agent: item.upstream_user_agent.clone(), - channel_group: None, - channel_group_uuid: None, + failover_group: None, + failover_group_uuid: None, }; PlaylistItem { header } @@ -307,8 +307,8 @@ impl From<&M3uPlaylistItem> for PlaylistItem { source_ordinal: item.source_ordinal, input_stream_id: item.get_input_stream_id().unwrap_or_else(|| "".intern()), upstream_user_agent: item.upstream_user_agent.clone(), - channel_group: None, - channel_group_uuid: None, + failover_group: None, + failover_group_uuid: None, }; PlaylistItem { header } @@ -379,8 +379,8 @@ impl PlaylistItem { source_ordinal: 0, input_stream_id: stream_id_str, upstream_user_agent: None, - channel_group: None, - channel_group_uuid: None, + failover_group: None, + failover_group_uuid: None, }; PlaylistItem { header } diff --git a/shared/src/model/playlist/m3u.rs b/shared/src/model/playlist/m3u.rs index 82f95d98c..8251c029c 100644 --- a/shared/src/model/playlist/m3u.rs +++ b/shared/src/model/playlist/m3u.rs @@ -305,7 +305,7 @@ impl crate::model::FieldGet for M3uPlaylistItem { | HeaderField::Input | HeaderField::Type | HeaderField::Genre - | HeaderField::ChannelGroup => None, + | HeaderField::FailoverGroup => None, } } } diff --git a/shared/src/model/playlist/tests/identity.rs b/shared/src/model/playlist/tests/identity.rs index 6d9be5e6c..8b7833ad0 100644 --- a/shared/src/model/playlist/tests/identity.rs +++ b/shared/src/model/playlist/tests/identity.rs @@ -179,22 +179,22 @@ fn legacy_playlist_items_fall_back_to_provider_id_without_virtual_id() { } #[test] -fn channel_group_field_round_trips_through_dsl_setters() { +fn failover_group_field_round_trips_through_dsl_setters() { use crate::model::{FieldGet, FieldSet, HeaderField, PlaylistItemHeader}; let mut header = PlaylistItemHeader::default(); - assert_eq!(HeaderField::parse("channel_group"), Some(HeaderField::ChannelGroup)); - assert_eq!(HeaderField::ChannelGroup.as_str(), "channel_group"); - assert!(header.set(HeaderField::ChannelGroup, "ZDF")); - assert_eq!(header.channel_group.as_deref(), Some("ZDF")); - assert!(header.set(HeaderField::ChannelGroup, "")); - assert_eq!(header.channel_group.as_deref(), Some("")); - assert!(header.get(HeaderField::ChannelGroup).is_some()); + assert_eq!(HeaderField::parse("failover_group"), Some(HeaderField::FailoverGroup)); + assert_eq!(HeaderField::FailoverGroup.as_str(), "failover_group"); + assert!(header.set(HeaderField::FailoverGroup, "ZDF")); + assert_eq!(header.failover_group.as_deref(), Some("ZDF")); + assert!(header.set(HeaderField::FailoverGroup, "")); + assert_eq!(header.failover_group.as_deref(), Some("")); + assert!(header.get(HeaderField::FailoverGroup).is_some()); } #[test] -fn header_without_channel_group_field_still_deserializes() { +fn header_without_failover_group_field_still_deserializes() { let mut value = serde_json::to_value(crate::model::PlaylistItemHeader::default()).expect("serializes"); - value.as_object_mut().expect("object").remove("channel_group"); + value.as_object_mut().expect("object").remove("failover_group"); let header: crate::model::PlaylistItemHeader = serde_json::from_value(value).expect("deserializes"); - assert!(header.channel_group.is_none()); + assert!(header.failover_group.is_none()); } diff --git a/shared/src/model/playlist/tests/m3u.rs b/shared/src/model/playlist/tests/m3u.rs index 4ba5cdb26..4dfd14bcb 100644 --- a/shared/src/model/playlist/tests/m3u.rs +++ b/shared/src/model/playlist/tests/m3u.rs @@ -21,7 +21,7 @@ fn header_field_parse_round_trips_every_variant() { HeaderField::EpgChannelId, HeaderField::Chno, HeaderField::Genre, - HeaderField::ChannelGroup, + HeaderField::FailoverGroup, ] { assert_eq!(HeaderField::parse(field.as_str()), Some(field), "{field} did not round-trip"); // Lookup stayed case-insensitive. @@ -46,7 +46,7 @@ fn m3u_item_exposes_provider_id_but_not_the_header_only_fields() { assert_eq!(item.get_field("chno").as_deref(), Some("7")); assert_eq!(item.get_field("caption").as_deref(), Some("Channel")); - for absent in ["id", "input", "type", "genre", "channel_group", "not_a_field"] { + for absent in ["id", "input", "type", "genre", "failover_group", "not_a_field"] { assert!(item.get_field(absent).is_none(), "{absent} must not resolve on an M3U item"); } } diff --git a/shared/src/model/playlist_update_run.rs b/shared/src/model/playlist_update_run.rs index d5a400a28..ab9d43315 100644 --- a/shared/src/model/playlist_update_run.rs +++ b/shared/src/model/playlist_update_run.rs @@ -60,6 +60,10 @@ pub struct PlaylistUpdateRunStateEvent { #[serde(default, skip_serializing_if = "Option::is_none")] pub execution_order: Option, pub state: PlaylistUpdateState, + /// Names of the targets the run processed. Empty when the run processed none or the sender does not + /// report them. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub targets: Vec, } impl PlaylistUpdateRunStateEvent { @@ -69,12 +73,22 @@ impl PlaylistUpdateRunStateEvent { execution_order: PlaylistUpdateRunOrder, state: PlaylistUpdateState, ) -> Self { - Self { run_id: Some(run_id), execution_order: Some(execution_order), state } + Self { run_id: Some(run_id), execution_order: Some(execution_order), state, targets: Vec::new() } } + #[must_use] + pub fn with_targets(mut self, targets: Vec) -> Self { + self.targets = targets; + self + } + + /// Whether the run processed the target `name`. + #[must_use] + pub fn processed_target(&self, name: &str) -> bool { self.targets.iter().any(|target| target == name) } + #[must_use] pub const fn uncorrelated(state: PlaylistUpdateState) -> Self { - Self { run_id: None, execution_order: None, state } + Self { run_id: None, execution_order: None, state, targets: Vec::new() } } } @@ -93,12 +107,16 @@ impl<'de> Deserialize<'de> for PlaylistUpdateRunStateEvent { #[serde(default)] execution_order: Option, state: PlaylistUpdateState, + #[serde(default)] + targets: Vec, }, } Ok(match CompatiblePayload::deserialize(deserializer)? { CompatiblePayload::Legacy(state) => Self::uncorrelated(state), - CompatiblePayload::Correlated { run_id, execution_order, state } => Self { run_id, execution_order, state }, + CompatiblePayload::Correlated { run_id, execution_order, state, targets } => { + Self { run_id, execution_order, state, targets } + } }) } } @@ -133,6 +151,20 @@ mod tests { Ok(()) } + #[test] + fn playlist_update_terminal_event_targets_are_optional_on_the_wire() -> Result<(), serde_json::Error> { + let event = PlaylistUpdateRunStateEvent::uncorrelated(PlaylistUpdateState::Success); + assert_eq!(serde_json::to_string(&event)?, r#"{"state":"Success"}"#); + let old = serde_json::from_str::(r#"{"run_id":"r","state":"Success"}"#)?; + assert!(old.targets.is_empty()); + let event = event.with_targets(vec!["Main TV".to_string()]); + let decoded = serde_json::from_str::(&serde_json::to_string(&event)?)?; + assert_eq!(decoded, event); + assert!(decoded.processed_target("Main TV")); + assert!(!decoded.processed_target("Kids")); + Ok(()) + } + #[test] fn playlist_update_progress_terminal_event_accepts_correlated_payload_without_execution_order( ) -> Result<(), serde_json::Error> { diff --git a/shared/src/model/ui_playlist_item.rs b/shared/src/model/ui_playlist_item.rs index 7b2b5cb9d..e45497241 100644 --- a/shared/src/model/ui_playlist_item.rs +++ b/shared/src/model/ui_playlist_item.rs @@ -35,6 +35,9 @@ pub struct UiPlaylistItem { pub rating: f64, #[serde(rename = "i", with = "arc_str_serde")] pub input_name: Arc, + /// Stable provider/origin id of the stream; empty when the item does not carry one. + #[serde(rename = "k", default, with = "arc_str_serde")] + pub input_stream_id: Arc, // EPG channel identifier for per-stream programme lookup. None when no EPG is configured. #[serde(rename = "e", default, skip_serializing_if = "Option::is_none", with = "arc_str_option_serde")] pub epg_channel_id: Option>, @@ -98,6 +101,7 @@ impl From<&CommonPlaylistItem> for UiPlaylistItem { category_id: item.category_id.unwrap_or(0), rating: get_rating(item.additional_properties.as_ref()), input_name: Arc::clone(&item.input_name), + input_stream_id: "".intern(), epg_channel_id: item.epg_channel_id.clone(), } } @@ -118,6 +122,7 @@ impl From for UiPlaylistItem { category_id: item.category_id, rating: get_rating(item.additional_properties.as_ref()), input_name: Arc::clone(&item.input_name), + input_stream_id: Arc::clone(&item.input_stream_id), epg_channel_id: item.epg_channel_id, } } @@ -138,6 +143,7 @@ impl From for UiPlaylistItem { category_id: 0, rating: 0.0, input_name: Arc::clone(&item.input_name), + input_stream_id: Arc::clone(&item.input_stream_id), epg_channel_id: item.epg_channel_id, } } @@ -159,6 +165,7 @@ impl From<&PlaylistItem> for UiPlaylistItem { category_id: header.category_id, rating: get_rating(header.additional_properties.as_ref()), input_name: Arc::clone(&header.input_name), + input_stream_id: Arc::clone(&header.input_stream_id), epg_channel_id: header.epg_channel_id.clone(), } } diff --git a/shared/src/model/view_type.rs b/shared/src/model/view_type.rs index a18ff0d07..8aa89922e 100644 --- a/shared/src/model/view_type.rs +++ b/shared/src/model/view_type.rs @@ -13,6 +13,7 @@ const PLAYLIST_UPDATE: &str = "playlist_update"; const PLAYLIST_SETTINGS: &str = "playlist_settings"; const PLAYLIST_EXPLORER: &str = "playlist_explorer"; const PLAYLIST_EPG: &str = "playlist_epg"; +const FAILOVER_GROUPS: &str = "failover_groups"; const RBAC: &str = "rbac"; const SOURCE_EDITOR: &str = "source_editor"; const STREAM_HISTORY: &str = "stream_history"; @@ -35,6 +36,7 @@ pub enum ViewType { PlaylistSettings, PlaylistExplorer, PlaylistEpg, + FailoverGroups, Rbac, RecordingLibrary, RecordingRules, @@ -57,6 +59,7 @@ impl ViewType { ViewType::PlaylistSettings => PLAYLIST_SETTINGS, ViewType::PlaylistExplorer => PLAYLIST_EXPLORER, ViewType::PlaylistEpg => PLAYLIST_EPG, + ViewType::FailoverGroups => FAILOVER_GROUPS, ViewType::Rbac => RBAC, ViewType::RecordingLibrary => RECORDING_LIBRARY, ViewType::RecordingRules => RECORDING_RULES, @@ -82,6 +85,7 @@ impl FromStr for ViewType { PLAYLIST_SETTINGS => Ok(ViewType::PlaylistSettings), PLAYLIST_EXPLORER => Ok(ViewType::PlaylistExplorer), PLAYLIST_EPG => Ok(ViewType::PlaylistEpg), + FAILOVER_GROUPS => Ok(ViewType::FailoverGroups), RBAC => Ok(ViewType::Rbac), RECORDING_LIBRARY => Ok(ViewType::RecordingLibrary), RECORDING_RULES => Ok(ViewType::RecordingRules), @@ -125,6 +129,13 @@ mod tests { use super::ViewType; use std::str::FromStr; + #[test] + fn failover_groups_view_round_trips_through_its_string_form() { + assert_eq!(ViewType::FailoverGroups.to_string(), "failover_groups"); + assert_eq!(ViewType::from_str("failover_groups").ok(), Some(ViewType::FailoverGroups)); + assert_eq!(ViewType::from_str("Failover_Groups").ok(), Some(ViewType::FailoverGroups)); + } + #[test] fn standalone_downloads_route_is_removed() { assert!(ViewType::from_str("downloads").is_err()); diff --git a/shared/src/model/web_socket.rs b/shared/src/model/web_socket.rs index 3761c4d7c..a311ea18d 100644 --- a/shared/src/model/web_socket.rs +++ b/shared/src/model/web_socket.rs @@ -120,6 +120,9 @@ pub enum ProtocolMessage { /// Inputs in maintenance after a change. Carries the whole map: it is small, /// and a client never has to merge. MaintenanceChangedResponse(crate::model::MaintenanceEntries), + /// Targets whose failover override section changed on disk and was reloaded. The channel + /// group editor re-fetches the section of an affected target. + FailoverOverridesChanged(Vec), /// The socket cannot serve recordings to this session, and the reason /// is actionable. /// diff --git a/test/fixtures/testkit/scenarios/failover-group-override-order.yml b/test/fixtures/testkit/scenarios/failover-group-override-order.yml new file mode 100644 index 000000000..0ebc104f2 --- /dev/null +++ b/test/fixtures/testkit/scenarios/failover-group-override-order.yml @@ -0,0 +1,60 @@ +schema_version: 1 +name: failover-group-override-order +tuliprox: + base_url: http://fixture.invalid + execution_mode: isolated_fixture + bootstrap: + command: "${env:TULIPROX_TESTKIT_SUT_BINARY}" + arguments: ["-s", "-c", "{config_file}", "-i", "{source_file}", "-a", "{api_proxy_file}", "--failover", "{run_directory}/failover.d"] + readiness_url: "{base_url}/healthcheck" + extra_config_files: + - source: fixtures/failover-group-override-order.yml + destination: failover.d/testkit.yml + fixture_stream: { share_live_hls: false, share_live_mpeg_ts: false } + failover_groups: + inputs: [input-a, input-b] +origin: + run_id: failover-group-override-order +actors: + - id: velora-viewer + agent: local + username: solis + user_agent: tuliprox-testkit/velora-viewer + client_ip: { mode: forwarded, value: 10.40.0.10 } + - id: kestrel-viewer + agent: local + username: solis + user_agent: tuliprox-testkit/kestrel-viewer + client_ip: { mode: forwarded, value: 10.40.0.11 } +channels: + velora-one: { origin_marker: 17, protocol: hls } + kestrel-news: { origin_marker: 18, protocol: hls } +steps: + - command_id: play-velora-one-overridden-order + start: + actor: velora-viewer + playback_id: velora-one + session_group: velora-one + channel: velora-one + await: { valid_frames: 3 } + assert_origin: + latest_request_account: input-b + - command_id: play-kestrel-news-auto-group + start: + actor: kestrel-viewer + playback_id: kestrel-news + session_group: kestrel-news + channel: kestrel-news + await: { valid_frames: 3 } + assert_origin: + latest_request_account: input-a + - command_id: stop-velora-one + stop: { playback_id: velora-one } + - command_id: stop-kestrel-news + stop: { playback_id: kestrel-news } + assert_runtime: { active_users: 0, active_user_connections: 0 } +policy_contract: + user_access_control: true + users: + solis: { max_connections: 2 } + admission_strategies: [] diff --git a/test/fixtures/testkit/scenarios/fixtures/failover-group-override-order.yml b/test/fixtures/testkit/scenarios/fixtures/failover-group-override-order.yml new file mode 100644 index 000000000..f1e98a11a --- /dev/null +++ b/test/fixtures/testkit/scenarios/fixtures/failover-group-override-order.yml @@ -0,0 +1,8 @@ +version: 1 +targets: + - target: testkit + auto_overrides: + - auto_key: "test channel 17" + policy: priority + order: + - { input: input-b, stream_id: "17" } diff --git a/tools/tuliprox-testkit/src/bootstrap.rs b/tools/tuliprox-testkit/src/bootstrap.rs index 8f534f561..ae47f80ad 100644 --- a/tools/tuliprox-testkit/src/bootstrap.rs +++ b/tools/tuliprox-testkit/src/bootstrap.rs @@ -1,5 +1,8 @@ use crate::{ - config::{BootstrapConfig, Channel, FixtureInputType, FixtureStalker, PolicyContract}, + config::{ + is_confined_relative_path, BootstrapConfig, Channel, ExtraConfigFile, FixtureInputType, FixtureStalker, + PolicyContract, + }, TestkitError, }; use std::{ @@ -19,6 +22,9 @@ const FIXTURE_PASSWORD: &str = "testkit-password"; pub struct FixtureInputPlan<'a> { pub input_type: FixtureInputType, pub stalker: &'a FixtureStalker, + /// Names of the separate m3u inputs of a failover-group fixture; empty for the single + /// generated input. + pub failover_inputs: &'a [String], } impl FixtureInputPlan<'_> { @@ -79,6 +85,37 @@ impl FixturePaths { } } +impl FixturePaths { + /// Copies the scenario's extra config files into the isolated config directory, creating + /// intermediate directories. Sources resolve against `source_root`, destinations against + /// the run root, which also holds `config.yml`. + pub fn copy_extra_config_files(&self, files: &[ExtraConfigFile], source_root: &Path) -> Result<(), TestkitError> { + for file in files { + if !is_confined_relative_path(&file.destination) { + return Err(TestkitError::Configuration(format!( + "extra config file destination '{}' must stay inside the config directory", + file.destination + ))); + } + if !is_confined_relative_path(&file.source) { + return Err(TestkitError::Configuration(format!( + "extra config file source '{}' must stay inside the scenario directory", + file.source + ))); + } + let destination = self.root.join(&file.destination); + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent)?; + } + let source = source_root.join(&file.source); + std::fs::copy(&source, &destination).map_err(|error| { + TestkitError::Configuration(format!("cannot copy extra config file {}: {error}", source.display())) + })?; + } + Ok(()) + } +} + #[must_use] pub const fn fixture_password() -> &'static str { FIXTURE_PASSWORD } @@ -156,11 +193,24 @@ fn render_sources( let share_hls = fixture_stream.share_live_hls; let share_mpeg_ts = fixture_stream.share_live_mpeg_ts; let extra_output = if add_xtream_output { " - type: xtream\n" } else { "" }; + let source_inputs = render_source_input_names(input); + let failover_options = + if input.failover_inputs.is_empty() { "" } else { " failover_groups:\n enabled: true\n" }; format!( - "inputs:\n{provider_config}sources:\n - inputs:\n - testkit-origin\n targets:\n - name: testkit\n output:\n - type: m3u\n{extra_output} options:\n share_live_streams:\n hls: {share_hls}\n mpeg_ts: {share_mpeg_ts}\n# Origin markers for this fixture: {marker_comment}\n" + "inputs:\n{provider_config}sources:\n - inputs:\n{source_inputs} targets:\n - name: testkit\n output:\n - type: m3u\n{extra_output} options:\n share_live_streams:\n hls: {share_hls}\n mpeg_ts: {share_mpeg_ts}\n{failover_options}# Origin markers for this fixture: {marker_comment}\n" ) } +fn render_source_input_names(input: &FixtureInputPlan<'_>) -> String { + if input.failover_inputs.is_empty() { + return " - testkit-origin\n".to_owned(); + } + input.failover_inputs.iter().fold(String::new(), |mut rendered, name| { + let _ = writeln!(rendered, " - {}", yaml_string(name)); + rendered + }) +} + fn render_provider_input( run_id: &str, origin_address: SocketAddr, @@ -170,6 +220,9 @@ fn render_provider_input( if input.is_stalker() { return render_stalker_input(origin_address, input.stalker); } + if !input.failover_inputs.is_empty() { + return render_failover_inputs(run_id, origin_address, input.failover_inputs); + } render_provider_pool(run_id, origin_address, policy) } @@ -186,6 +239,19 @@ fn render_stalker_input(origin_address: SocketAddr, stalker: &FixtureStalker) -> ) } +/// One m3u input per failover member. Each input is served as its own origin account with +/// the member-shaped catalog (live channels only, stable stream ids). +fn render_failover_inputs(run_id: &str, origin_address: SocketAddr, names: &[String]) -> String { + names.iter().fold(String::new(), |mut rendered, name| { + let _ = writeln!( + rendered, + " - name: {}\n type: m3u\n url: http://{origin_address}/catalog/input.m3u?run={run_id}&account={name}&failover_member=true", + yaml_string(name) + ); + rendered + }) +} + fn render_provider_pool(run_id: &str, origin_address: SocketAddr, policy: Option<&PolicyContract>) -> String { let accounts = policy.map(|contract| contract.provider_pool.as_slice()).unwrap_or_default(); if let Some((root, aliases)) = accounts.split_first() { @@ -341,6 +407,7 @@ impl IsolatedFixture { add_xtream_output, input, )?; + paths.copy_extra_config_files(&bootstrap.extra_config_files, working_directory)?; let markers = fixture_markers(channels); let mut origin_cmd = Command::new(testkit_binary); origin_cmd @@ -463,6 +530,7 @@ fn render_bootstrap( arguments: bootstrap.arguments.iter().map(|argument| render(argument)).collect(), readiness_url: render(&bootstrap.readiness_url), readiness_timeout_millis: bootstrap.readiness_timeout_millis, + extra_config_files: bootstrap.extra_config_files.clone(), }) } @@ -564,6 +632,7 @@ mod tests { arguments: Vec::new(), readiness_url: "http://127.0.0.1:1/ready".to_owned(), readiness_timeout_millis: 5_000, + extra_config_files: Vec::new(), }; let started_at = tokio::time::Instant::now(); let result = IsolatedSut::start(&config, Path::new("/tmp")).await; @@ -604,6 +673,7 @@ mod tests { &FixtureInputPlan { input_type: crate::config::FixtureInputType::M3u, stalker: &crate::config::FixtureStalker::default(), + failover_inputs: &[], }, )?; let config = std::fs::read_to_string(&fixture.config_file)?; @@ -642,6 +712,7 @@ mod tests { FixtureInputPlan { input_type: crate::config::FixtureInputType::M3u, stalker: Box::leak(Box::new(crate::config::FixtureStalker::default())), + failover_inputs: &[], } } @@ -654,7 +725,11 @@ mod tests { refuse_create_link_markers: vec![19], separate_descriptor_command: false, }; - let plan = FixtureInputPlan { input_type: crate::config::FixtureInputType::Stalker, stalker: &stalker }; + let plan = FixtureInputPlan { + input_type: crate::config::FixtureInputType::Stalker, + stalker: &stalker, + failover_inputs: &[], + }; let source = render_sources( "run", "127.0.0.1:9910".parse().unwrap(), @@ -702,4 +777,117 @@ mod tests { assert!(!source.contains("type: xtream")); assert!(source.contains("mpeg_ts: true")); } + + fn fixture_paths_for_tests() -> Result { + FixturePaths::create( + "extra-files-run", + "127.0.0.1:9910".parse::().map_err(|error| TestkitError::Configuration(error.to_string()))?, + "127.0.0.1:8901".parse::().map_err(|error| TestkitError::Configuration(error.to_string()))?, + None, + &HashMap::new(), + &crate::config::FixtureStreamOptions::default(), + false, + &m3u_input(), + ) + } + + #[test] + fn bootstrap_copies_extra_config_files() -> Result<(), TestkitError> { + let source_root = tempfile::tempdir()?; + std::fs::create_dir_all(source_root.path().join("fixtures"))?; + std::fs::write(source_root.path().join("fixtures/override.yml"), "version: 1\ntargets: []\n")?; + let paths = fixture_paths_for_tests()?; + let files = [ExtraConfigFile { + source: "fixtures/override.yml".to_owned(), + destination: "failover.d/testkit.yml".to_owned(), + }]; + + paths.copy_extra_config_files(&files, source_root.path())?; + + let copied = std::fs::read_to_string(paths.root.join("failover.d/testkit.yml"))?; + assert_eq!(copied, "version: 1\ntargets: []\n"); + Ok(()) + } + + #[test] + fn extra_config_files_cannot_leave_the_config_directory() -> Result<(), TestkitError> { + let source_root = tempfile::tempdir()?; + std::fs::write(source_root.path().join("override.yml"), "version: 1\n")?; + let paths = fixture_paths_for_tests()?; + for destination in ["../escape.yml", "/tmp/escape.yml", ""] { + let files = [ExtraConfigFile { source: "override.yml".to_owned(), destination: destination.to_owned() }]; + assert!( + paths.copy_extra_config_files(&files, source_root.path()).is_err(), + "destination {destination:?} must be rejected" + ); + } + Ok(()) + } + + #[test] + fn extra_config_file_sources_cannot_leave_the_scenario_directory() -> Result<(), TestkitError> { + let source_root = tempfile::tempdir()?; + let paths = fixture_paths_for_tests()?; + for source in ["../escape.yml", "/etc/hostname", ""] { + let files = [ExtraConfigFile { source: source.to_owned(), destination: "failover.d/a.yml".to_owned() }]; + assert!( + paths.copy_extra_config_files(&files, source_root.path()).is_err(), + "source {source:?} must be rejected" + ); + } + Ok(()) + } + + #[test] + fn missing_extra_config_file_source_is_a_configuration_error() -> Result<(), TestkitError> { + let source_root = tempfile::tempdir()?; + let paths = fixture_paths_for_tests()?; + let files = [ExtraConfigFile { source: "absent.yml".to_owned(), destination: "failover.d/a.yml".to_owned() }]; + assert!(matches!( + paths.copy_extra_config_files(&files, source_root.path()), + Err(TestkitError::Configuration(message)) if message.contains("absent.yml") + )); + Ok(()) + } + + #[test] + fn failover_inputs_render_one_member_account_input_each_and_enable_the_target() { + let names = ["input-a".to_owned(), "input-b".to_owned()]; + let plan = FixtureInputPlan { + input_type: crate::config::FixtureInputType::M3u, + stalker: &crate::config::FixtureStalker::default(), + failover_inputs: &names, + }; + let source = render_sources( + "run", + "127.0.0.1:9910".parse().unwrap(), + &HashMap::new(), + None, + &crate::config::FixtureStreamOptions::default(), + false, + &plan, + ); + + assert!(source.contains(" - name: 'input-a'\n type: m3u\n"), "{source}"); + assert!(source.contains("account=input-a&failover_member=true"), "{source}"); + assert!(source.contains("account=input-b&failover_member=true"), "{source}"); + assert!(source.contains(" - inputs:\n - 'input-a'\n - 'input-b'\n targets:"), "{source}"); + assert!(source.contains("failover_groups:\n enabled: true\n"), "{source}"); + assert!(!source.contains("testkit-origin"), "{source}"); + } + + #[test] + fn single_input_fixture_keeps_failover_groups_off() { + let source = render_sources( + "run", + "127.0.0.1:9910".parse().unwrap(), + &HashMap::new(), + None, + &crate::config::FixtureStreamOptions::default(), + false, + &m3u_input(), + ); + assert!(source.contains(" - testkit-origin\n"), "{source}"); + assert!(!source.contains("failover_groups"), "{source}"); + } } diff --git a/tools/tuliprox-testkit/src/config.rs b/tools/tuliprox-testkit/src/config.rs index bfaa1719c..05989a34a 100644 --- a/tools/tuliprox-testkit/src/config.rs +++ b/tools/tuliprox-testkit/src/config.rs @@ -163,6 +163,15 @@ impl Default for FixtureStalker { } } +/// Replaces the single generated provider input with one m3u input per name, each served as +/// its own origin account, and turns failover groups on for the generated target. All inputs +/// carry the same channels, so the target groups them automatically. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FixtureFailoverGroups { + pub inputs: Vec, +} + #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct TuliproxConfig { @@ -174,6 +183,8 @@ pub struct TuliproxConfig { #[serde(default)] pub stalker: FixtureStalker, #[serde(default)] + pub failover_groups: Option, + #[serde(default)] pub admin_username: Option, #[serde(default)] pub admin_password_env: Option, @@ -213,6 +224,20 @@ pub struct BootstrapConfig { pub readiness_url: String, #[serde(default = "default_readiness_timeout_millis")] pub readiness_timeout_millis: u64, + /// Files copied into the isolated config directory before the SUT starts, for example a + /// `failover.d/` override file. + #[serde(default)] + pub extra_config_files: Vec, +} + +/// One fixture file placed next to the generated `config.yml`. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExtraConfigFile { + /// Path relative to the scenario file's directory. + pub source: String, + /// Path relative to the isolated config directory. Must stay below it. + pub destination: String, } fn default_readiness_timeout_millis() -> u64 { 30_000 } @@ -618,6 +643,8 @@ impl Scenario { self.validate_policy_contract()?; self.validate_playback_endpoint()?; self.validate_stalker_fixture()?; + self.validate_failover_groups_fixture()?; + self.validate_extra_config_files()?; Ok(()) } @@ -690,6 +717,48 @@ impl Scenario { Ok(()) } + /// Failover-group inputs are plain m3u inputs of the fixture origin; they cannot be + /// combined with a provider pool or the Stalker portal, which define the inputs themselves. + fn validate_failover_groups_fixture(&self) -> Result<(), TestkitError> { + let Some(failover) = &self.tuliprox.failover_groups else { + return Ok(()); + }; + let mut names = HashSet::new(); + let valid_names = failover.inputs.iter().all(|name| { + !name.is_empty() + && name.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + && names.insert(name.as_str()) + }); + if failover.inputs.len() < 2 || !valid_names { + return Err(TestkitError::Configuration( + "failover_groups requires at least two unique URL-safe input names".to_owned(), + )); + } + let uses_pool = self + .policy_contract + .as_ref() + .is_some_and(|contract| !contract.provider_pool.is_empty() || contract.provider_max_connections.is_some()); + if uses_pool || self.tuliprox.input_type != FixtureInputType::M3u { + return Err(TestkitError::Configuration( + "failover_groups cannot be combined with provider_pool, provider_max_connections or input_type: stalker" + .to_owned(), + )); + } + Ok(()) + } + + fn validate_extra_config_files(&self) -> Result<(), TestkitError> { + for file in self.tuliprox.bootstrap.iter().flat_map(|bootstrap| &bootstrap.extra_config_files) { + if !is_confined_relative_path(&file.destination) { + return Err(TestkitError::Configuration(format!( + "extra_config_files destination '{}' must be a relative path inside the config directory", + file.destination + ))); + } + } + Ok(()) + } + pub fn expanded_steps(&self) -> Result, TestkitError> { let mut expanded = Vec::new(); expand_steps(&self.steps, "", &mut expanded)?; @@ -697,6 +766,14 @@ impl Scenario { } } +/// True for a non-empty relative path made of normal components only, so joining it to a +/// directory can never leave that directory. +#[must_use] +pub fn is_confined_relative_path(path: &str) -> bool { + !path.is_empty() + && Path::new(path).components().all(|component| matches!(component, std::path::Component::Normal(_))) +} + fn validate_provider_pool_contract(contract: &PolicyContract) -> Result<(), TestkitError> { if contract.provider_max_connections.is_some() && !contract.provider_pool.is_empty() { return Err(TestkitError::Configuration( @@ -829,6 +906,7 @@ steps: fixture_stream: FixtureStreamOptions::default(), input_type: FixtureInputType::default(), stalker: FixtureStalker::default(), + failover_groups: None, }, origin: None, actors: vec![Actor { @@ -1007,12 +1085,14 @@ steps: arguments: Vec::new(), readiness_url: "http://example.invalid/healthcheck".to_owned(), readiness_timeout_millis: 1000, + extra_config_files: Vec::new(), }), execution_mode: ExecutionMode::IsolatedFixture, playback_endpoint: PlaybackEndpoint::M3u, fixture_stream: FixtureStreamOptions::default(), input_type: FixtureInputType::Stalker, stalker: FixtureStalker::default(), + failover_groups: None, }, origin: None, actors: Vec::new(), @@ -1138,4 +1218,72 @@ steps: .unwrap(); assert!(scenario.validate().is_err()); } + + fn failover_scenario(extra: &str) -> Result { + let yaml = format!( + r" +schema_version: 1 +name: failover-validation +tuliprox: + base_url: 'http://example.invalid' + bootstrap: + command: 'true' + readiness_url: 'http://example.invalid/healthcheck' +{extra} +" + ); + serde_saphyr::from_str(&yaml).map_err(|error| TestkitError::Configuration(error.to_string())) + } + + #[test] + fn failover_groups_fixture_accepts_two_unique_inputs() -> Result<(), TestkitError> { + failover_scenario(" failover_groups: { inputs: [input-a, input-b] }")?.validate() + } + + #[test] + fn failover_groups_fixture_rejects_bad_input_lists() -> Result<(), TestkitError> { + for inputs in ["[input-a]", "[input-a, input-a]", "[input-a, 'bad name']", "[input-a, '']"] { + let scenario = failover_scenario(&format!(" failover_groups: {{ inputs: {inputs} }}"))?; + assert!(scenario.validate().is_err(), "{inputs} must be rejected"); + } + Ok(()) + } + + #[test] + fn failover_groups_fixture_rejects_provider_pool() -> Result<(), TestkitError> { + let mut scenario = failover_scenario(" failover_groups: { inputs: [input-a, input-b] }")?; + scenario.policy_contract = Some(PolicyContract { + user_access_control: false, + users: HashMap::new(), + admission_strategies: None, + recent_eviction_reentry_ttl_ms: None, + hls_session_ttl_secs: None, + provider_affinity_ttl_secs: None, + hls_wrap_media_playlist: None, + grace: None, + provider_max_connections: None, + provider_pool: vec![ProviderPoolAccount { + name: "account-a".to_owned(), + max_connections: 1, + priority: None, + }], + expected_provider_slots: None, + }); + assert!(scenario.validate().is_err()); + Ok(()) + } + + #[test] + fn extra_config_file_destinations_must_be_confined() -> Result<(), TestkitError> { + let valid = + failover_scenario(" extra_config_files: [{ source: ../x.yml, destination: failover.d/testkit.yml }]")?; + valid.validate()?; + let escaping = failover_scenario(" extra_config_files: [{ source: x.yml, destination: ../x.yml }]")?; + assert!(escaping.validate().is_err()); + assert!(is_confined_relative_path("failover.d/testkit.yml")); + assert!(!is_confined_relative_path("/etc/passwd")); + assert!(!is_confined_relative_path("a/../../b")); + assert!(!is_confined_relative_path("")); + Ok(()) + } } diff --git a/tools/tuliprox-testkit/src/main.rs b/tools/tuliprox-testkit/src/main.rs index 24c332173..fc47f5aff 100644 --- a/tools/tuliprox-testkit/src/main.rs +++ b/tools/tuliprox-testkit/src/main.rs @@ -784,11 +784,16 @@ async fn serve_origin( Ok(RunExit::Passed) } -fn catalog_m3u(state: &OriginState, host: &str, account: Option<&str>) -> String { +/// `failover_member` shapes the catalog for a failover-group member input: no VOD entries, so a +/// playlist merging several inputs of this origin does not carry each movie once per input, +/// and a stable `xui-id` per channel, because HLS entry URLs have no numeric id a member +/// reference (input name plus provider stream id) could use. +fn catalog_m3u(state: &OriginState, host: &str, account: Option<&str>, failover_member: bool) -> String { let account_query = account.map_or_else(String::new, |account| format!("&token={account}")); let mut catalog = String::from("#EXTM3U\n"); for marker in state.markers.iter() { - let _ = writeln!(catalog, "#EXTINF:-1 tvg-id=\"test-{marker}\",Test channel {marker}"); + let member_id = if failover_member { format!(" xui-id=\"{marker}\"") } else { String::new() }; + let _ = writeln!(catalog, "#EXTINF:-1 tvg-id=\"test-{marker}\"{member_id},Test channel {marker}"); let path = if state.hls_markers.contains(marker) { format!("hls/{marker}/index.m3u8") } else { @@ -796,6 +801,9 @@ fn catalog_m3u(state: &OriginState, host: &str, account: Option<&str>) -> String }; let _ = writeln!(catalog, "http://{host}/{path}?run={}{}", state.run_id.0, account_query); } + if failover_member { + return catalog; + } let _ = writeln!(catalog, "#EXTINF:-1 tvg-id=\"test-vod-movie.mkv\" tvg-type=\"movie\",Test Movie"); let _ = writeln!(catalog, "http://{host}/vod/movie.mkv?run={}{}", state.run_id.0, account_query); let _ = writeln!( @@ -910,7 +918,11 @@ async fn catalog( headers: HeaderMap, ) -> impl IntoResponse { let host = headers.get(header::HOST).and_then(|value| value.to_str().ok()).unwrap_or("127.0.0.1"); - ([(header::CONNECTION, "close")], catalog_m3u(&state, host, query.get("account").map(String::as_str))) + let failover_member = query.get("failover_member").is_some_and(|value| value == "true"); + ( + [(header::CONNECTION, "close")], + catalog_m3u(&state, host, query.get("account").map(String::as_str), failover_member), + ) } struct LiveStreamState { @@ -2154,6 +2166,11 @@ async fn run_controller( &tuliprox_testkit::bootstrap::FixtureInputPlan { input_type: scenario.tuliprox.input_type, stalker: &scenario.tuliprox.stalker, + failover_inputs: scenario + .tuliprox + .failover_groups + .as_ref() + .map_or(&[][..], |failover| failover.inputs.as_slice()), }, ) .await @@ -3738,18 +3755,32 @@ mod tests { async fn catalog_exposes_each_configured_marker() { let mut state = test_origin_state("catalog-run"); state.markers = Arc::new(vec![17, 19]); - let document = catalog_m3u(&state, "127.0.0.1", None); + let document = catalog_m3u(&state, "127.0.0.1", None, false); assert!(document.contains("tvg-id=\"test-17\"")); assert!(document.contains("tvg-id=\"test-19\"")); assert!(document.contains("run=catalog-run")); } + #[tokio::test] + async fn failover_member_catalog_omits_vod_and_carries_stable_ids() { + let mut state = test_origin_state("catalog-run"); + state.markers = Arc::new(vec![17]); + let full = catalog_m3u(&state, "127.0.0.1", Some("input-a"), false); + assert!(full.contains("test-vod-movie.mkv")); + assert!(!full.contains("xui-id")); + let member = catalog_m3u(&state, "127.0.0.1", Some("input-a"), true); + assert!(member.contains("tvg-id=\"test-17\" xui-id=\"17\",Test channel 17")); + assert!(member.contains("token=input-a")); + assert!(!member.contains("test-vod")); + assert!(!member.contains("Test Series")); + } + #[tokio::test] async fn hls_catalog_and_segments_preserve_alias_account_tokens() -> Result<(), TestkitError> { let mut state = test_origin_state("hls-run"); state.markers = Arc::new(vec![17, 19]); state.hls_markers = Arc::new(HashSet::from([17])); - let document = catalog_m3u(&state, "127.0.0.1", Some("account-b")); + let document = catalog_m3u(&state, "127.0.0.1", Some("account-b"), false); assert!(document.contains("/hls/17/index.m3u8?run=hls-run&token=account-b")); assert!(document.contains("/live/19.ts?run=hls-run&token=account-b")); let response = hls( diff --git a/tools/tuliprox-testkit/tests/hls_fast_start_process.rs b/tools/tuliprox-testkit/tests/hls_fast_start_process.rs index 61c9f8623..14b4635c4 100644 --- a/tools/tuliprox-testkit/tests/hls_fast_start_process.rs +++ b/tools/tuliprox-testkit/tests/hls_fast_start_process.rs @@ -53,6 +53,7 @@ async fn run(mode: StartupMode, missing: u32, abort: bool, unknown_length: bool) .to_vec(), readiness_url: "{base_url}/healthcheck".to_owned(), readiness_timeout_millis: 30_000, + extra_config_files: Vec::new(), }; let channels = HashMap::from([("channel".to_owned(), Channel { origin_marker: 17, protocol: "hls".to_owned() })]); let options = FixtureStreamOptions { hls_startup_mode: Some(mode), ..FixtureStreamOptions::default() }; @@ -66,7 +67,11 @@ async fn run(mode: StartupMode, missing: u32, abort: bool, unknown_length: bool) &channels, &options, false, - &FixtureInputPlan { input_type: FixtureInputType::M3u, stalker: &FixtureStalker::default() }, + &FixtureInputPlan { + input_type: FixtureInputType::M3u, + stalker: &FixtureStalker::default(), + failover_inputs: &[], + }, ) .await?; let result = async {