feat(watch): group added/removed contexts per watch entry (#912)

* feat(watch): group added/removed contexts per watch entry

- watch entries: regex string or {pattern, on:
[channels|group_added|group_removed]}
- one playlist.watch.changed per refresh with WatchChange list;
playlist.groups.changed aliased
- Telegram/ntfy message length limits
- web UI: Mapping and Watch sub pages, new Mapping icon
- ClusterFlags on create_bitset!, bitflags removed

BREAKING CHANGE: playlist.watch.changed templates must use the new
watch.* fields

* feat(watch): group added/removed contexts per watch entry

- watch entries: regex string or {pattern, on:
[channels|group_added|group_removed]}
- one playlist.watch.changed per refresh with WatchChange list;
playlist.groups.changed aliased
- Telegram/ntfy message length limits
- web UI: Mapping and Watch sub pages, new Mapping icon
- ClusterFlags on create_bitset!, bitflags removed

BREAKING CHANGE: playlist.watch.changed templates must use the new
watch.* fields
This commit is contained in:
euzu
2026-10-12 21:05:55 +02:00
committed by GitHub
parent 45c10edd3d
commit b6bab647df
45 changed files with 1981 additions and 546 deletions
+174 -40
View File
@@ -1,5 +1,7 @@
use crate::model::{PlaylistItemType, XtreamCluster};
use bitflags::bitflags;
use crate::{
create_bitset,
model::{PlaylistItemType, XtreamCluster},
};
use serde::{
de,
de::{Error, SeqAccess, Visitor},
@@ -7,13 +9,24 @@ use serde::{
};
use std::fmt;
bitflags! {
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub struct ClusterFlags: u16 {
const Live = 1; // 0b0000_0001
const Vod = 1 << 1; // 0b0000_0010
const Series = 1 << 2; // 0b0000_0100
}
create_bitset!(u8, ClusterFlag, Live, Vod, Series);
/// Which xtream clusters (live, vod, series) something applies to.
///
/// A newtype over the `create_bitset!` set rather than the set itself: the
/// generated set derives a plain `u8` serde form, while this type's wire form
/// is the `[live,vod]` string persisted in configs and user databases. The API
/// mirrors the `bitflags` type it replaced, so call sites read the same.
#[derive(Copy, Clone, PartialEq, Eq, Hash)]
pub struct ClusterFlags(ClusterFlagSet);
#[allow(non_upper_case_globals)]
impl ClusterFlags {
pub const Live: Self = Self::flag(ClusterFlag::Live);
pub const Vod: Self = Self::flag(ClusterFlag::Vod);
pub const Series: Self = Self::flag(ClusterFlag::Series);
const fn flag(flag: ClusterFlag) -> Self { Self(ClusterFlagSet(1 << flag as u8)) }
}
impl Default for ClusterFlags {
@@ -21,57 +34,115 @@ impl Default for ClusterFlags {
}
impl ClusterFlags {
pub fn has_cluster(&self, item_type: PlaylistItemType) -> bool {
match item_type.cluster() {
XtreamCluster::Live => self.contains(ClusterFlags::Live),
XtreamCluster::Video => self.contains(ClusterFlags::Vod),
XtreamCluster::Series => self.contains(ClusterFlags::Series),
#[must_use]
pub const fn all() -> Self { Self(ClusterFlagSet::ALL) }
#[must_use]
pub const fn empty() -> Self { Self(ClusterFlagSet::new()) }
/// The raw bits, widened to the `u16` the `bitflags` type exposed.
#[inline]
pub fn bits(&self) -> u16 { u16::from(self.0 .0) }
#[inline]
pub fn is_all(&self) -> bool { self.0 == ClusterFlagSet::ALL }
#[inline]
pub fn is_empty(&self) -> bool { self.0.is_empty() }
/// Whether every cluster in `other` is set.
#[inline]
pub fn contains(&self, other: Self) -> bool { self.0.contains_all(&other.0) }
/// Whether any cluster in `other` is set.
#[inline]
pub fn intersects(&self, other: Self) -> bool { self.0.contains_any(&other.0) }
#[inline]
pub fn insert(&mut self, other: Self) { self.0.union(other.0); }
#[inline]
pub fn remove(&mut self, other: Self) { self.0 = ClusterFlagSet(self.0 .0 & !other.0 .0); }
#[inline]
pub fn toggle(&mut self, other: Self) { self.0 = ClusterFlagSet(self.0 .0 ^ other.0 .0); }
#[inline]
pub fn set(&mut self, other: Self, value: bool) {
if value {
self.insert(other);
} else {
self.remove(other);
}
}
pub fn has_cluster(&self, item_type: PlaylistItemType) -> bool {
self.0.contains(match item_type.cluster() {
XtreamCluster::Live => ClusterFlag::Live,
XtreamCluster::Video => ClusterFlag::Vod,
XtreamCluster::Series => ClusterFlag::Series,
})
}
pub fn has_full_flags(&self) -> bool { self.is_all() }
fn parse_flag(item: &str) -> Result<ClusterFlag, String> {
let item = item.trim();
// ASCII-insensitive compare: no lowercase copy per item.
[("live", ClusterFlag::Live), ("vod", ClusterFlag::Vod), ("series", ClusterFlag::Series)]
.into_iter()
.find_map(|(name, flag)| name.eq_ignore_ascii_case(item).then_some(flag))
.ok_or_else(|| format!("Invalid flag {}, allowed are live, vod, series", item.to_lowercase()))
}
fn from_items<I, S>(items: I) -> Result<Self, String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut result = ClusterFlags::empty();
let mut result = ClusterFlagSet::new();
for item in items {
match item.as_ref().trim().to_lowercase().as_str() {
"live" => result.set(ClusterFlags::Live, true),
"vod" => result.set(ClusterFlags::Vod, true),
"series" => result.set(ClusterFlags::Series, true),
invalid => return Err(format!("Invalid flag {invalid}, allowed are live, vod, series")),
}
result.set(Self::parse_flag(item.as_ref())?);
}
Ok(result)
Ok(Self(result))
}
}
impl std::ops::BitOr for ClusterFlags {
type Output = Self;
#[inline]
fn bitor(self, other: Self) -> Self { Self(ClusterFlagSet(self.0 .0 | other.0 .0)) }
}
impl std::ops::BitOrAssign for ClusterFlags {
#[inline]
fn bitor_assign(&mut self, other: Self) { self.insert(other); }
}
impl From<Option<ClusterFlags>> for ClusterFlags {
fn from(value: Option<ClusterFlags>) -> Self { value.unwrap_or_else(ClusterFlags::all) }
}
impl fmt::Display for ClusterFlags {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut flag_strings = Vec::new();
if self.contains(ClusterFlags::Live) {
flag_strings.push("live");
f.write_str("[")?;
let mut separator = "";
for (flag, name) in [(ClusterFlag::Live, "live"), (ClusterFlag::Vod, "vod"), (ClusterFlag::Series, "series")] {
if self.0.contains(flag) {
f.write_str(separator)?;
f.write_str(name)?;
separator = ",";
}
}
if self.contains(ClusterFlags::Vod) {
flag_strings.push("vod");
}
if self.contains(ClusterFlags::Series) {
flag_strings.push("series");
}
write!(f, "[{}]", flag_strings.join(","))
f.write_str("]")
}
}
impl fmt::Debug for ClusterFlags {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "ClusterFlags({self})") }
}
impl TryFrom<&str> for ClusterFlags {
type Error = String;
@@ -125,14 +196,77 @@ impl<'de> Deserialize<'de> for ClusterFlags {
where
A: SeqAccess<'de>,
{
let mut values = Vec::new();
while let Some(val) = seq.next_element::<String>()? {
let entry = val.trim().to_lowercase();
values.push(entry);
let mut result = ClusterFlagSet::new();
while let Some(value) = seq.next_element::<String>()? {
result.set(ClusterFlags::parse_flag(&value).map_err(A::Error::custom)?);
}
ClusterFlags::try_from(values).map_err(A::Error::custom)
Ok(ClusterFlags(result))
}
}
deserializer.deserialize_any(ClusterFlagsVisitor)
}
}
#[cfg(test)]
mod tests {
use super::ClusterFlags;
use crate::model::PlaylistItemType;
#[test]
fn default_and_none_mean_all_clusters() {
assert_eq!(ClusterFlags::default(), ClusterFlags::all());
assert_eq!(ClusterFlags::from(None), ClusterFlags::all());
assert!(ClusterFlags::all().is_all());
assert!(ClusterFlags::all().has_full_flags());
assert!(ClusterFlags::empty().is_empty());
assert!(!(ClusterFlags::Live | ClusterFlags::Vod).is_all());
}
#[test]
fn set_operations_behave_like_flags() {
let mut flags = ClusterFlags::empty();
flags.insert(ClusterFlags::Live);
flags |= ClusterFlags::Series;
assert!(flags.contains(ClusterFlags::Live));
assert!(flags.contains(ClusterFlags::Live | ClusterFlags::Series));
assert!(!flags.contains(ClusterFlags::Live | ClusterFlags::Vod));
assert!(flags.intersects(ClusterFlags::Live | ClusterFlags::Vod));
assert!(!flags.intersects(ClusterFlags::Vod));
flags.set(ClusterFlags::Live, false);
flags.set(ClusterFlags::Vod, true);
assert_eq!(flags, ClusterFlags::Vod | ClusterFlags::Series);
flags.toggle(ClusterFlags::Series);
flags.toggle(ClusterFlags::Live);
assert_eq!(flags, ClusterFlags::Live | ClusterFlags::Vod);
}
#[test]
fn has_cluster_maps_item_types() {
let flags = ClusterFlags::Vod;
assert!(flags.has_cluster(PlaylistItemType::Video));
assert!(!flags.has_cluster(PlaylistItemType::Live));
}
#[test]
fn display_and_parse_round_trip() {
assert_eq!((ClusterFlags::Live | ClusterFlags::Series).to_string(), "[live,series]");
assert_eq!(ClusterFlags::empty().to_string(), "[]");
assert_eq!(ClusterFlags::try_from("[ Vod , live ]"), Ok(ClusterFlags::Live | ClusterFlags::Vod));
assert_eq!(ClusterFlags::try_from("()"), Ok(ClusterFlags::empty()));
assert!(ClusterFlags::try_from("live,radio").is_err());
assert_eq!(ClusterFlags::try_from(vec!["series".to_string()]), Ok(ClusterFlags::Series));
}
/// The serialized form is persisted in configs and user databases, so it
/// must not change with the implementation underneath.
#[test]
fn serde_format_is_stable() {
let flags = ClusterFlags::Live | ClusterFlags::Vod;
assert_eq!(serde_json::to_string(&flags).unwrap(), r#""[live,vod]""#);
assert_eq!(serde_json::from_str::<ClusterFlags>(r#""[live,vod]""#).unwrap(), flags);
assert_eq!(serde_json::from_str::<ClusterFlags>(r#"["LIVE"," vod "]"#).unwrap(), flags);
assert_eq!(serde_saphyr::from_str::<ClusterFlags>("[series]\n").unwrap(), ClusterFlags::Series);
let bin = rmp_serde::to_vec(&flags).unwrap();
assert_eq!(rmp_serde::from_slice::<ClusterFlags>(&bin).unwrap(), flags);
}
}
+2
View File
@@ -44,6 +44,7 @@ mod template_definition;
mod time_units;
mod tmdb_curation;
mod trakt;
mod watch;
mod web_auth;
mod web_ui;
@@ -108,5 +109,6 @@ pub use template_definition::*;
pub use time_units::*;
pub use tmdb_curation::*;
pub use trakt::*;
pub use watch::*;
pub use web_auth::*;
pub use web_ui::*;
+4 -10
View File
@@ -5,8 +5,8 @@ use crate::{
},
error::TuliproxError,
model::{
ConfigFavouritesDto, ConfigRenameDto, ConfigSortDto, CurationConfigDto, HdHomeRunDeviceOverview,
PatternTemplate, Prepare, PrepareAll, ProcessingOrder, TargetType,
ConfigFavouritesDto, ConfigRenameDto, ConfigSortDto, ConfigWatchDto, CurationConfigDto,
HdHomeRunDeviceOverview, PatternTemplate, Prepare, PrepareAll, ProcessingOrder, TargetType,
},
};
@@ -47,7 +47,7 @@ pub struct ConfigTargetDto {
#[serde(default, skip_serializing_if = "is_default_processing_order")]
pub processing_order: ProcessingOrder,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub watch: Option<Vec<String>>,
pub watch: Option<Vec<ConfigWatchDto>>,
#[serde(default, skip_serializing_if = "is_false")]
pub use_memory_cache: bool,
}
@@ -236,13 +236,7 @@ impl ConfigTargetDto {
self.favourites.prepare(templates)?;
if let Some(watch) = &self.watch {
for pat in watch {
if let Err(err) = crate::model::REGEX_CACHE.get_or_compile(pat) {
return Err(TuliproxError::ConfigTarget(format!("Invalid watch regular expression: {err}")));
}
}
}
self.watch.prepare_all(())?;
self.filter.prepare(templates)?;
self.rename.prepare_all(templates)?;
+41 -2
View File
@@ -1,6 +1,6 @@
use super::{
ConfigTargetDto, ConfigTargetFilterDto, ConfigTargetOptions, ConfigTargetShareLiveStreams, EpgOutputOptions,
M3uTargetOutputDto, StrmTargetOutputDto, TargetOutputDto, XtreamTargetOutputDto,
ConfigTargetDto, ConfigTargetFilterDto, ConfigTargetOptions, ConfigTargetShareLiveStreams, ConfigWatchDto,
EpgOutputOptions, M3uTargetOutputDto, StrmTargetOutputDto, TargetOutputDto, XtreamTargetOutputDto,
};
fn target_with_outputs(output: Vec<TargetOutputDto>) -> ConfigTargetDto {
@@ -452,3 +452,42 @@ fn target_options_mpeg_ts_helper_keeps_existing_stream_share_semantics() {
assert!(!hls_only.share_live_mpeg_ts_enabled());
assert!(mpeg_ts.share_live_mpeg_ts_enabled());
}
#[test]
fn watch_entry_with_empty_on_is_rejected() {
let mut target = target_with_outputs(vec![xtream_output()]);
target.watch = Some(vec![ConfigWatchDto { pattern: "^A".to_string(), on: crate::model::WatchContextSet::new() }]);
let err = target.prepare(1, None, None).unwrap_err().to_string();
assert!(err.contains("empty 'on' list"), "{err}");
}
#[test]
fn watch_entry_with_contexts_prepares() {
let mut target = target_with_outputs(vec![xtream_output()]);
target.watch =
Some(vec![ConfigWatchDto::channels("^A"), ConfigWatchDto::new("^B", [crate::model::WatchContext::GroupAdded])]);
target.prepare(1, None, None).unwrap();
}
#[test]
fn watch_target_yaml_accepts_both_forms() {
let target: ConfigTargetDto =
serde_saphyr::from_str("name: watched\nwatch:\n - \"^A\"\n - pattern: \"^B\"\n on: [group_added]\n")
.expect("target yaml");
let watch = target.watch.expect("watch set");
assert_eq!(watch[0], ConfigWatchDto::channels("^A"));
assert!(
watch[1].has(crate::model::WatchContext::GroupAdded) && !watch[1].has(crate::model::WatchContext::Channels)
);
}
#[test]
fn every_bad_watch_entry_is_reported_at_once() {
let mut target = target_with_outputs(vec![xtream_output()]);
target.watch = Some(vec![
ConfigWatchDto { pattern: "^A".to_string(), on: crate::model::WatchContextSet::new() },
ConfigWatchDto::channels("^(B"),
]);
let err = target.prepare(1, None, None).unwrap_err().to_string();
assert!(err.contains("empty 'on' list") && err.contains("Invalid watch regular expression"), "{err}");
}
+303
View File
@@ -0,0 +1,303 @@
//! One entry of a target's `watch` list.
//!
//! An entry is either a bare regex string, which watches the channels of
//! matching groups (the only form that existed before), or a map with
//! `pattern` and `on`, which also chooses whether groups appearing or
//! vanishing are reported.
//!
//! The contexts are a `u8` bitset built by `create_bitset!`, like `RoleSet`
//! and `PermissionSet`: no allocation per entry, and "does this entry apply
//! to X" is a `test` instruction in the per-group loop.
use crate::{
create_bitset,
error::TuliproxError,
model::{Prepare, REGEX_CACHE},
};
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
create_bitset!(u8, WatchContext, Channels, GroupAdded, GroupRemoved);
/// The config spelling of a [`WatchContext`].
///
/// The macro-generated enum serialises its Rust variant names; the YAML
/// spelling is snake case.
#[derive(Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum WireContext {
Channels,
GroupAdded,
GroupRemoved,
}
const CONTEXTS: [(WatchContext, WireContext); 3] = [
(WatchContext::Channels, WireContext::Channels),
(WatchContext::GroupAdded, WireContext::GroupAdded),
(WatchContext::GroupRemoved, WireContext::GroupRemoved),
];
impl From<WireContext> for WatchContext {
fn from(context: WireContext) -> Self {
match context {
WireContext::Channels => Self::Channels,
WireContext::GroupAdded => Self::GroupAdded,
WireContext::GroupRemoved => Self::GroupRemoved,
}
}
}
/// The `on` list, read straight into the bitset without an intermediate `Vec`.
struct OnList(WatchContextSet);
impl<'de> Deserialize<'de> for OnList {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct OnVisitor;
impl<'de> de::Visitor<'de> for OnVisitor {
type Value = OnList;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a list of `channels`, `group_added`, `group_removed`")
}
fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
let mut set = WatchContextSet::new();
while let Some(context) = seq.next_element::<WireContext>()? {
set.set(context.into());
}
Ok(OnList(set))
}
}
deserializer.deserialize_seq(OnVisitor)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigWatchDto {
pub pattern: String,
/// Never empty once `ConfigTargetDto::prepare` accepted the target.
pub on: WatchContextSet,
}
const CHANNELS_ONLY: WatchContextSet = WatchContextSet(1 << WatchContext::Channels as u8);
impl ConfigWatchDto {
/// The short form: watch the channels of matching groups.
#[must_use]
pub fn channels(pattern: impl Into<String>) -> Self { Self { pattern: pattern.into(), on: CHANNELS_ONLY } }
#[must_use]
pub fn new(pattern: impl Into<String>, on: impl IntoIterator<Item = WatchContext>) -> Self {
let mut set = WatchContextSet::new();
for context in on {
set.set(context);
}
Self { pattern: pattern.into(), on: set }
}
#[must_use]
pub fn has(&self, context: WatchContext) -> bool { self.on.contains(context) }
}
/// One entry is usable when it reports at least one context and its pattern
/// compiles. Shared by config load and the web UI editor, so both reject the
/// same entries for the same reason.
impl Prepare for ConfigWatchDto {
type Ctx<'a> = ();
fn prepare(&mut self, (): Self::Ctx<'_>) -> Result<(), TuliproxError> {
if self.on.is_empty() {
return Err(TuliproxError::ConfigTarget(format!("watch entry '{}' has an empty 'on' list", self.pattern)));
}
REGEX_CACHE
.get_or_compile(&self.pattern)
.map(|_| ())
.map_err(|err| TuliproxError::ConfigTarget(format!("Invalid watch regular expression: {err}")))
}
}
impl Serialize for ConfigWatchDto {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
struct OnRef(WatchContextSet);
impl Serialize for OnRef {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_seq(
CONTEXTS.iter().filter(|(context, _)| self.0.contains(*context)).map(|(_, wire)| *wire),
)
}
}
#[derive(Serialize)]
struct FullFormRef<'a> {
pattern: &'a str,
on: OnRef,
}
if self.on == CHANNELS_ONLY {
serializer.serialize_str(&self.pattern)
} else {
FullFormRef { pattern: &self.pattern, on: OnRef(self.on) }.serialize(serializer)
}
}
}
impl<'de> Deserialize<'de> for ConfigWatchDto {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
// A visitor rather than `#[serde(untagged)]`: untagged reports every
// mistake in the map form as "did not match any variant", which hides
// the key the operator mistyped.
struct WatchVisitor;
impl<'de> de::Visitor<'de> for WatchVisitor {
type Value = ConfigWatchDto;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a group regex string or a map with `pattern` and `on`")
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
Ok(ConfigWatchDto::channels(value))
}
fn visit_string<E: de::Error>(self, value: String) -> Result<Self::Value, E> {
Ok(ConfigWatchDto::channels(value))
}
// `Vec<String>` used to accept unquoted YAML scalars such as
// `- 2024`; keep loading them as patterns.
fn visit_i64<E: de::Error>(self, value: i64) -> Result<Self::Value, E> {
Ok(ConfigWatchDto::channels(value.to_string()))
}
fn visit_u64<E: de::Error>(self, value: u64) -> Result<Self::Value, E> {
Ok(ConfigWatchDto::channels(value.to_string()))
}
fn visit_bool<E: de::Error>(self, value: bool) -> Result<Self::Value, E> {
Ok(ConfigWatchDto::channels(value.to_string()))
}
fn visit_map<M: de::MapAccess<'de>>(self, map: M) -> Result<Self::Value, M::Error> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct FullForm {
pattern: String,
#[serde(default)]
on: Option<OnList>,
}
let full = FullForm::deserialize(de::value::MapAccessDeserializer::new(map))?;
Ok(ConfigWatchDto { pattern: full.pattern, on: full.on.map_or(CHANNELS_ONLY, |on| on.0) })
}
}
deserializer.deserialize_any(WatchVisitor)
}
}
#[cfg(test)]
mod tests {
use super::{ConfigWatchDto, WatchContext, WatchContextSet};
use crate::model::Prepare;
fn yaml(src: &str) -> Vec<ConfigWatchDto> { serde_saphyr::from_str(src).expect("valid watch yaml") }
fn set(contexts: &[WatchContext]) -> WatchContextSet {
let mut set = WatchContextSet::new();
for context in contexts {
set.set(*context);
}
set
}
#[test]
fn a_plain_string_watches_channels() {
let entries = yaml("- \"^DE Sport.*\"\n");
assert_eq!(entries, vec![ConfigWatchDto::channels("^DE Sport.*")]);
}
#[test]
fn the_object_form_reads_pattern_and_on() {
let entries = yaml("- pattern: \"^Event.*\"\n on: [group_added, channels]\n");
assert_eq!(entries[0].pattern, "^Event.*");
assert_eq!(entries[0].on, set(&[WatchContext::GroupAdded, WatchContext::Channels]));
}
#[test]
fn the_object_form_without_on_watches_channels() {
let entries = yaml("- pattern: \"^News\"\n");
assert_eq!(entries, vec![ConfigWatchDto::channels("^News")]);
}
#[test]
fn duplicate_contexts_collapse() {
let entries = yaml("- pattern: x\n on: [group_removed, channels, group_removed]\n");
assert_eq!(entries[0].on, set(&[WatchContext::GroupRemoved, WatchContext::Channels]));
}
#[test]
fn an_empty_on_list_stays_empty_for_prepare_to_reject() {
let entries = yaml("- pattern: x\n on: []\n");
assert!(entries[0].on.is_empty());
}
#[test]
fn an_unknown_key_is_named_in_the_error() {
let err = serde_saphyr::from_str::<Vec<ConfigWatchDto>>("- group: \"^Sports\"\n").unwrap_err().to_string();
assert!(err.contains("group"), "error must name the offending key: {err}");
assert!(!err.contains("did not match any variant"), "untagged-style error leaked: {err}");
}
#[test]
fn an_unknown_context_is_rejected() {
assert!(serde_saphyr::from_str::<Vec<ConfigWatchDto>>("- pattern: x\n on: [groups]\n").is_err());
}
#[test]
fn numeric_and_bool_scalars_still_load_as_patterns() {
let entries = yaml("- 2024\n- true\n");
assert_eq!(entries, vec![ConfigWatchDto::channels("2024"), ConfigWatchDto::channels("true")]);
}
#[test]
fn round_trip_keeps_short_form_and_writes_contexts_in_canonical_order() {
let entries = vec![
ConfigWatchDto::channels("^A"),
ConfigWatchDto::new("^B", [WatchContext::GroupRemoved, WatchContext::Channels]),
];
let json = serde_json::to_string(&entries).expect("serialize");
assert_eq!(json, r#"["^A",{"pattern":"^B","on":["channels","group_removed"]}]"#);
let back: Vec<ConfigWatchDto> = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, entries);
}
#[test]
fn yaml_round_trip_keeps_both_forms() {
let entries = vec![
ConfigWatchDto::channels("^A"),
ConfigWatchDto::new("^B", [WatchContext::GroupAdded, WatchContext::GroupRemoved]),
];
let yaml_text = serde_saphyr::to_string(&entries).expect("serialize yaml");
assert_eq!(yaml(&yaml_text), entries, "{yaml_text}");
}
#[test]
fn prepare_accepts_a_valid_entry() {
assert!(ConfigWatchDto::new("^Event.*", [WatchContext::GroupAdded]).prepare(()).is_ok());
}
#[test]
fn prepare_rejects_an_empty_on_list() {
let err = ConfigWatchDto { pattern: "^A".to_string(), on: WatchContextSet::new() }.prepare(()).unwrap_err();
assert!(err.to_string().contains("watch entry '^A' has an empty 'on' list"), "{err}");
}
#[test]
fn prepare_rejects_an_invalid_regex() {
let err = ConfigWatchDto::channels("^(Sport").prepare(()).unwrap_err();
assert!(err.to_string().contains("Invalid watch regular expression"), "{err}");
}
}
+9 -6
View File
@@ -35,7 +35,6 @@ pub enum EventKind {
DiskAlert,
ConfigReloadFailed,
PlaylistWatchChanged,
PlaylistGroupsChanged,
PlaylistWatchDisabled,
PlaylistWatchUnmatched,
RecordingStarted,
@@ -68,7 +67,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; 42] = [
Self::ServerError,
Self::ServerStarted,
Self::ServerShutdown,
@@ -88,7 +87,6 @@ impl EventKind {
Self::DiskAlert,
Self::ConfigReloadFailed,
Self::PlaylistWatchChanged,
Self::PlaylistGroupsChanged,
Self::PlaylistWatchDisabled,
Self::PlaylistWatchUnmatched,
Self::RecordingStarted,
@@ -139,7 +137,6 @@ impl EventKind {
Self::PlaylistUpdate
| Self::PlaylistUpdateProgress
| Self::PlaylistWatchChanged
| Self::PlaylistGroupsChanged
| Self::PlaylistWatchDisabled
| Self::PlaylistWatchUnmatched => Permission::PlaylistWrite,
Self::LibraryScanProgress | Self::LibraryScanFailed => Permission::LibraryWrite,
@@ -254,7 +251,6 @@ impl EventKind {
Self::DiskAlert => "system.disk.alert",
Self::ConfigReloadFailed => "config.reload.failed",
Self::PlaylistWatchChanged => "playlist.watch.changed",
Self::PlaylistGroupsChanged => "playlist.groups.changed",
Self::PlaylistWatchDisabled => "playlist.watch.disabled",
Self::PlaylistWatchUnmatched => "playlist.watch.unmatched",
Self::RecordingStarted => "recording.started",
@@ -283,7 +279,14 @@ impl EventKind {
/// Parse a wire name back. Unknown names are `None` rather than a
/// fallback variant, so a typo in a subscription is visible.
#[must_use]
pub fn from_wire_name(name: &str) -> Option<Self> { Self::ALL.into_iter().find(|kind| kind.as_wire_name() == name) }
pub fn from_wire_name(name: &str) -> Option<Self> {
// `playlist.groups.changed` was folded into watch changes; existing
// plugin subscriptions keep working.
if name == "playlist.groups.changed" {
return Some(Self::PlaylistWatchChanged);
}
Self::ALL.into_iter().find(|kind| kind.as_wire_name() == name)
}
}
impl EventKindMask {
+4 -9
View File
@@ -5,11 +5,10 @@ use crate::model::{
stats::SourceStats,
ActiveUserConnectionChange, ConfigReloadFailure, ConfigType, ConnectionDenied, DiskAlert, LibraryScanProgressEvent,
LibraryScanSummaryStatus, MetadataUpdateFailure, MsgKind, NotificationDeadLetter, Permission,
PlaylistGroupsChanged, PlaylistUpdateProgressEvent, PlaylistUpdateRunId, PlaylistUpdateRunOrder,
PlaylistUpdateState, ProviderAccountEvent, ProviderAccountState, ProviderFetchFailure, ProviderPoolExhausted,
ProviderPriorityFallback, RecordingLifecycleMessage, ScheduledTaskFailure, ServerLifecycleEvent,
ServerLifecycleState, StreamProbeFailure, SystemInfo, UserLifecycleEvent, UserLifecycleState, WatchChanges,
WatchDisabled, WatchUnmatched,
PlaylistUpdateProgressEvent, PlaylistUpdateRunId, PlaylistUpdateRunOrder, PlaylistUpdateState,
ProviderAccountEvent, ProviderAccountState, ProviderFetchFailure, ProviderPoolExhausted, ProviderPriorityFallback,
RecordingLifecycleMessage, ScheduledTaskFailure, ServerLifecycleEvent, ServerLifecycleState, StreamProbeFailure,
SystemInfo, UserLifecycleEvent, UserLifecycleState, WatchChanges, WatchDisabled, WatchUnmatched,
};
use std::sync::Arc;
@@ -56,7 +55,6 @@ pub enum EventMessage {
/// A target's `watch` config saw its group membership change.
PlaylistWatchChanged(WatchChanges),
/// A target gained or lost whole groups between refreshes.
PlaylistGroupsChanged(PlaylistGroupsChanged),
/// A target's `watch` config is configured but not working.
PlaylistWatchDisabled(WatchDisabled),
/// `watch` patterns that matched no group in the refreshed playlist.
@@ -134,7 +132,6 @@ impl EventMessage {
Self::DiskAlert(_) => EventKind::DiskAlert,
Self::ConfigReloadFailed(_) => EventKind::ConfigReloadFailed,
Self::PlaylistWatchChanged(_) => EventKind::PlaylistWatchChanged,
Self::PlaylistGroupsChanged(_) => EventKind::PlaylistGroupsChanged,
Self::PlaylistWatchDisabled(_) => EventKind::PlaylistWatchDisabled,
Self::PlaylistWatchUnmatched(_) => EventKind::PlaylistWatchUnmatched,
// One payload, three kinds: a subscriber that only cares about
@@ -232,7 +229,6 @@ impl EventMessage {
Self::DiskAlert(_) => registry::SYSTEM_DISK_ALERT,
Self::ConfigReloadFailed(_) => registry::CONFIG_RELOAD_FAILED,
Self::PlaylistWatchChanged(_) => registry::PLAYLIST_WATCH_CHANGED,
Self::PlaylistGroupsChanged(_) => registry::PLAYLIST_GROUPS_CHANGED,
Self::PlaylistWatchDisabled(_) => registry::PLAYLIST_WATCH_DISABLED,
Self::PlaylistWatchUnmatched(_) => registry::PLAYLIST_WATCH_UNMATCHED,
Self::RecordingLifecycle(msg) => match msg.event {
@@ -309,7 +305,6 @@ impl EventMessage {
Self::DiskAlert(alert) => encode(alert),
Self::ConfigReloadFailed(failure) => encode(failure),
Self::PlaylistWatchChanged(changes) => encode(changes),
Self::PlaylistGroupsChanged(changes) => encode(changes),
Self::PlaylistWatchDisabled(disabled) => encode(disabled),
Self::PlaylistWatchUnmatched(unmatched) => encode(unmatched),
Self::RecordingLifecycle(msg) => encode(msg),
+7
View File
@@ -55,3 +55,10 @@ fn all_matches_every_kind() {
assert!(EventKindMask::ALL.contains(kind));
}
}
#[test]
fn groups_changed_alias_resolves_for_kinds() {
// Folded into `playlist.watch.changed`; plugin manifests that subscribed
// to the old name keep receiving group changes.
assert_eq!(EventKind::from_wire_name("playlist.groups.changed"), Some(EventKind::PlaylistWatchChanged));
}
+123 -32
View File
@@ -1,4 +1,5 @@
use crate::concat_string;
use std::sync::Arc;
#[derive(
Debug,
@@ -60,7 +61,49 @@ impl MsgKind {
#[cfg(test)]
mod tests {
use super::MsgKind;
use super::{MsgKind, WatchChange, WatchChanges, WatchGroupItems, WatchItems};
use std::sync::Arc;
fn titles(names: &[&str]) -> Vec<Arc<str>> { names.iter().map(|n| Arc::from(*n)).collect() }
fn sample_changes() -> WatchChanges {
WatchChanges::new(
"sports".to_string(),
vec![
WatchChange::GroupsAdded(WatchItems::complete(titles(&["Event Bundesliga"]))),
WatchChange::GroupsRemoved(WatchItems::complete(titles(&["DE Kids"]))),
WatchChange::ChannelsAdded(WatchGroupItems {
group: "DE Sport HD".into(),
items: WatchItems::complete(titles(&["Sky Sport 5"])),
}),
],
)
}
#[test]
fn watch_changes_serialize_as_tagged_list() {
let json = serde_json::to_value(sample_changes()).expect("serialize");
assert_eq!(json["target"], "sports");
assert_eq!(json["changes"][0]["kind"], "groups_added");
assert_eq!(json["changes"][0]["items"][0], "Event Bundesliga");
assert_eq!(json["changes"][2]["kind"], "channels_added");
assert_eq!(json["changes"][2]["group"], "DE Sport HD");
assert_eq!(json["changes"][2]["total"], 1);
assert_eq!(json["changes"][2]["truncated"], false);
let back: WatchChanges = serde_json::from_value(json).expect("deserialize");
assert_eq!(back, sample_changes());
}
#[test]
fn template_view_groups_changes_by_kind() {
let changes = sample_changes();
let view = serde_json::to_value(changes.template_view()).expect("serialize view");
assert_eq!(view["groups_added"]["items"][0], "Event Bundesliga");
assert_eq!(view["groups_removed"]["total"], 1);
assert_eq!(view["channels_added"][0]["group"], "DE Sport HD");
assert!(view.get("channels_removed").is_none(), "empty views must be absent");
assert_eq!(view["changes"].as_array().map(Vec::len), Some(3));
}
#[test]
fn wire_name_matches_serde_rename() {
@@ -110,50 +153,98 @@ mod tests {
}
}
/// Group membership changes a target's `watch` config detected on refresh.
/// Every watch change one target refresh detected.
///
/// Lives here rather than in `tuliprox-core` because it is an event payload:
/// `EventMessage::PlaylistWatchChanged` carries it, and `shared` is the one
/// crate every emitter and every subscriber can name.
/// crate every emitter and every subscriber can name. One event per target
/// and refresh, so a reshuffle across many groups is one notification.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct WatchChanges {
pub target: String,
pub group: String,
/// Channel titles added, as a sample. Shorter than [`Self::added_total`]
/// when [`Self::truncated`] is set, and empty when the change was too
/// large to list at all - but never anything except channel titles.
pub added: Vec<String>,
/// Channel titles removed. Same sampling rule as [`Self::added`].
pub removed: Vec<String>,
/// How many channels were added, whatever the list above carries.
///
/// The lists used to absorb their own truncation notice: a synthesised
/// `"... 42 more added entries omitted"` pushed in beside real channel
/// titles. That was legible to the text template and to nothing else -
/// [`crate::model::EventMessage::payload`] serialises this struct straight
/// to JSON for plugins, and a plugin cannot tell a sentinel from a channel
/// actually named that. The counts live here so the lists stay honest.
#[serde(default)]
pub added_total: usize,
/// How many channels were removed. See [`Self::added_total`].
#[serde(default)]
pub removed_total: usize,
/// Whether the lists are a sample rather than the whole change.
#[serde(default)]
/// Group changes first, then channel changes ordered by group title.
pub changes: Vec<WatchChange>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum WatchChange {
ChannelsAdded(WatchGroupItems),
ChannelsRemoved(WatchGroupItems),
GroupsAdded(WatchItems),
GroupsRemoved(WatchItems),
}
/// A sampled list of titles.
///
/// `items` is shorter than `total` when `truncated` is set, and empty when the
/// change was too large to list - but never anything except real titles. A
/// plugin reading the JSON cannot tell a synthesised "... 42 more" entry from a
/// channel actually named that, so the counts live beside the list.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct WatchItems {
pub items: Vec<Arc<str>>,
pub total: usize,
pub truncated: bool,
}
impl WatchChanges {
/// A complete change set, nothing sampled.
///
/// The totals follow the lists, so a caller that is not truncating cannot
/// get them out of step.
impl WatchItems {
/// A complete list, nothing sampled.
#[must_use]
pub fn new(target: String, group: String, added: Vec<String>, removed: Vec<String>) -> Self {
Self { target, group, added_total: added.len(), removed_total: removed.len(), added, removed, truncated: false }
pub fn complete(items: Vec<Arc<str>>) -> Self { Self { total: items.len(), items, truncated: false } }
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct WatchGroupItems {
pub group: Arc<str>,
#[serde(flatten)]
pub items: WatchItems,
}
impl WatchChanges {
#[must_use]
pub fn new(target: String, changes: Vec<WatchChange>) -> Self { Self { target, changes } }
/// The shape notification templates see under `watch`.
#[must_use]
pub fn template_view(&self) -> WatchTemplateView<'_> {
let mut view = WatchTemplateView {
target: &self.target,
changes: &self.changes,
groups_added: None,
groups_removed: None,
channels_added: Vec::new(),
channels_removed: Vec::new(),
};
for change in &self.changes {
match change {
WatchChange::GroupsAdded(items) => view.groups_added = Some(items),
WatchChange::GroupsRemoved(items) => view.groups_removed = Some(items),
WatchChange::ChannelsAdded(group) => view.channels_added.push(group),
WatchChange::ChannelsRemoved(group) => view.channels_removed.push(group),
}
}
view
}
}
/// `changes` regrouped by kind, so a template can say
/// `{{#each watch.groups_added.items}}` instead of filtering the raw list
/// with `eq`. Borrows from the payload; absent keys mean "none".
#[derive(Debug, serde::Serialize)]
pub struct WatchTemplateView<'a> {
pub target: &'a str,
pub changes: &'a [WatchChange],
#[serde(skip_serializing_if = "Option::is_none")]
pub groups_added: Option<&'a WatchItems>,
#[serde(skip_serializing_if = "Option::is_none")]
pub groups_removed: Option<&'a WatchItems>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub channels_added: Vec<&'a WatchGroupItems>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub channels_removed: Vec<&'a WatchGroupItems>,
}
/// One recording's lifecycle transition.
///
/// `event` says which transition: `MsgKind::Recording{Started,Completed,Failed}`.
+8 -9
View File
@@ -28,7 +28,6 @@ mod pagination;
mod playlist;
mod playlist_categories;
mod playlist_document;
mod playlist_groups_changed;
mod playlist_info_document;
mod playlist_request;
mod playlist_update_run;
@@ -82,14 +81,14 @@ pub use self::{
custom_video_stream_type::*, epg::*, epg_grid::*, epg_request::*, event::*, input_refresh_policy::*,
input_update_action::*, ip_check::*, item_field::*, library_request::*, log::*, mapping::*, media_properties::*,
messaging::*, metadata_update_failure::*, notification::*, notification_dead_letter::*, pagination::*, playlist::*,
playlist_categories::*, playlist_groups_changed::*, 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::*,
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::*;
@@ -143,4 +143,5 @@ pub const LEGACY_ALIASES: &[(&str, EventId)] = &[
("recordingcompleted", registry::RECORDING_COMPLETED),
("recording_failed", registry::RECORDING_FAILED),
("recordingfailed", registry::RECORDING_FAILED),
("playlist.groups.changed", registry::PLAYLIST_WATCH_CHANGED),
];
+1 -7
View File
@@ -16,7 +16,6 @@ pub const SYSTEM_SHUTDOWN: EventId = EventId::new("system.shutdown");
pub const PLAYLIST_UPDATE_COMPLETED: EventId = EventId::new("playlist.update.completed");
pub const PLAYLIST_UPDATE_FAILED: EventId = EventId::new("playlist.update.failed");
pub const PLAYLIST_WATCH_CHANGED: EventId = EventId::new("playlist.watch.changed");
pub const PLAYLIST_GROUPS_CHANGED: EventId = EventId::new("playlist.groups.changed");
pub const PLAYLIST_WATCH_DISABLED: EventId = EventId::new("playlist.watch.disabled");
pub const PLAYLIST_WATCH_UNMATCHED: EventId = EventId::new("playlist.watch.unmatched");
@@ -113,12 +112,7 @@ pub const ALL: &[EventDescriptor] = &[
EventDescriptor {
id: PLAYLIST_WATCH_CHANGED,
severity: Severity::Info,
description: "Channels were added to or removed from a watched group.",
},
EventDescriptor {
id: PLAYLIST_GROUPS_CHANGED,
severity: Severity::Info,
description: "Groups were added to or removed from a target.",
description: "Channels or groups were added to or removed from a watched target.",
},
EventDescriptor {
id: PLAYLIST_WATCH_DISABLED,
+5
View File
@@ -206,3 +206,8 @@ fn registry_has_no_duplicate_ids() {
assert!(seen.insert(descriptor.id), "duplicate registry entry for {}", descriptor.id);
}
}
#[test]
fn groups_changed_alias_resolves_for_ids() {
assert_eq!(EventId::from_wire("playlist.groups.changed"), Some(registry::PLAYLIST_WATCH_CHANGED));
}
@@ -1,37 +0,0 @@
use serde::{Deserialize, Serialize};
/// Groups that appeared in or vanished from a target between refreshes.
///
/// The `watch` feature tracks channels *inside* named groups, and is blind to
/// the group set itself in both directions. A group appearing is silent:
/// `process_group_watch` finds no baseline file, writes one, and emits
/// nothing, so the group's entire channel list reads as "not new" from then
/// on. A group vanishing is worse - it is absent from the refreshed playlist,
/// so nothing iterates it and no code path observes the disappearance at all.
///
/// Sampled the same way [`WatchChanges`](crate::model::WatchChanges) is: the
/// lists carry group titles only, and the counts stay true whatever the lists
/// hold.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlaylistGroupsChanged {
pub target: String,
/// Group titles that were not in the previous refresh.
pub added: Vec<String>,
/// Group titles that were in the previous refresh and are gone now.
pub removed: Vec<String>,
#[serde(default)]
pub added_total: usize,
#[serde(default)]
pub removed_total: usize,
/// Whether the lists are a sample rather than the whole change.
#[serde(default)]
pub truncated: bool,
}
impl PlaylistGroupsChanged {
/// A complete change set, nothing sampled.
#[must_use]
pub fn new(target: String, added: Vec<String>, removed: Vec<String>) -> Self {
Self { target, added_total: added.len(), removed_total: removed.len(), added, removed, truncated: false }
}
}