mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-18 22:03:01 +02:00
@@ -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",
|
||||
|
||||
@@ -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()))
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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: {})",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ pub const MAPPER_FIELDS: &[HeaderField] = &[
|
||||
HeaderField::Rec,
|
||||
HeaderField::Url,
|
||||
HeaderField::EpgChannelId,
|
||||
HeaderField::ChannelGroup,
|
||||
];
|
||||
|
||||
/// Whether `name` resolves to one of `allowed`.
|
||||
|
||||
@@ -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
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user