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
+249 -186
View File
@@ -1,10 +1,11 @@
use log::error;
use shared::model::{
EventMessage, EventSink, PlaylistGroup, PlaylistGroupsChanged, WatchChanges, WatchDisabled, WatchDisabledReason,
EventMessage, EventSink, PlaylistGroup, WatchChange, WatchChanges, WatchContext, WatchDisabled,
WatchDisabledReason, WatchGroupItems, WatchItems,
};
use std::{collections::BTreeSet, path::Path, sync::Arc};
use tuliprox_core::{
model::AppConfig,
model::ConfigWatch,
utils,
utils::{binary_deserialize, binary_serialize, file_exists_async},
};
@@ -12,45 +13,79 @@ use tuliprox_core::{
const WATCH_NOTIFICATION_LIST_LIMIT: usize = 120;
const WATCH_NOTIFICATION_SUMMARY_THRESHOLD: usize = 500;
/// Track which *groups* a target has, as opposed to which channels a group has.
/// Groups that appeared or vanished, kept only where a `group_added` /
/// `group_removed` entry matches.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct GroupChanges {
pub added: Option<WatchItems>,
pub removed: Option<WatchItems>,
}
/// One watched group's channel delta. An unchanged group yields no diff at
/// all, so at least one side is always set.
#[derive(Debug, PartialEq, Eq)]
pub struct GroupChannelDiff {
pub group: Arc<str>,
pub added: Option<WatchItems>,
pub removed: Option<WatchItems>,
}
/// Sample sorted titles: the list is capped, the total is not.
///
/// `process_group_watch` below is blind to the group set in both directions.
/// A group appearing is silent - it finds no baseline, writes one and emits
/// nothing, so the whole channel list reads as "not new" from then on. A
/// group vanishing is never even looked at: it is absent from the refreshed
/// playlist, so nothing iterates it.
/// Takes an iterator so a set difference is sampled as it is walked, without
/// collecting it first.
fn sample<'a>(titles: impl Iterator<Item = &'a Arc<str>>) -> WatchItems {
let mut items = Vec::new();
let mut total = 0usize;
for title in titles {
if items.len() < WATCH_NOTIFICATION_LIST_LIMIT {
items.push(Arc::clone(title));
}
total += 1;
}
WatchItems { truncated: items.len() < total, items, total }
}
fn sample_non_empty<'a>(titles: impl Iterator<Item = &'a Arc<str>>) -> Option<WatchItems> {
let items = sample(titles);
(items.total > 0).then_some(items)
}
/// Diff the target's group titles against the persisted index.
///
/// The index lives beside the per-group directory rather than inside it
/// The index holds *every* group, whatever the watch entries say, so editing
/// a pattern never reads as a change and a newly added `group_added` entry
/// already has a baseline. It lives beside the per-group directory
/// (`<target>.groups.bin`, not `<target>/__groups.bin`) so it cannot collide
/// with a group whose sanitized title happens to match.
pub async fn process_target_groups_watch<E: EventSink>(
app_config: &Arc<AppConfig>,
/// with a group whose sanitized title matches.
///
/// First sight writes the baseline and reports nothing: announcing every
/// group as new on the first refresh would be noise, not news. An unreadable
/// index is reported and replaced - a corrupt file never becomes readable
/// again, and keeping it would switch group tracking off for good.
pub async fn diff_target_groups<E: EventSink>(
storage_dir: &str,
events: &E,
target_name: &str,
playlist: &[PlaylistGroup],
) {
let new_tree: BTreeSet<Arc<str>> = playlist.iter().map(|group| group.title.clone()).collect();
watches: &[ConfigWatch],
) -> GroupChanges {
let new_tree: BTreeSet<Arc<str>> = playlist.iter().map(|group| Arc::clone(&group.title)).collect();
let index_filename = format!("{}.groups.bin", utils::sanitize_filename(target_name));
let cfg = app_config.config.load();
let Some(path) = utils::get_file_path(&cfg.storage_dir, Some(std::path::PathBuf::from(&index_filename))) else {
let Some(path) = utils::get_file_path(storage_dir, Some(std::path::PathBuf::from(&index_filename))) else {
error!("failed to resolve a path for group index {index_filename}");
emit_watch_disabled(events, target_name, "", format!("could not resolve a path for {index_filename}"));
return;
return GroupChanges::default();
};
// First sight writes the baseline and says nothing. Announcing every
// group as new on the first refresh after an upgrade would be noise, not
// news.
let mut reported = GroupChanges::default();
let mut changed = true;
if file_exists_async(&path).await {
if let Some(loaded_tree) = load_watch_tree(&path).await {
let added: BTreeSet<Arc<str>> = new_tree.difference(&loaded_tree).cloned().collect();
let removed: BTreeSet<Arc<str>> = loaded_tree.difference(&new_tree).cloned().collect();
if added.is_empty() && removed.is_empty() {
changed = false;
} else {
emit_groups_changed(events, target_name, &added, &removed);
changed = new_tree != loaded_tree;
if changed {
reported = group_changes(&new_tree, &loaded_tree, watches);
}
} else {
error!("failed to load group index {}", path.to_str().unwrap_or_default());
@@ -74,55 +109,97 @@ pub async fn process_target_groups_watch<E: EventSink>(
);
}
}
reported
}
/// Same sampling discipline as the per-group watch: titles only in the lists,
/// counts that stay true whatever the lists carry.
fn emit_groups_changed<E: EventSink>(
events: &E,
/// The groups in `current` but not `previous` that a `group_added` entry
/// matches, and the reverse for `group_removed`.
///
/// The set differences are filtered and sampled while they are walked, so no
/// intermediate set is built, and a direction no entry asks for is never
/// walked at all.
pub fn group_changes(
current: &BTreeSet<Arc<str>>,
previous: &BTreeSet<Arc<str>>,
watches: &[ConfigWatch],
) -> GroupChanges {
let kept = |from: &BTreeSet<Arc<str>>, without: &BTreeSet<Arc<str>>, context: WatchContext| {
if !watches.iter().any(|watch| watch.contexts.contains(context)) {
return None;
}
sample_non_empty(from.difference(without).filter(|title| {
watches.iter().any(|watch| watch.contexts.contains(context) && watch.pattern.is_match(title))
}))
};
GroupChanges {
added: kept(current, previous, WatchContext::GroupAdded),
removed: kept(previous, current, WatchContext::GroupRemoved),
}
}
/// Turn one refresh's group and channel changes into a single event payload.
///
/// Order: group additions, group removals, then channel changes by group
/// title (added before removed), so notifications and tests do not depend on
/// which concurrent group diff finished first. Above the summary threshold,
/// summed over every group, channel lists are dropped and only counts remain:
/// nobody reads thousands of titles, and channels such as Telegram reject a
/// body that large outright.
pub fn assemble_watch_changes(
target_name: &str,
added: &BTreeSet<Arc<str>>,
removed: &BTreeSet<Arc<str>>,
) {
let added_total = added.len();
let removed_total = removed.len();
let mut added: Vec<String> = added.iter().map(std::string::ToString::to_string).collect();
let mut removed: Vec<String> = removed.iter().map(std::string::ToString::to_string).collect();
let mut truncated = false;
for list in [&mut added, &mut removed] {
if list.len() > WATCH_NOTIFICATION_LIST_LIMIT {
list.truncate(WATCH_NOTIFICATION_LIST_LIMIT);
truncated = true;
groups: GroupChanges,
mut channel_diffs: Vec<GroupChannelDiff>,
) -> Option<WatchChanges> {
channel_diffs.sort_unstable_by(|a, b| a.group.cmp(&b.group));
let channel_total: usize = channel_diffs
.iter()
.flat_map(|diff| diff.added.iter().chain(diff.removed.iter()))
.map(|items| items.total)
.sum();
let counts_only = channel_total > WATCH_NOTIFICATION_SUMMARY_THRESHOLD;
let mut changes = Vec::with_capacity(2 + channel_diffs.len() * 2);
changes.extend(groups.added.map(WatchChange::GroupsAdded));
changes.extend(groups.removed.map(WatchChange::GroupsRemoved));
let listed = |mut items: WatchItems| {
if counts_only {
items.items = Vec::new();
items.truncated = true;
}
items
};
for GroupChannelDiff { group, added, removed } in channel_diffs {
if let Some(items) = added {
changes
.push(WatchChange::ChannelsAdded(WatchGroupItems { group: Arc::clone(&group), items: listed(items) }));
}
if let Some(items) = removed {
changes.push(WatchChange::ChannelsRemoved(WatchGroupItems { group, items: listed(items) }));
}
}
events.emit(EventMessage::PlaylistGroupsChanged(PlaylistGroupsChanged {
target: target_name.to_string(),
added,
removed,
added_total,
removed_total,
truncated,
}));
(!changes.is_empty()).then(|| WatchChanges::new(target_name.to_string(), changes))
}
/// Diff one watched group's channels against its baseline.
pub async fn process_group_watch<E: EventSink>(
app_config: &Arc<AppConfig>,
storage_dir: &str,
events: &E,
target_name: &str,
pl: &PlaylistGroup,
) {
let mut new_tree = BTreeSet::new();
pl.channels.iter().for_each(|chan| {
let header = &chan.header;
let title = if header.title.is_empty() { header.name.clone() } else { header.title.clone() };
new_tree.insert(title);
});
) -> Option<GroupChannelDiff> {
let new_tree: BTreeSet<Arc<str>> = pl
.channels
.iter()
.map(|chan| {
let header = &chan.header;
Arc::clone(if header.title.is_empty() { &header.name } else { &header.title })
})
.collect();
let watch_filename =
format!("{}/{}.bin", utils::sanitize_filename(target_name), utils::sanitize_filename(&pl.title));
let cfg = app_config.config.load();
let Some(path) = utils::get_file_path(&cfg.storage_dir, Some(std::path::PathBuf::from(&watch_filename))) else {
let Some(path) = utils::get_file_path(storage_dir, Some(std::path::PathBuf::from(&watch_filename))) else {
error!("failed to write watch_file {watch_filename}");
emit_watch_disabled(
events,
@@ -130,19 +207,18 @@ pub async fn process_group_watch<E: EventSink>(
&pl.title,
format!("could not resolve a storage path for {watch_filename}"),
);
return;
return None;
};
let save_path = path.as_path();
let mut diff = None;
let mut changed = true;
if file_exists_async(&path).await {
if let Some(loaded_tree) = load_watch_tree(&path).await {
// Find elements in set2 but not in set1
let added_difference: BTreeSet<Arc<str>> = new_tree.difference(&loaded_tree).cloned().collect();
let removed_difference: BTreeSet<Arc<str>> = loaded_tree.difference(&new_tree).cloned().collect();
changed = !added_difference.is_empty() || !removed_difference.is_empty();
let added = sample_non_empty(new_tree.difference(&loaded_tree));
let removed = sample_non_empty(loaded_tree.difference(&new_tree));
changed = added.is_some() || removed.is_some();
if changed {
handle_watch_notification(events, &added_difference, &removed_difference, target_name, &pl.title);
diff = Some(GroupChannelDiff { group: Arc::clone(&pl.title), added, removed });
}
} else {
error!("failed to load watch_file {}", path.to_str().unwrap_or_default());
@@ -159,16 +235,17 @@ pub async fn process_group_watch<E: EventSink>(
}
if changed {
if let Err(err) = save_watch_tree(save_path, &new_tree).await {
error!("failed to write watch_file {}: {err}", save_path.to_str().unwrap_or_default());
if let Err(err) = save_watch_tree(path.as_path(), &new_tree).await {
error!("failed to write watch_file {}: {err}", path.to_str().unwrap_or_default());
emit_watch_disabled(
events,
target_name,
&pl.title,
format!("could not write watch state at {}: {err}", save_path.to_str().unwrap_or_default()),
format!("could not write watch state at {}: {err}", path.to_str().unwrap_or_default()),
);
}
}
diff
}
/// Report that a group's watch state could not be read or written.
@@ -187,56 +264,6 @@ fn emit_watch_disabled<E: EventSink>(events: &E, target_name: &str, group_name:
events.emit(EventMessage::PlaylistWatchDisabled(disabled));
}
/// Turn a group's membership delta into an event.
///
/// Large changes are sampled rather than listed in full, but the sampling is
/// reported in [`WatchChanges::added_total`] / [`WatchChanges::removed_total`]
/// and [`WatchChanges::truncated`] rather than written into the lists as prose.
fn handle_watch_notification<E: EventSink>(
events: &E,
added: &BTreeSet<Arc<str>>,
removed: &BTreeSet<Arc<str>>,
target_name: &str,
group_name: &str,
) {
let added_total = added.len();
let removed_total = removed.len();
let total_changed = added_total.saturating_add(removed_total);
if total_changed == 0 {
return;
}
let mut added: Vec<String> = added.iter().map(std::string::ToString::to_string).collect();
let mut removed: Vec<String> = removed.iter().map(std::string::ToString::to_string).collect();
let mut truncated = false;
if total_changed > WATCH_NOTIFICATION_SUMMARY_THRESHOLD {
// A whole-provider reshuffle. Nobody reads ten thousand titles, and
// every channel routing this pays for the bytes, so send the counts
// alone.
added.clear();
removed.clear();
truncated = true;
} else {
for list in [&mut added, &mut removed] {
if list.len() > WATCH_NOTIFICATION_LIST_LIMIT {
list.truncate(WATCH_NOTIFICATION_LIST_LIMIT);
truncated = true;
}
}
}
events.emit(EventMessage::PlaylistWatchChanged(WatchChanges {
target: target_name.to_string(),
group: group_name.to_string(),
added,
removed,
added_total,
removed_total,
truncated,
}));
}
async fn load_watch_tree(path: &Path) -> Option<BTreeSet<Arc<str>>> {
let encoded = tokio::fs::read(path).await.ok()?;
binary_deserialize(&encoded[..]).ok()
@@ -253,79 +280,115 @@ async fn save_watch_tree(path: &Path, tree: &BTreeSet<Arc<str>>) -> std::io::Res
#[cfg(test)]
mod tests {
use super::{handle_watch_notification, WATCH_NOTIFICATION_LIST_LIMIT, WATCH_NOTIFICATION_SUMMARY_THRESHOLD};
use shared::model::{EventMessage, EventSink, WatchChanges};
use std::{
collections::BTreeSet,
sync::{Arc, Mutex},
use super::{
assemble_watch_changes, group_changes, sample, GroupChanges, GroupChannelDiff, WATCH_NOTIFICATION_LIST_LIMIT,
};
use shared::model::{ConfigWatchDto, WatchChange, WatchContext, WatchItems};
use std::{collections::BTreeSet, sync::Arc};
use tuliprox_core::model::ConfigWatch;
#[derive(Default)]
struct CollectSink(Mutex<Vec<WatchChanges>>);
fn set(names: &[&str]) -> BTreeSet<Arc<str>> { names.iter().map(|n| Arc::from(*n)).collect() }
impl EventSink for CollectSink {
fn emit(&self, event: EventMessage) {
if let EventMessage::PlaylistWatchChanged(changes) = event {
self.0.lock().unwrap().push(changes);
fn titles(count: usize) -> BTreeSet<Arc<str>> {
// Zero-padded so the `BTreeSet` order matches the numeric order.
(0..count).map(|i| Arc::from(format!("Channel {i:05}").as_str())).collect()
}
fn watch(pattern: &str, contexts: &[WatchContext]) -> ConfigWatch {
ConfigWatch {
pattern: Arc::new(regex::Regex::new(pattern).expect("regex")),
contexts: ConfigWatchDto::new(pattern, contexts.iter().copied()).on,
}
}
fn diff(group: &str, added: usize, removed: usize) -> GroupChannelDiff {
GroupChannelDiff {
group: Arc::from(group),
added: (added > 0).then(|| sample(titles(added).iter())),
removed: (removed > 0).then(|| sample(titles(removed).iter())),
}
}
#[test]
fn a_small_list_is_complete() {
let items = sample(titles(3).iter());
assert_eq!((items.items.len(), items.total, items.truncated), (3, 3, false));
}
/// The lists used to carry a synthesised "... N more entries omitted"
/// string beside real channel titles, which a plugin reading the JSON
/// payload could not tell from a channel actually named that.
#[test]
fn a_long_list_keeps_titles_only_and_the_true_total() {
let items = sample(titles(WATCH_NOTIFICATION_LIST_LIMIT + 40).iter());
assert_eq!(items.items.len(), WATCH_NOTIFICATION_LIST_LIMIT);
assert_eq!(items.total, WATCH_NOTIFICATION_LIST_LIMIT + 40);
assert!(items.truncated);
assert!(items.items.iter().all(|t| t.starts_with("Channel ")));
}
#[test]
fn group_changes_are_filtered_per_context() {
let previous = set(&["DE Kids", "Event B", "Stays"]);
let current = set(&["Event A", "News 2", "Stays"]);
let watches = [watch("^Event", &[WatchContext::GroupAdded]), watch(".*", &[WatchContext::GroupRemoved])];
let changes = group_changes(&current, &previous, &watches);
assert_eq!(changes.added.map(|i| i.items), Some(vec![Arc::from("Event A")]));
assert_eq!(changes.removed.map(|i| i.items), Some(vec![Arc::from("DE Kids"), Arc::from("Event B")]));
}
#[test]
fn channel_only_watches_report_no_group_changes() {
let changes = group_changes(&set(&["X"]), &set(&["Y"]), &[watch(".*", &[WatchContext::Channels])]);
assert_eq!(changes, GroupChanges::default());
}
#[test]
fn nothing_changed_assembles_nothing() {
assert!(assemble_watch_changes("t", GroupChanges::default(), Vec::new()).is_none());
}
#[test]
fn changes_are_ordered_groups_first_then_channels_by_group() {
let groups =
GroupChanges { added: Some(sample(set(&["New"]).iter())), removed: Some(sample(set(&["Old"]).iter())) };
let changes = assemble_watch_changes("t", groups, vec![diff("B", 1, 1), diff("A", 1, 0)]).expect("changes");
let kinds: Vec<String> = changes
.changes
.iter()
.map(|c| match c {
WatchChange::GroupsAdded(_) => "ga".to_string(),
WatchChange::GroupsRemoved(_) => "gr".to_string(),
WatchChange::ChannelsAdded(g) => format!("ca:{}", g.group),
WatchChange::ChannelsRemoved(g) => format!("cr:{}", g.group),
})
.collect();
assert_eq!(kinds, ["ga", "gr", "ca:A", "ca:B", "cr:B"]);
}
#[test]
fn a_large_aggregate_reports_counts_only_for_channels() {
// 20 groups x 30 changes = 600 > WATCH_NOTIFICATION_SUMMARY_THRESHOLD.
let diffs = (0..20).map(|i| diff(&format!("G{i:02}"), 30, 0)).collect();
let groups = GroupChanges { added: Some(sample(set(&["New"]).iter())), removed: None };
let changes = assemble_watch_changes("t", groups, diffs).expect("changes");
for change in &changes.changes {
match change {
WatchChange::ChannelsAdded(g) => {
assert!(g.items.items.is_empty() && g.items.truncated && g.items.total == 30);
}
WatchChange::GroupsAdded(items) => assert_eq!(items, &WatchItems::complete(vec![Arc::from("New")])),
other => panic!("unexpected {other:?}"),
}
}
}
fn titles(count: usize) -> BTreeSet<Arc<str>> {
// Zero-padded so the `BTreeSet` order matches the numeric order, which
// keeps the truncation assertions readable.
(0..count).map(|i| Arc::from(format!("Channel {i:05}").as_str())).collect()
}
fn emit(added: usize, removed: usize) -> WatchChanges {
let sink = CollectSink::default();
handle_watch_notification(&sink, &titles(added), &titles(removed), "target", "group");
let mut captured = sink.0.lock().unwrap();
assert_eq!(captured.len(), 1, "expected exactly one event");
captured.pop().unwrap()
}
#[test]
fn an_unchanged_group_emits_nothing() {
let sink = CollectSink::default();
handle_watch_notification(&sink, &BTreeSet::new(), &BTreeSet::new(), "target", "group");
assert!(sink.0.lock().unwrap().is_empty());
}
#[test]
fn a_small_change_lists_every_title() {
let changes = emit(3, 2);
assert_eq!(changes.added.len(), 3);
assert_eq!(changes.removed.len(), 2);
assert_eq!(changes.added_total, 3);
assert_eq!(changes.removed_total, 2);
assert!(!changes.truncated);
}
/// The regression this whole field set exists for: the lists used to carry
/// a synthesised "... N more entries omitted" string beside real channel
/// titles, which a plugin reading the JSON payload could not distinguish
/// from a channel actually named that.
#[test]
fn a_truncated_list_carries_only_channel_titles() {
let added = WATCH_NOTIFICATION_LIST_LIMIT + 40;
let changes = emit(added, 0);
assert_eq!(changes.added.len(), WATCH_NOTIFICATION_LIST_LIMIT);
assert_eq!(changes.added_total, added, "the total must survive the truncation");
assert!(changes.truncated);
for title in &changes.added {
assert!(title.starts_with("Channel "), "list carries prose, not a channel title: {title}");
}
}
#[test]
fn a_very_large_change_reports_counts_only() {
let added = WATCH_NOTIFICATION_SUMMARY_THRESHOLD + 100;
let changes = emit(added, 0);
assert!(changes.added.is_empty(), "a change this size should not list titles at all");
assert_eq!(changes.added_total, added);
assert!(changes.truncated);
fn a_small_aggregate_keeps_channel_lists() {
let changes = assemble_watch_changes("t", GroupChanges::default(), vec![diff("G", 3, 2)]).expect("changes");
assert!(changes.changes.iter().all(|c| match c {
WatchChange::ChannelsAdded(g) | WatchChange::ChannelsRemoved(g) => !g.items.truncated,
_ => false,
}));
}
}
@@ -4,7 +4,7 @@ use crate::{
input_cache,
metadata_sink::{MetadataUpdateSink, NoopMetadataSink},
parser::xmltv::{flatten_tvguide, merge_epg_trees, EpgMergeAccumulator},
playlist_watch::{process_group_watch, process_target_groups_watch},
playlist_watch::{assemble_watch_changes, diff_target_groups, process_group_watch},
processor::{
epg::{
adopt_guide_live_epg_id_case, clear_invalid_live_epg_ids, process_playlist_epg,
@@ -29,8 +29,8 @@ use shared::{
InputRefreshOverride, InputRefreshPolicy, InputStats, InputType, InputUpdateAction, InputUpdateRequest,
MappingStage, PipelineStats, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemType,
PlaylistUpdateInputTelemetry, PlaylistUpdateProgressEvent, PlaylistUpdateRunId, PlaylistUpdateRunOrder,
PlaylistUpdateState, PlaylistUpdateSummary, StreamProperties, TargetStats, UUIDType, WatchDisabled,
WatchDisabledReason, WatchUnmatched, XtreamCluster,
PlaylistUpdateState, PlaylistUpdateSummary, StreamProperties, TargetStats, UUIDType, WatchContext,
WatchDisabled, WatchDisabledReason, WatchUnmatched, XtreamCluster,
},
utils::{create_alias_uuid, interner_gc, Internable},
};
@@ -1105,34 +1105,48 @@ pub(crate) async fn process_watch<E: EventSink>(
return false;
}
// One snapshot for the whole refresh rather than a config load per group.
let config = app_config.config.load_full();
let storage_dir = config.storage_dir.as_str();
// Before the per-group fan-out: this is about which groups exist, not
// what is inside the ones the patterns name, so it must see every group
// rather than only the watched ones.
process_target_groups_watch(app_config, events, &target.name, new_playlist).await;
let group_changes = diff_target_groups(storage_dir, events, &target.name, new_playlist, watches).await;
// Only `channels` entries are matched against the current groups: they
// pick the groups to diff, and they are the only ones the unmatched
// warning reports on (`ConfigWatch::reports_unmatched`). Once a group is
// watched, an entry that already matched elsewhere needs no regex run.
let mut matched = vec![false; watches.len()];
let mut watched_groups = Vec::new();
let mut group_watches = Vec::new();
for group in new_playlist {
let mut any = false;
for (index, pattern) in watches.iter().enumerate() {
if pattern.is_match(&group.title) {
let mut watch_channels = false;
for (index, watch) in watches.iter().enumerate() {
if !watch.contexts.contains(WatchContext::Channels) || (watch_channels && matched[index]) {
continue;
}
if watch.pattern.is_match(&group.title) {
matched[index] = true;
any = true;
watch_channels = true;
}
}
if any {
watched_groups.push(group);
if watch_channels {
// Futures built up front: a lazy `map` closure over borrowed state
// trips the higher-ranked `Send` check of the spawned pipeline task.
group_watches.push(process_group_watch(storage_dir, events, &target.name, group));
}
}
// A pattern that matches nothing looks exactly like a group that has not
// changed. `EventKindMask::from_wire_names` already reports unmatched
// subscription names for the same reason: a typo must surface.
// A channel pattern that matches nothing looks exactly like a group that
// has not changed, so a typo must surface. Entries waiting for a group to
// appear, and removal-only entries, match nothing by design.
// `EventKindMask::from_wire_names` reports unmatched names for the same reason.
let unmatched: Vec<String> = watches
.iter()
.enumerate()
.filter(|(index, _)| !matched[*index])
.map(|(_, pattern)| pattern.as_str().to_string())
.zip(&matched)
.filter(|(watch, matched)| !**matched && watch.reports_unmatched())
.map(|(watch, _)| watch.pattern.as_str().to_string())
.collect();
if !unmatched.is_empty() {
warn!("target '{}' has {} watch pattern(s) matching no group", target.name, unmatched.len());
@@ -1143,11 +1157,12 @@ pub(crate) async fn process_watch<E: EventSink>(
)));
}
futures::stream::iter(
watched_groups.into_iter().map(|pl| process_group_watch(app_config, events, &target.name, pl)),
)
.for_each_concurrent(16, |f| f)
.await;
let channel_diffs: Vec<_> =
futures::stream::iter(group_watches).buffer_unordered(16).filter_map(futures::future::ready).collect().await;
if let Some(changes) = assemble_watch_changes(&target.name, group_changes, channel_diffs) {
events.emit(EventMessage::PlaylistWatchChanged(changes));
}
true
}
@@ -22,6 +22,8 @@ mod mapping_stage;
mod curation_effect_gate;
mod watch_contexts;
#[cfg(test)]
mod quality_rejection_fallback;
@@ -181,7 +181,7 @@ async fn assert_curation_preserves_empty_mixed_output_guard(canonical: Option<sh
..M3uTargetOutputDto::default()
}),
],
watch: Some(vec![".*".to_string()]),
watch: Some(vec![shared::model::ConfigWatchDto::channels(".*")]),
use_memory_cache: true,
..ConfigTargetDto::default()
});
@@ -282,7 +282,7 @@ async fn complete_empty_curation_publishes_empty_watch_group_state() {
}),
..XtreamTargetOutputDto::default()
})],
watch: Some(vec![".*".to_string()]),
watch: Some(vec![shared::model::ConfigWatchDto::channels(".*")]),
..ConfigTargetDto::default()
});
let mut seeded = vec![PlaylistGroup {
@@ -0,0 +1,127 @@
// `use super::*` brings `process_watch` into scope the same way `curation_effect_gate.rs` gets it.
use super::{curation_effect_gate::app_config, support::PlaylistRunCollectSink, *};
use shared::model::{ConfigTargetDto, ConfigWatchDto, WatchChange, WatchContext};
use tempfile::tempdir;
fn group(title: &str, channels: &[&str]) -> PlaylistGroup {
PlaylistGroup {
id: 1,
title: title.intern(),
xtream_cluster: XtreamCluster::Live,
channels: channels
.iter()
.map(|name| PlaylistItem {
header: PlaylistItemHeader { name: name.intern(), title: name.intern(), ..Default::default() },
})
.collect(),
}
}
fn target(watch: Vec<ConfigWatchDto>) -> ConfigTarget {
ConfigTarget::from(&ConfigTargetDto { name: "sports".to_string(), watch: Some(watch), ..Default::default() })
}
fn watch_events(sink: &PlaylistRunCollectSink) -> Vec<EventMessage> {
sink.0
.lock()
.unwrap()
.drain(..)
.filter(|e| {
matches!(
e,
EventMessage::PlaylistWatchChanged(_)
| EventMessage::PlaylistWatchUnmatched(_)
| EventMessage::PlaylistWatchDisabled(_)
)
})
.collect()
}
#[tokio::test]
async fn first_run_is_silent_then_one_event_carries_groups_and_channels() {
let dir = tempdir().expect("tempdir");
let config = app_config(dir.path());
let target = target(vec![
ConfigWatchDto::channels("^DE Sport"),
ConfigWatchDto::new("^Event", [WatchContext::GroupAdded]),
ConfigWatchDto::new(".*", [WatchContext::GroupRemoved]),
]);
let sink = PlaylistRunCollectSink::default();
let first = vec![group("DE Sport HD", &["Sky 1"]), group("DE Kids", &["KiKa"])];
assert!(process_watch(&config, &sink, &target, &first).await);
assert!(watch_events(&sink).is_empty(), "first run must only write baselines");
let second =
vec![group("DE Sport HD", &["Sky 1", "Sky 5"]), group("Event Bundesliga", &["E1"]), group("News 2", &[])];
assert!(process_watch(&config, &sink, &target, &second).await);
let events = watch_events(&sink);
assert_eq!(events.len(), 1, "{events:?}");
let EventMessage::PlaylistWatchChanged(changes) = &events[0] else { panic!("{events:?}") };
assert_eq!(changes.target, "sports");
assert!(
matches!(&changes.changes[0], WatchChange::GroupsAdded(i) if i.items == vec![Arc::from("Event Bundesliga")])
);
assert!(matches!(&changes.changes[1], WatchChange::GroupsRemoved(i) if i.items == vec![Arc::from("DE Kids")]));
assert!(matches!(&changes.changes[2], WatchChange::ChannelsAdded(g)
if g.group.as_ref() == "DE Sport HD" && g.items.items == vec![Arc::from("Sky 5")]));
assert_eq!(changes.changes.len(), 3, "News 2 matches no group_added entry");
}
#[tokio::test]
async fn channel_only_watch_reports_no_group_changes() {
let dir = tempdir().expect("tempdir");
let config = app_config(dir.path());
let target = target(vec![ConfigWatchDto::channels(".*")]);
let sink = PlaylistRunCollectSink::default();
assert!(process_watch(&config, &sink, &target, &[group("A", &["a"])]).await);
assert!(process_watch(&config, &sink, &target, &[group("A", &["a"]), group("B", &["b"])]).await);
assert!(watch_events(&sink).is_empty(), "a new group is only a new channel baseline here");
}
#[tokio::test]
async fn unmatched_warning_only_for_channel_entries_without_group_added() {
let dir = tempdir().expect("tempdir");
let config = app_config(dir.path());
let target = target(vec![
ConfigWatchDto::channels("^Nope1"),
ConfigWatchDto::new("^Nope2", [WatchContext::Channels, WatchContext::GroupRemoved]),
ConfigWatchDto::new("^Nope3", [WatchContext::Channels, WatchContext::GroupAdded]),
ConfigWatchDto::new("^Nope4", [WatchContext::GroupAdded]),
ConfigWatchDto::new("^Nope5", [WatchContext::GroupRemoved]),
]);
let sink = PlaylistRunCollectSink::default();
assert!(process_watch(&config, &sink, &target, &[group("A", &["a"])]).await);
let events = watch_events(&sink);
let EventMessage::PlaylistWatchUnmatched(unmatched) = &events[0] else { panic!("{events:?}") };
assert_eq!(unmatched.patterns, vec!["^Nope1".to_string(), "^Nope2".to_string()]);
}
#[tokio::test]
async fn corrupt_group_index_still_reports_channel_changes() {
let dir = tempdir().expect("tempdir");
let config = app_config(dir.path());
let target = target(vec![ConfigWatchDto::new(".*", [WatchContext::Channels, WatchContext::GroupAdded])]);
let sink = PlaylistRunCollectSink::default();
assert!(process_watch(&config, &sink, &target, &[group("A", &["a"])]).await);
std::fs::write(dir.path().join("sports.groups.bin"), [0xff_u8; 3]).expect("corrupt index");
assert!(process_watch(&config, &sink, &target, &[group("A", &["a", "b"]), group("B", &["x"])]).await);
let events = watch_events(&sink);
assert!(events.iter().any(|e| matches!(e, EventMessage::PlaylistWatchDisabled(_))), "{events:?}");
let changes = events
.iter()
.find_map(|e| if let EventMessage::PlaylistWatchChanged(c) = e { Some(c) } else { None })
.expect("channel change still reported");
assert!(changes.changes.iter().all(|c| matches!(c, WatchChange::ChannelsAdded(_))), "no group change: {changes:?}");
// The index was rewritten, so the next refresh diffs normally.
let third = [group("A", &["a", "b"]), group("B", &["x"]), group("C", &[])];
assert!(process_watch(&config, &sink, &target, &third).await);
let events = watch_events(&sink);
assert!(
events.iter().any(|e| matches!(e, EventMessage::PlaylistWatchChanged(c)
if matches!(&c.changes[0], WatchChange::GroupsAdded(i) if i.items == vec![Arc::from("C")]))),
"{events:?}"
);
}