mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-18 22:03:01 +02:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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}`.
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user