Feature/failover (#924)

* feature stream failover
This commit is contained in:
euzu
2026-10-16 21:10:07 +02:00
committed by GitHub
parent 86d19f955c
commit 0e4e01409c
203 changed files with 15664 additions and 820 deletions
+1
View File
@@ -110,6 +110,7 @@ error_kinds! {
ConfigVideoDownload => "config video download error",
ConfigTarget => "config target error",
TargetBouquet => "target bouquet error",
ChannelGroup => "channel group error",
ConfigWebUi => "config web UI error",
RepositoryEpg => "repository EPG error",
RepositoryXtream => "repository XTream error",
+162
View File
@@ -0,0 +1,162 @@
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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub epg_channel_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub playlist_group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy: Option<ChannelGroupPolicy>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub members: Vec<ChannelMemberRefDto>,
}
#[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<ChannelGroupPolicy>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub order: Vec<ChannelMemberRefDto>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub add: Vec<ChannelMemberRefDto>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub remove: Vec<ChannelMemberRefDto>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logo: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub epg_channel_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub playlist_group: Option<String>,
}
#[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<ManualChannelGroupDto>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub auto_overrides: Vec<AutoGroupOverrideDto>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub excluded: Vec<ChannelMemberRefDto>,
}
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()))
});
}
}
+9 -3
View File
@@ -10,9 +10,9 @@ use crate::{
is_false, is_none_or_empty_metadata_update, is_true, is_zero_u32,
},
model::{
ConfigApiDto, HdHomeRunConfigDto, IpCheckConfigDto, LibraryConfigDto, LogConfigDto, MessagingConfigDto,
MetadataUpdateConfigDto, ProxyConfigDto, ReverseProxyConfigDto, ScheduleConfigDto, VideoConfigDto,
WebUiConfigDto,
ChannelGroupsConfig, ConfigApiDto, HdHomeRunConfigDto, IpCheckConfigDto, LibraryConfigDto, LogConfigDto,
MessagingConfigDto, MetadataUpdateConfigDto, ProxyConfigDto, ReverseProxyConfigDto, ScheduleConfigDto,
VideoConfigDto, WebUiConfigDto,
},
utils::is_blank_optional_string,
};
@@ -107,6 +107,8 @@ pub struct ConfigDto {
pub ipcheck: Option<IpCheckConfigDto>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub library: Option<LibraryConfigDto>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channel_groups: Option<ChannelGroupsConfig>,
}
impl Default for ConfigDto {
@@ -145,6 +147,7 @@ impl Default for ConfigDto {
proxy: None,
ipcheck: None,
library: None,
channel_groups: None,
}
}
}
@@ -221,6 +224,8 @@ impl<'de> serde::Deserialize<'de> for ConfigDto {
ipcheck: Option<IpCheckConfigDto>,
#[serde(default)]
library: Option<LibraryConfigDto>,
#[serde(default)]
channel_groups: Option<ChannelGroupsConfig>,
}
let raw = Raw::deserialize(deserializer)?;
@@ -259,6 +264,7 @@ impl<'de> serde::Deserialize<'de> for ConfigDto {
proxy: raw.proxy,
ipcheck: raw.ipcheck,
library: raw.library,
channel_groups: raw.channel_groups,
})
}
}
+3
View File
@@ -78,6 +78,9 @@ impl ConfigDto {
if let Some(library) = &mut self.library {
library.playlist.prepare();
}
if let Some(channel_groups) = &mut self.channel_groups {
channel_groups.prepare()?;
}
Ok(())
}
-1
View File
@@ -184,7 +184,6 @@ impl ConfigInputDto {
)));
}
}
// R7
if self.media_server.is_some() {
return Err(TuliproxError::ConfigInput(format!(
"staged input does not support media_server configuration (input: {})",
+9
View File
@@ -243,6 +243,9 @@ impl ConfigTargetDto {
if let Some(sort) = self.sort.as_mut() {
sort.prepare(templates)?;
}
if let Some(options) = &mut self.options {
options.prepare()?;
}
Ok(())
}
}
@@ -250,9 +253,15 @@ impl ConfigTargetDto {
#[cfg(test)]
mod tests;
mod channel_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 filter::ConfigTargetFilterDto;
pub use options::{
ConfigTargetOptions, ConfigTargetShareLiveStreams, DeduplicateConfig, DeduplicateKeep, DeduplicateMatchBy,
@@ -0,0 +1,453 @@
use crate::error::TuliproxError;
use regex::Regex;
use std::{collections::BTreeMap, sync::Arc};
/// A unit-variant config enum with its YAML names, `ALL`, `Display` and `FromStr`.
macro_rules! config_enum {
($(#[$meta:meta])* $name:ident, $key:literal { $default:ident => $default_str:literal $(, $variant:ident => $str:literal)* $(,)? }) => {
$(#[$meta])*
#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum $name {
#[default]
#[serde(rename = $default_str)]
$default,
$(#[serde(rename = $str)] $variant,)*
}
impl $name {
pub const ALL: &'static [Self] = &[Self::$default $(, Self::$variant)*];
/// The name used in the configuration.
pub const fn as_str(self) -> &'static str {
match self {
Self::$default => $default_str,
$(Self::$variant => $str,)*
}
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) }
}
impl std::str::FromStr for $name {
type Err = TuliproxError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::ALL
.iter()
.copied()
.find(|variant| variant.as_str() == value)
.ok_or_else(|| TuliproxError::ConfigBase(format!("unknown channel_groups.{}: {value}", $key)))
}
}
};
}
config_enum!(ChannelMatchBy, "match_by" {
Caption => "caption",
Name => "name",
Title => "title",
EpgChannelId => "epg_channel_id",
});
config_enum!(
/// Member order of a group: `auto` ranks by health and quality, `priority` keeps the list order.
ChannelGroupPolicy, "policy" { Auto => "auto", Priority => "priority" }
);
config_enum!(RuntimeAudioLanguage, "failover.audio_language" { Prefer => "prefer", Require => "require" });
config_enum!(EpgConflictMode, "epg_conflict" { Split => "split", Ignore => "ignore" });
/// Grouping-only match key extensions; `deduplicate` never uses them.
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct ChannelMatchNormalize {
#[serde(skip_serializing_if = "Option::is_none")]
pub strip_regex: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub ignore_tokens: Vec<String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub aliases: BTreeMap<String, String>,
#[serde(skip)]
pub t_strip_regex: Option<Arc<Regex>>,
}
impl PartialEq for ChannelMatchNormalize {
fn eq(&self, other: &Self) -> bool {
self.strip_regex == other.strip_regex
&& self.ignore_tokens == other.ignore_tokens
&& self.aliases == other.aliases
}
}
impl Eq for ChannelMatchNormalize {}
impl ChannelMatchNormalize {
pub fn is_empty(&self) -> bool {
self.strip_regex.is_none() && self.ignore_tokens.is_empty() && self.aliases.is_empty()
}
pub fn prepare(&mut self) -> Result<(), TuliproxError> {
self.t_strip_regex = self
.strip_regex
.as_deref()
.map(|pattern| {
Regex::new(pattern).map(Arc::new).map_err(|err| {
TuliproxError::RegexCompile(format!("channel_groups.match_normalize {pattern} {err}"))
})
})
.transpose()?;
Ok(())
}
}
/// A group always has at least this many members; a single channel stays a normal channel.
pub const MIN_GROUP_MEMBERS: usize = 2;
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 {
/// 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 {
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 enabled: bool,
/// Seconds without media before the member is replaced; 0 disables the check.
pub no_data_timeout_secs: u64,
/// A member delivering less than this percentage of real-time speed is too slow; 0 disables it.
pub slow_source_pct: u8,
/// How long a member must stay too slow before it is replaced.
pub slow_source_secs: u64,
/// Longest time without playback while looking for a replacement.
pub max_outage_secs: u64,
/// Retries of a member that played stably before others are tried.
pub same_source_retries: u8,
/// Playback after which a member counts as stable and an incident is over.
pub stable_after_secs: u64,
/// Most switches within `max_switches_window_secs`; 0 disables the limit.
pub max_switches: u8,
pub max_switches_window_secs: u64,
pub audio_language: RuntimeAudioLanguage,
}
impl Default for ChannelGroupFailoverConfig {
fn default() -> Self {
Self {
enabled: true,
no_data_timeout_secs: 8,
slow_source_pct: 90,
slow_source_secs: 15,
max_outage_secs: 20,
same_source_retries: 1,
stable_after_secs: 30,
max_switches: 6,
max_switches_window_secs: 600,
audio_language: RuntimeAudioLanguage::Prefer,
}
}
}
/// 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 {
/// Consecutive failures that put a member on hold.
pub source_failures: u8,
/// First hold time; doubles while the member keeps failing.
pub cooldown_secs: u64,
pub cooldown_max_secs: u64,
/// Failures of one input (from at least two members) within `input_window_secs` that put
/// the whole input back.
pub input_failures: u8,
pub input_window_secs: u64,
}
impl Default for ChannelGroupCircuitConfig {
fn default() -> Self {
Self {
source_failures: 2,
cooldown_secs: 60,
cooldown_max_secs: 900,
input_failures: 3,
input_window_secs: 120,
}
}
}
/// `config.yml` `channel_groups`: the settings every target with channel groups uses.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct ChannelGroupsConfig {
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,
#[serde(default = "default_true")]
pub allow_provider_grace: bool,
pub start: ChannelGroupStartConfig,
pub failover: ChannelGroupFailoverConfig,
pub circuit: ChannelGroupCircuitConfig,
}
impl Default for ChannelGroupsConfig {
fn default() -> Self {
Self {
match_by: ChannelMatchBy::default(),
match_as_ascii: false,
match_normalize: ChannelMatchNormalize::default(),
epg_conflict: EpgConflictMode::default(),
name_strip_quality: false,
policy: ChannelGroupPolicy::default(),
allow_provider_grace: true,
start: ChannelGroupStartConfig::default(),
failover: ChannelGroupFailoverConfig::default(),
circuit: ChannelGroupCircuitConfig::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 allow_provider_grace: bool,
}
impl ChannelGroupsConfig {
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}")));
if self.start.max_attempts == 0 {
return invalid("start.max_attempts` must be > 0");
}
if self.circuit.source_failures == 0 {
return invalid("circuit.source_failures` must be > 0");
}
if self.circuit.input_failures < 2 {
return invalid("circuit.input_failures` must be at least 2");
}
if self.failover.slow_source_pct > 100 {
return invalid("failover.slow_source_pct` must be between 0 and 100");
}
if self.failover.max_outage_secs == 0 {
return invalid("failover.max_outage_secs` must be > 0");
}
if self.circuit.cooldown_max_secs < self.circuit.cooldown_secs {
self.circuit.cooldown_max_secs = self.circuit.cooldown_secs;
}
self.match_normalize.prepare()
}
/// The grouping settings of `target`: these settings with the target's overrides applied.
#[must_use]
pub fn for_target(&self, target: &ChannelGroupsTargetConfig) -> 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);
config.epg_conflict = target.epg_conflict.unwrap_or(config.epg_conflict);
config.name_strip_quality = target.name_strip_quality.unwrap_or(config.name_strip_quality);
config.policy = target.policy.unwrap_or(config.policy);
config.allow_provider_grace = target.allow_provider_grace.unwrap_or(config.allow_provider_grace);
config.failover.enabled = target.failover.unwrap_or(config.failover.enabled);
config
}
/// The playback settings of a target, without copying the grouping settings.
pub fn playback(&self, target: Option<&ChannelGroupsTargetConfig>) -> ChannelGroupPlayback {
let mut failover = self.failover;
failover.enabled = target.and_then(|target| target.failover).unwrap_or(failover.enabled);
ChannelGroupPlayback {
start: self.start,
failover,
allow_provider_grace: target
.and_then(|target| target.allow_provider_grace)
.unwrap_or(self.allow_provider_grace),
}
}
}
/// `source.yml` `targets[].options.channel_groups`: turns channel 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 enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub match_by: Option<ChannelMatchBy>,
#[serde(skip_serializing_if = "Option::is_none")]
pub match_as_ascii: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub epg_conflict: Option<EpgConflictMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name_strip_quality: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy: Option<ChannelGroupPolicy>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allow_provider_grace: Option<bool>,
/// Switching members while watching; `None` uses `failover.enabled` of `config.yml`.
#[serde(skip_serializing_if = "Option::is_none")]
pub failover: Option<bool>,
}
impl ChannelGroupsTargetConfig {
pub fn is_default(&self) -> bool { *self == Self::default() }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn match_by_round_trips_through_its_config_name() {
for &match_by in ChannelMatchBy::ALL {
let name = match_by.to_string();
assert_eq!(name.parse::<ChannelMatchBy>().expect("known name"), match_by);
assert_eq!(serde_json::to_string(&match_by).expect("serializes"), format!("\"{name}\""));
}
assert!("unknown".parse::<ChannelMatchBy>().is_err());
}
#[test]
fn defaults_match_design() {
let cfg: ChannelGroupsConfig = serde_json::from_str("{}").expect("parses");
assert_eq!(cfg, ChannelGroupsConfig::default());
assert_eq!(cfg.match_by, ChannelMatchBy::Caption);
assert_eq!(cfg.policy, ChannelGroupPolicy::Auto);
assert!(cfg.allow_provider_grace);
assert_eq!(cfg.start, ChannelGroupStartConfig { 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);
assert_eq!(cfg.failover.slow_source_secs, 15);
assert_eq!(cfg.failover.max_outage_secs, 20);
assert_eq!(cfg.failover.same_source_retries, 1);
assert_eq!(cfg.failover.stable_after_secs, 30);
assert_eq!(cfg.failover.max_switches, 6);
assert_eq!(cfg.failover.max_switches_window_secs, 600);
assert_eq!(cfg.failover.audio_language, RuntimeAudioLanguage::Prefer);
assert_eq!(
cfg.circuit,
ChannelGroupCircuitConfig {
source_failures: 2,
cooldown_secs: 60,
cooldown_max_secs: 900,
input_failures: 3,
input_window_secs: 120
}
);
assert_eq!(cfg.epg_conflict, EpgConflictMode::Split);
assert!(!cfg.name_strip_quality);
}
#[test]
fn match_normalize_compiles_regex_on_prepare() {
let mut cfg: ChannelGroupsConfig = 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 =
serde_json::from_str(r#"{"match_normalize":{"strip_regex":"("}}"#).expect("parses");
assert!(bad.prepare().is_err());
}
#[test]
fn rejects_out_of_range_values() {
for json in [
r#"{"failover":{"slow_source_pct":101}}"#,
r#"{"failover":{"max_outage_secs":0}}"#,
r#"{"circuit":{"input_failures":1}}"#,
r#"{"circuit":{"source_failures":0}}"#,
r#"{"start":{"max_attempts":0}}"#,
] {
let mut cfg: ChannelGroupsConfig = 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::<ChannelGroupsConfig>(r#"{"min_members":2}"#).is_err());
assert!(serde_json::from_str::<ChannelGroupsConfig>(r#"{"selection":{}}"#).is_err());
assert!(serde_json::from_str::<ChannelGroupsTargetConfig>(r#"{"enabled":true,"min_members":2}"#).is_err());
assert!(serde_json::from_str::<ChannelGroupsTargetConfig>(r#"{"enabled":true,"match_normalize":{}}"#).is_err());
}
#[test]
fn cooldown_max_is_raised_to_cooldown() {
let mut cfg: ChannelGroupsConfig =
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);
}
#[test]
fn target_overrides_replace_only_set_fields() {
let global = ChannelGroupsConfig {
match_by: ChannelMatchBy::Name,
name_strip_quality: true,
failover: ChannelGroupFailoverConfig { no_data_timeout_secs: 12, ..ChannelGroupFailoverConfig::default() },
..ChannelGroupsConfig::default()
};
let unset = ChannelGroupsTargetConfig { enabled: true, ..ChannelGroupsTargetConfig::default() };
assert_eq!(global.for_target(&unset), global);
let target: ChannelGroupsTargetConfig = 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!(!effective.allow_provider_grace);
assert!(!effective.failover.enabled);
assert_eq!(effective.failover.no_data_timeout_secs, 12);
assert_eq!(effective.match_as_ascii, global.match_as_ascii);
}
#[test]
fn playback_applies_target_failover_and_grace() {
let global = ChannelGroupsConfig::default();
let target = ChannelGroupsTargetConfig {
enabled: true,
allow_provider_grace: Some(false),
failover: Some(false),
..ChannelGroupsTargetConfig::default()
};
let playback = global.playback(Some(&target));
assert!(!playback.allow_provider_grace);
assert!(!playback.failover.enabled);
assert_eq!(playback.start, global.start);
let defaults = global.playback(None);
assert!(defaults.allow_provider_grace);
assert!(defaults.failover.enabled);
}
#[test]
fn target_serializes_only_overrides() {
let target = ChannelGroupsTargetConfig { enabled: true, failover: Some(false), ..Default::default() };
assert_eq!(serde_json::to_string(&target).expect("serializes"), r#"{"enabled":true,"failover":false}"#);
}
}
@@ -122,6 +122,8 @@ pub struct ConfigTargetOptions {
pub epg_output: EpgOutputOptions,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub force_redirect: Option<ClusterFlags>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channel_groups: Option<super::channel_groups::ChannelGroupsTargetConfig>,
}
impl ConfigTargetOptions {
@@ -132,8 +134,11 @@ 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()
}
pub fn prepare(&mut self) -> Result<(), crate::error::TuliproxError> { Ok(()) }
pub const fn lowercase_epg_ids(&self) -> bool { self.epg_output.lowercase_ids }
pub const fn lowercase_xmltv_display_names(&self) -> bool { self.epg_output.lowercase_xmltv_display_names }
+18 -2
View File
@@ -62,6 +62,10 @@ pub enum EventKind {
RecordingProgress,
/// Appended last for the same reason.
MaintenanceChanged,
ChannelGroupMemberSelected,
ChannelGroupExhausted,
ChannelGroupCircuitChanged,
ChannelGroupMemberSwitched,
}
impl EventKind {
@@ -69,7 +73,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; 43] = [
pub const ALL: [Self; 47] = [
Self::ServerError,
Self::ServerStarted,
Self::ServerShutdown,
@@ -113,6 +117,10 @@ impl EventKind {
Self::AuthPermissionDenied,
Self::RecordingProgress,
Self::MaintenanceChanged,
Self::ChannelGroupMemberSelected,
Self::ChannelGroupExhausted,
Self::ChannelGroupCircuitChanged,
Self::ChannelGroupMemberSwitched,
];
/// This kind's bit position.
@@ -177,7 +185,11 @@ impl EventKind {
// Sits with the metadata-update events it is produced by.
| Self::StreamProbeFailed
| Self::NotificationDeadLettered
| Self::ScheduledTaskFailed => Permission::SystemRead,
| Self::ScheduledTaskFailed
| Self::ChannelGroupMemberSelected
| Self::ChannelGroupExhausted
| Self::ChannelGroupCircuitChanged
| Self::ChannelGroupMemberSwitched => Permission::SystemRead,
Self::MaintenanceChanged => Permission::MaintenanceRead,
}
}
@@ -278,6 +290,10 @@ 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",
}
}
+77 -7
View File
@@ -3,12 +3,13 @@ use crate::model::{
auth_audit::{AuthAuditEvent, AuthAuditOutcome},
notification::{registry, EventId, Severity},
stats::SourceStats,
ActiveUserConnectionChange, ConfigReloadFailure, ConfigType, ConnectionDenied, DiskAlert, LibraryScanProgressEvent,
LibraryScanSummaryStatus, MaintenanceEntries, MetadataUpdateFailure, MsgKind, NotificationDeadLetter, Permission,
PlaylistUpdateProgressEvent, PlaylistUpdateRunId, PlaylistUpdateRunOrder, PlaylistUpdateState,
ProviderAccountEvent, ProviderAccountState, ProviderFetchFailure, ProviderPoolExhausted, ProviderPriorityFallback,
RecordingLifecycleMessage, ScheduledTaskFailure, ServerLifecycleEvent, ServerLifecycleState, StreamProbeFailure,
SystemInfo, UserLifecycleEvent, UserLifecycleState, WatchChanges, WatchDisabled, WatchUnmatched,
ActiveUserConnectionChange, CapacityTier, CircuitState, ConfigReloadFailure, ConfigType, ConnectionDenied,
DiskAlert, LibraryScanProgressEvent, LibraryScanSummaryStatus, MaintenanceEntries, MetadataUpdateFailure, MsgKind,
NotificationDeadLetter, Permission, PlaylistUpdateProgressEvent, PlaylistUpdateRunId, PlaylistUpdateRunOrder,
PlaylistUpdateState, ProviderAccountEvent, ProviderAccountState, ProviderFetchFailure, ProviderPoolExhausted,
ProviderPriorityFallback, RecordingLifecycleMessage, ScheduledTaskFailure, ServerLifecycleEvent,
ServerLifecycleState, StreamProbeFailure, SwitchReason, SystemInfo, UserLifecycleEvent, UserLifecycleState,
WatchChanges, WatchDisabled, WatchUnmatched,
};
use std::sync::Arc;
@@ -100,6 +101,30 @@ pub enum EventMessage {
/// see [`EventMessage::kind`] - so a subscriber can ask for the failures
/// without being woken by every successful sign-in.
AuthAudit(AuthAuditEvent),
ChannelGroupMemberSelected {
target_id: u16,
group_vid: u32,
member_vid: u32,
tier: CapacityTier,
},
ChannelGroupExhausted {
target_id: u16,
group_vid: u32,
},
ChannelGroupCircuitChanged {
input_name: Arc<str>,
input_stream_id: Arc<str>,
state: CircuitState,
},
/// A running group stream continued on another member.
ChannelGroupMemberSwitched {
target_id: u16,
group_vid: u32,
from_member_vid: u32,
to_member_vid: u32,
reason: SwitchReason,
},
}
impl EventMessage {
@@ -170,6 +195,10 @@ 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,
}
}
@@ -267,7 +296,13 @@ impl EventMessage {
Self::RecordingChanged => registry::RECORDING_QUEUE_CHANGED,
Self::RecordingRulesChanged => registry::RECORDING_RULES_CHANGED,
Self::MaintenanceChanged(_) => registry::MAINTENANCE_CHANGED,
Self::PlaylistUpdateProgress(_) | Self::SystemInfoUpdate(_) | Self::RecordingProgress => return None,
Self::PlaylistUpdateProgress(_)
| Self::SystemInfoUpdate(_)
| Self::RecordingProgress
| Self::ChannelGroupMemberSelected { .. }
| Self::ChannelGroupExhausted { .. }
| Self::ChannelGroupCircuitChanged { .. }
| Self::ChannelGroupMemberSwitched { .. } => return None,
})
}
@@ -323,6 +358,41 @@ 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 } => {
serde_json::json!({
"target_id": target_id,
"group_vid": group_vid,
"member_vid": member_vid,
"tier": tier,
})
}
Self::ChannelGroupExhausted { 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 } => {
serde_json::json!({
"target_id": target_id,
"group_vid": group_vid,
"from_member_vid": from_member_vid,
"to_member_vid": to_member_vid,
"reason": reason,
})
}
Self::ChannelGroupCircuitChanged { input_name, input_stream_id, state } => {
let state_str = match state {
CircuitState::Closed => "closed",
CircuitState::OpenUntil(_) => "open",
CircuitState::HalfOpenInFlight => "half_open",
};
serde_json::json!({
"input_name": input_name.as_ref(),
"input_stream_id": input_stream_id.as_ref(),
"state": state_str,
})
}
}
}
+20
View File
@@ -32,6 +32,16 @@ pub trait EventSink: Send + Sync {
_exp_date: Option<i64>,
) {
}
/// Forward probe technical metadata to the member health index.
fn observe_stream_probe(
&self,
_input_name: Arc<str>,
_input_stream_id: Arc<str>,
_video: Option<&str>,
_audio: Option<&str>,
_bitrate_kbps: u32,
) {
}
}
/// The sink that drops everything.
@@ -57,6 +67,16 @@ impl<T: EventSink + ?Sized> EventSink for Arc<T> {
) {
(**self).observe_provider_account(name, identity, status, exp_date);
}
fn observe_stream_probe(
&self,
input_name: Arc<str>,
input_stream_id: Arc<str>,
video: Option<&str>,
audio: Option<&str>,
bitrate_kbps: u32,
) {
(**self).observe_stream_probe(input_name, input_stream_id, video, audio, bitrate_kbps);
}
}
impl<T: EventSink> EventSink for &T {
+3
View File
@@ -79,6 +79,7 @@ pub enum HeaderField {
EpgChannelId,
Chno,
Genre,
ChannelGroup,
}
impl HeaderField {
@@ -110,6 +111,7 @@ impl HeaderField {
("epg_id", HeaderField::EpgChannelId),
("chno", HeaderField::Chno),
("genre", HeaderField::Genre),
("channel_group", HeaderField::ChannelGroup),
];
TABLE.iter().find(|(key, _)| name.eq_ignore_ascii_case(key)).map(|(_, field)| *field)
}
@@ -135,6 +137,7 @@ impl HeaderField {
Self::EpgChannelId => "epg_channel_id",
Self::Chno => "chno",
Self::Genre => "genre",
Self::ChannelGroup => "channel_group",
}
}
}
+1
View File
@@ -31,6 +31,7 @@ pub const MAPPER_FIELDS: &[HeaderField] = &[
HeaderField::Rec,
HeaderField::Url,
HeaderField::EpgChannelId,
HeaderField::ChannelGroup,
];
/// Whether `name` resolves to one of `allowed`.
+29 -19
View File
@@ -167,10 +167,15 @@ impl MediaQuality {
fn from_ffprobe_info_audio(audio: Option<&str>) -> Option<(AudioCodec, AudioChannels)> {
// Assuming the first audio stream is the primary one.
let audio_info = audio.and_then(|v| serde_json::from_str::<Map<String, Value>>(v).ok())?;
let audio_val = audio.and_then(|v| serde_json::from_str::<Value>(v).ok())?;
let audio_info = match &audio_val {
Value::Object(map) => Some(map),
Value::Array(arr) => arr.first().and_then(Value::as_object),
_ => None,
}?;
// Audio codec
let audio_codec = get_value(&audio_info, &["codec_name"])
let audio_codec = get_value(audio_info, &["codec_name"])
.and_then(|v| v.as_str().map(str::to_lowercase))
.map_or(AudioCodec::default(), |name| match name.as_str() {
"aac" => AudioCodec::AAC,
@@ -183,16 +188,16 @@ impl MediaQuality {
});
// Audio channels
let audio_channels =
get_value(&audio_info, &["channels"]).and_then(|v| v.as_i64()).map_or(AudioChannels::default(), |ch| {
match ch {
8 => AudioChannels::Surround71,
6 => AudioChannels::Surround51,
2 => AudioChannels::Stereo,
1 => AudioChannels::Mono,
_ => AudioChannels::default(),
}
});
let audio_channels = get_value(audio_info, &["channels"]).and_then(|v| v.as_i64()).map_or(
AudioChannels::default(),
|ch| match ch {
8 => AudioChannels::Surround71,
6 => AudioChannels::Surround51,
2 => AudioChannels::Stereo,
1 => AudioChannels::Mono,
_ => AudioChannels::default(),
},
);
Some((audio_codec, audio_channels))
}
@@ -200,15 +205,20 @@ impl MediaQuality {
fn from_ffprobe_info_video(
video: Option<&str>,
) -> Option<(VideoResolution, VideoCodec, VideoDynamicRange, VideoBitDepth)> {
let video_info = video.and_then(|v| serde_json::from_str::<Map<String, Value>>(v).ok())?;
let video_val = video.and_then(|v| serde_json::from_str::<Value>(v).ok())?;
let video_info = match &video_val {
Value::Object(map) => Some(map),
Value::Array(arr) => arr.first().and_then(Value::as_object),
_ => None,
}?;
// 1. Classify video resolution from the frame dimensions
let width = get_value(&video_info, &["width", "coded_width"]).and_then(|v| v.as_u64());
let height = get_value(&video_info, &["height", "coded_height"]).and_then(|v| v.as_u64());
let width = get_value(video_info, &["width", "coded_width"]).and_then(|v| v.as_u64());
let height = get_value(video_info, &["height", "coded_height"]).and_then(|v| v.as_u64());
let resolution = VideoResolution::from_dimensions(width, height);
// 2. Classify video codec
let video_codec = get_value(&video_info, &["codec_name"])
let video_codec = get_value(video_info, &["codec_name"])
.and_then(|v| v.as_str().map(str::to_lowercase))
.map_or(VideoCodec::default(), |name| match name.as_str() {
"h264" => VideoCodec::H264,
@@ -222,12 +232,12 @@ impl MediaQuality {
// 3. Classify dynamic range
let dynamic_range = {
let tag_string =
get_value(&video_info, &["codec_tag_string"]).and_then(|v| v.as_str().map(str::to_lowercase));
get_value(video_info, &["codec_tag_string"]).and_then(|v| v.as_str().map(str::to_lowercase));
if tag_string == Some("dovi".to_string()) {
VideoDynamicRange::DV
} else {
get_value(&video_info, &["color_transfer"]).and_then(|v| v.as_str().map(str::to_lowercase)).map_or(
get_value(video_info, &["color_transfer"]).and_then(|v| v.as_str().map(str::to_lowercase)).map_or(
VideoDynamicRange::SDR,
|ct| match ct.as_str() {
"smpte2084" => VideoDynamicRange::HDR, // Generic HDR/HDR10
@@ -239,7 +249,7 @@ impl MediaQuality {
};
// 4. Classify bit depth
let bit_depth = get_value(&video_info, &["pix_fmt"]).and_then(|v| v.as_str().map(ToString::to_string)).map_or(
let bit_depth = get_value(video_info, &["pix_fmt"]).and_then(|v| v.as_str().map(ToString::to_string)).map_or(
VideoBitDepth::Eight,
|fmt| {
if fmt.contains("10le") || fmt.contains("10be") {
+13 -12
View File
@@ -1,6 +1,7 @@
mod active_user_connection_change;
mod auth;
mod auth_audit;
pub mod channel_group;
mod cluster_flags;
mod config;
mod connection_denied;
@@ -78,18 +79,18 @@ mod xtream;
pub mod xtream_const;
pub use self::{
active_user_connection_change::*, auth::*, auth_audit::*, cluster_flags::*, config::*, connection_denied::*,
custom_video_stream_type::*, epg::*, epg_grid::*, epg_request::*, event::*, 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::*, playlist_request::*,
playlist_update_run::*, playlist_update_status::*, processing_order::*, progress::*, provider_fetch_failure::*,
provider_pool::*, recording::*, recording_math::*, regex_cache::*, resource::*, scheduled_task_failure::*,
search_fields::*, search_request::*, server_lifecycle::*, short_epg::*, stalker::*, stalker_item::*, stats::*,
status_check::*, stream_history::*, stream_history_record::*, stream_info::*, stream_meter::*,
stream_probe_failure::*, stream_properties::*, strm_export_style::*, system_info::*, target_bouquet::*,
target_type::*, transfer::*, ui_playlist_item::*, user_command::*, user_id::*, user_lifecycle::*, uuidtype::*,
watch_health::*, web_socket::*, xtream::*,
active_user_connection_change::*, auth::*, auth_audit::*, channel_group::*, cluster_flags::*, config::*,
connection_denied::*, custom_video_stream_type::*, epg::*, epg_grid::*, epg_request::*, event::*,
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::*,
playlist_request::*, playlist_update_run::*, playlist_update_status::*, processing_order::*, progress::*,
provider_fetch_failure::*, provider_pool::*, recording::*, recording_math::*, regex_cache::*, resource::*,
scheduled_task_failure::*, search_fields::*, search_request::*, server_lifecycle::*, short_epg::*, stalker::*,
stalker_item::*, stats::*, status_check::*, stream_history::*, stream_history_record::*, stream_info::*,
stream_meter::*, stream_probe_failure::*, stream_properties::*, strm_export_style::*, system_info::*,
target_bouquet::*, target_type::*, transfer::*, ui_playlist_item::*, user_command::*, user_id::*,
user_lifecycle::*, uuidtype::*, watch_health::*, web_socket::*, xtream::*,
};
pub use ids::*;
pub use prepare::*;
+10
View File
@@ -56,6 +56,12 @@ pub struct PlaylistItemHeader {
pub input_stream_id: Arc<str>,
#[serde(default, rename = "source_user_agent", alias = "upstream_user_agent", with = "arc_str_option_serde")]
pub upstream_user_agent: Option<Arc<str>>,
/// Mapping override for channel grouping: `Some("")` opts out, `Some(key)` forces a key.
#[serde(default, with = "arc_str_option_serde")]
pub channel_group: Option<Arc<str>>,
/// Set on group items by the grouping stage; copies (favourites) inherit it.
#[serde(default)]
pub channel_group_uuid: Option<UUIDType>,
}
impl Default for PlaylistItemHeader {
@@ -84,6 +90,8 @@ impl Default for PlaylistItemHeader {
source_ordinal: 0,
input_stream_id: "".intern(),
upstream_user_agent: None,
channel_group: None,
channel_group_uuid: None,
}
}
}
@@ -166,6 +174,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),
// Not carried by the header.
HeaderField::ProviderId => None,
}
@@ -192,6 +201,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::Chno => match value.parse::<u32>() {
Ok(parsed) => self.chno = parsed,
Err(_) => return false,
+6
View File
@@ -273,6 +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,
};
PlaylistItem { header }
@@ -305,6 +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,
};
PlaylistItem { header }
@@ -375,6 +379,8 @@ impl PlaylistItem {
source_ordinal: 0,
input_stream_id: stream_id_str,
upstream_user_agent: None,
channel_group: None,
channel_group_uuid: None,
};
PlaylistItem { header }
+5 -1
View File
@@ -301,7 +301,11 @@ impl crate::model::FieldGet for M3uPlaylistItem {
// M3U resource endpoint resolves a URL path segment through
// `get_field`, so making these resolvable would turn a 404 into a
// response. Preserved from the string-keyed accessor this replaces.
HeaderField::Id | HeaderField::Input | HeaderField::Type | HeaderField::Genre => None,
HeaderField::Id
| HeaderField::Input
| HeaderField::Type
| HeaderField::Genre
| HeaderField::ChannelGroup => None,
}
}
}
@@ -177,3 +177,24 @@ fn legacy_playlist_items_fall_back_to_provider_id_without_virtual_id() {
assert_eq!(identified_xtream_item.input_stream_id.as_ref(), "explicit-origin-alpha");
assert_eq!(identified_xtream_item.get_input_stream_id().as_deref(), Some("explicit-origin-alpha"));
}
#[test]
fn channel_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());
}
#[test]
fn header_without_channel_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");
let header: crate::model::PlaylistItemHeader = serde_json::from_value(value).expect("deserializes");
assert!(header.channel_group.is_none());
}
+2 -1
View File
@@ -21,6 +21,7 @@ fn header_field_parse_round_trips_every_variant() {
HeaderField::EpgChannelId,
HeaderField::Chno,
HeaderField::Genre,
HeaderField::ChannelGroup,
] {
assert_eq!(HeaderField::parse(field.as_str()), Some(field), "{field} did not round-trip");
// Lookup stayed case-insensitive.
@@ -45,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", "not_a_field"] {
for absent in ["id", "input", "type", "genre", "channel_group", "not_a_field"] {
assert!(item.get_field(absent).is_none(), "{absent} must not resolve on an M3U item");
}
}
+8 -1
View File
@@ -38,10 +38,12 @@ impl StreamTechnicalInfo {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct StreamChannel {
pub target_id: u16,
pub virtual_id: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group_virtual_id: Option<u32>,
pub provider_id: u32,
#[serde(with = "arc_str_serde")]
pub input_name: Arc<str>,
@@ -71,6 +73,9 @@ pub struct StreamChannel {
}
impl StreamChannel {
#[must_use]
pub fn client_virtual_id(&self) -> u32 { self.group_virtual_id.unwrap_or(self.virtual_id) }
/// Returns a copy of this channel with the supplied EPG reference timestamp
/// installed. Used by the M3U non-HLS path to thread an archive/catchup
/// timestamp parsed from the request URL into the `StreamChannel` that the
@@ -99,6 +104,7 @@ impl XtreamPlaylistItem {
StreamChannel {
target_id,
virtual_id: self.virtual_id.get(),
group_virtual_id: None,
provider_id: self.provider_id,
input_name: Arc::clone(&self.input_name),
item_type: self.item_type,
@@ -123,6 +129,7 @@ impl M3uPlaylistItem {
StreamChannel {
target_id,
virtual_id: self.virtual_id.get(),
group_virtual_id: None,
provider_id: self.get_provider_id().unwrap_or_default(),
input_name: Arc::clone(&self.input_name),
item_type: self.item_type,