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
+23 -10
View File
@@ -4,6 +4,17 @@
## ⚠️ Breaking Changes
- **`playlist.watch.changed` carries every watch change of one update.** The payload is now
`{ target, changes: [...] }`, where each change has a `kind` (`groups_added`, `groups_removed`, `channels_added`,
`channels_removed`). Templates using `{{watch.group}}`, `{{watch.added}}` or `{{watch.removed}}` must be updated:
```handlebars
{{!-- before --}}
{{watch.group}}: {{#each watch.added}}{{this}} {{/each}}
{{!-- after --}}
{{#each watch.channels_added}}{{group}}: {{#each items}}{{this}} {{/each}}{{/each}}
```
- **`clear_invalid_epg_ids` moved to `epg_output.clear_invalid_ids`.** The target option now sits next to the other
EPG id output options. The top-level `options.clear_invalid_epg_ids` and its legacy alias `options.required_epg` are
rejected while loading. To migrate, move the value:
@@ -317,16 +328,12 @@
layer to stop discarding the distinction: an empty `Some` is now load-bearing and means "configured and unusable",
which is not the same as "not configured". `playlist.watch.unmatched` covers the fourth silence — a pattern matching
no group looks exactly like a group that has not changed, so a typo in `watch` was invisible.
- **`playlist.groups.changed`**: `watch` tracked channels inside named groups and was blind to the group set itself,
in both directions. A group appearing was silent — no baseline file, so one was written, nothing was emitted, and
the group's entire channel list read as "not new" from then on — and a group vanishing was worse, since it is absent
from the refreshed playlist and no code path observed the disappearance at all. The target's group titles are now
diffed against a persisted index before the per-group fan-out, so it sees every group rather than only the ones the
watch patterns name; the question is which groups exist, not what is inside the watched ones. The index sits beside
the per-group directory (`<target>.groups.bin`, not `<target>/__groups.bin`) so it cannot collide with a group whose
sanitized title matches, and first sight writes the baseline silently — announcing every existing group as new on
the first refresh after an upgrade would be noise. Gated on `target.watch` being configured, so it costs nothing for
targets that never asked to be watched.
- **Watch group contexts**: a `watch` entry can be a map with `pattern` and `on` (`channels`, `group_added`,
`group_removed`), so groups appearing in or vanishing from a target are reported, filtered per entry. Plain string
entries keep watching channels only. The target's group titles are diffed against a persisted index
(`<target>.groups.bin`) that always holds every group, so editing a pattern never reads as a change; first sight
writes the baseline silently. Group changes arrive in `playlist.watch.changed`; the short-lived
`playlist.groups.changed` event is gone and its name is accepted as an alias.
- **`metadata.update.failed` for an input whose tasks burn through their retries**: the completion event only fires
when a cycle drains *with changes*, and an exhausted task only reached a `debug!`, so an input whose resolves fail
every time emitted a start and then nothing for as long as it stayed broken — on the bus, indistinguishable from one
@@ -1094,6 +1101,10 @@
## 🐛 Fixes
- **Long Telegram and ntfy notifications are no longer rejected.** Telegram accepts at most 4096 characters (UTF-16
units) and ntfy 4096 bytes per message; neither channel had a limit, so a large watch or statistics notification
failed and landed in the dead letter queue. Telegram now cuts the final text, after `MarkdownV2` escaping, and drops
an escape that the cut separated from its character; ntfy bodies are truncated like Pushover's.
- **Short EPG and player guides stay empty when playlist and XMLTV ids differ only in case.** A live channel whose
EPG id matches a guide channel case-insensitively (for example `usa4k.us` against `<channel id="USA4K.us">`) now
adopts the guide's spelling, so the playlist `tvg-id` / `epg_channel_id`, the XMLTV `<channel id>` and the EPG db
@@ -1732,6 +1743,8 @@
## 🛠 Maintenance
- **`ClusterFlags` is built on `create_bitset!`.** It was the last user of the `bitflags` crate, which is removed
from `shared`. The API and the persisted `[live,vod]` form are unchanged.
- **Testkit scenarios can pause and tune reconnect windows.** A step with only `pause_millis` (1 to 300000) idles the
scenario, and `policy_contract` accepts `hls_session_ttl_secs` and `provider_affinity_ttl_secs`. The new scenario
`m3u-hls-provider-affinity-after-lease-expiry` uses both to prove that a returning HLS playback stays on its
Generated
-1
View File
@@ -3799,7 +3799,6 @@ version = "3.3.147"
dependencies = [
"aes",
"base64 0.23.1",
"bitflags 2.13.1",
"blake3",
"brotli",
"bytes",
+2
View File
@@ -328,6 +328,8 @@ Notification capabilities include:
- Preview/test endpoint so templates can be rendered without sending them
- Events for provider failures, exhausted pools, priority fallback, user connection denial, auth activity, config reload
failures, metadata failures, scheduled-task failures, recording lifecycle, disk alerts, and more
- Playlist change tracking: get told when channels in the groups you care about change, or when whole groups appear
or disappear — for example a new event or PPV group — each filtered by your own group patterns
- Event statistics and a recent-event ring for troubleshooting
- Disk usage alerts with warning/critical thresholds and configurable repeat intervals
@@ -1254,7 +1254,7 @@ mod tests {
processing: Some(String::from("group ~ 'secret'")),
..Default::default()
},
watch: Some(vec![String::from("watch-expr")]),
watch: Some(vec![shared::model::ConfigWatchDto::channels("watch-expr")]),
mapping: Some(vec![String::from("mapping-name")]),
..Default::default()
}],
+5 -13
View File
@@ -516,7 +516,6 @@ fn to_protocol_message(event: EventMessage) -> Option<(ProtocolMessage, &'static
| EventMessage::DiskAlert(_)
| EventMessage::ConfigReloadFailed(_)
| EventMessage::PlaylistWatchChanged(_)
| EventMessage::PlaylistGroupsChanged(_)
| EventMessage::PlaylistWatchDisabled(_)
| EventMessage::PlaylistWatchUnmatched(_)
| EventMessage::RecordingLifecycle(_)
@@ -704,9 +703,9 @@ mod tests {
use shared::model::{
AuthAuditEvent, AuthAuditOutcome, Claims, ConfigReloadFailure, DiskAlert, DiskAlertLevel,
LibraryScanProgressEvent, LibraryScanSummary, LibraryScanSummaryStatus, MetadataUpdateFailure, MsgKind,
Permission, PlaylistGroupsChanged, PlaylistUpdateProgressEvent, PlaylistUpdateRunId, PlaylistUpdateRunOrder,
PlaylistUpdateState, PlaylistUpdateSummary, ProtocolHandler, ProtocolHandlerMemory, ProtocolMessage,
ProviderAccountEvent, ProviderAccountState, ProviderFailureKind, ProviderFetchFailure, ProviderPoolExhausted,
Permission, PlaylistUpdateProgressEvent, PlaylistUpdateRunId, PlaylistUpdateRunOrder, PlaylistUpdateState,
PlaylistUpdateSummary, ProtocolHandler, ProtocolHandlerMemory, ProtocolMessage, ProviderAccountEvent,
ProviderAccountState, ProviderFailureKind, ProviderFetchFailure, ProviderPoolExhausted,
ProviderPriorityFallback, RecordingLifecycleMessage, RoleSet, UserId, UserRole, WatchChanges, WatchDisabled,
WatchDisabledReason, WatchUnmatched, CURRENT_PERMISSION_SCHEMA_VERSION, PERM_ALL, PROTOCOL_VERSION,
TOKEN_NO_AUTH,
@@ -745,13 +744,12 @@ mod tests {
// every notification-only kind against `expected = true` and the
// test failed on `DiskAlert` - it has been red since the lifecycle
// events joined the bus.
const NOT_ON_THE_WIRE: [EventKind; 29] = [
const NOT_ON_THE_WIRE: [EventKind; 28] = [
EventKind::ConnectionDenied,
EventKind::ScheduledTaskFailed,
EventKind::ProviderFetchFailed,
EventKind::ProviderPoolExhausted,
EventKind::ProviderPriorityFallback,
EventKind::PlaylistGroupsChanged,
EventKind::PlaylistWatchDisabled,
EventKind::PlaylistWatchUnmatched,
EventKind::NotificationDeadLettered,
@@ -893,13 +891,7 @@ mod tests {
paths: "config.yml".to_string(),
error: "boom".to_string(),
}),
EventMessage::PlaylistWatchChanged(WatchChanges::new(
"t".to_string(),
"g".to_string(),
Vec::new(),
Vec::new(),
)),
EventMessage::PlaylistGroupsChanged(PlaylistGroupsChanged::new("t".to_string(), Vec::new(), Vec::new())),
EventMessage::PlaylistWatchChanged(WatchChanges::new("t".to_string(), Vec::new())),
EventMessage::PlaylistWatchDisabled(WatchDisabled::new(
"t".to_string(),
WatchDisabledReason::InvalidPatterns,
@@ -23,10 +23,10 @@ use log::{debug, warn};
use shared::model::{
notification::{EventId, Severity},
AuthAuditEvent, AuthAuditOutcome, ConnectionDenied, EventKind, EventKindMask, EventMessage,
LibraryScanSummaryStatus, MetadataUpdateFailure, PlaylistGroupsChanged, ProviderFetchFailure,
ProviderPoolExhausted, ProviderPriorityFallback, ScheduledTaskFailure, ServerLifecycleEvent, ServerLifecycleState,
StreamProbeFailure, StreamProbeFailureReason, UserLifecycleEvent, UserLifecycleState, WatchDisabled,
WatchDisabledReason, WatchUnmatched,
LibraryScanSummaryStatus, MetadataUpdateFailure, ProviderFetchFailure, ProviderPoolExhausted,
ProviderPriorityFallback, ScheduledTaskFailure, ServerLifecycleEvent, ServerLifecycleState, StreamProbeFailure,
StreamProbeFailureReason, UserLifecycleEvent, UserLifecycleState, WatchDisabled, WatchDisabledReason,
WatchUnmatched,
};
use std::{fmt::Write, sync::Arc};
use tokio::sync::broadcast::error::RecvError;
@@ -77,7 +77,6 @@ const NOTIFIABLE_KINDS: EventKindMask = EventKindMask::new()
.with(EventKind::DiskAlert)
.with(EventKind::ConfigReloadFailed)
.with(EventKind::PlaylistWatchChanged)
.with(EventKind::PlaylistGroupsChanged)
.with(EventKind::PlaylistWatchDisabled)
.with(EventKind::PlaylistWatchUnmatched)
.with(EventKind::ProviderAccountStatus)
@@ -210,17 +209,16 @@ pub fn to_notification(message: &EventMessage) -> Option<NotificationEvent> {
Some(NotificationEvent::new(id, title.clone(), title))
}
// The three that already have a `MessageContent` shape reuse it:
// `from_content` is what built their title, body and template fields
// before, and rebuilding that here by hand would be a second copy
// free to drift from the templates that render it.
// Events that already have a `MessageContent` shape reuse its
// builders: `from_content` is what built their title, body and
// template fields before, and rebuilding that here by hand would be a
// second copy free to drift from the templates that render it. Watch
// changes use the same builders through a borrowing entry point, so
// the title lists are not cloned per notification.
EventMessage::DiskAlert(alert) => {
Some(NotificationEvent::from_content(&MessageContent::DiskAlert(alert.clone())))
}
EventMessage::PlaylistWatchChanged(changes) => {
Some(NotificationEvent::from_content(&MessageContent::Watch(changes.clone())))
}
EventMessage::PlaylistGroupsChanged(changes) => Some(groups_changed_notification(id, changes)),
EventMessage::PlaylistWatchChanged(changes) => Some(NotificationEvent::from_watch_changes(changes)),
EventMessage::PlaylistWatchDisabled(disabled) => {
Some(watch_disabled_notification(id, disabled, message.severity()))
}
@@ -312,34 +310,6 @@ fn metadata_failure_notification(
NotificationEvent::new(id, title, body).with_severity(severity).with_fields(failure)
}
fn groups_changed_notification(id: EventId, changes: &PlaylistGroupsChanged) -> NotificationEvent {
let title =
format!("{} group(s) added, {} removed in {}", changes.added_total, changes.removed_total, changes.target);
let mut body = title.clone();
push_sampled_list(&mut body, "Added", &changes.added, changes.added_total);
push_sampled_list(&mut body, "Removed", &changes.removed, changes.removed_total);
NotificationEvent::new(id, title, body).with_fields(changes)
}
/// Render one direction of a sampled change set.
///
/// The lists carry titles only and may be shorter than the total - or empty -
/// so the omission is spelled out rather than smuggled into the list.
fn push_sampled_list(body: &mut String, label: &str, names: &[String], total: usize) {
if total == 0 {
return;
}
let _ = write!(body, "\n\n{label} ({total}):");
for name in names {
body.push('\n');
body.push_str(name);
}
let omitted = total.saturating_sub(names.len());
if omitted > 0 {
let _ = write!(body, "\n... {omitted} more not listed");
}
}
fn watch_disabled_notification(id: EventId, disabled: &WatchDisabled, severity: Severity) -> NotificationEvent {
let what = match disabled.reason {
WatchDisabledReason::InvalidPatterns => "no watch pattern compiled".to_string(),
@@ -501,12 +471,11 @@ mod tests {
notification::{registry, Severity},
ActiveUserConnectionChange, AuthAuditEvent, AuthAuditOutcome, ConfigReloadFailure, ConfigType,
ConnectionDenied, DiskAlert, DiskAlertLevel, EventKind, LibraryScanProgressEvent, LibraryScanSummary,
LibraryScanSummaryStatus, MetadataUpdateFailure, MsgKind, NotificationDeadLetter, PlaylistGroupsChanged,
PlaylistUpdateProgressEvent, PlaylistUpdateState, PlaylistUpdateSummary, ProviderAccountEvent,
ProviderAccountState, ProviderFailureKind, ProviderFetchFailure, ProviderPoolExhausted,
ProviderPriorityFallback, RecordingLifecycleMessage, ScheduledTaskFailure, ServerLifecycleEvent,
StreamProbeFailure, StreamProbeFailureReason, SystemInfo, UserLifecycleEvent, UserLifecycleState, WatchChanges,
WatchDisabled, WatchDisabledReason, WatchUnmatched,
LibraryScanSummaryStatus, MetadataUpdateFailure, MsgKind, NotificationDeadLetter, PlaylistUpdateProgressEvent,
PlaylistUpdateState, PlaylistUpdateSummary, ProviderAccountEvent, ProviderAccountState, ProviderFailureKind,
ProviderFetchFailure, ProviderPoolExhausted, ProviderPriorityFallback, RecordingLifecycleMessage,
ScheduledTaskFailure, ServerLifecycleEvent, StreamProbeFailure, StreamProbeFailureReason, SystemInfo,
UserLifecycleEvent, UserLifecycleState, WatchChanges, WatchDisabled, WatchDisabledReason, WatchUnmatched,
};
use std::sync::Arc;
@@ -606,13 +575,7 @@ mod tests {
paths: "config.yml".to_string(),
error: "boom".to_string(),
}),
EventMessage::PlaylistWatchChanged(WatchChanges::new(
"t".to_string(),
"g".to_string(),
Vec::new(),
Vec::new(),
)),
EventMessage::PlaylistGroupsChanged(PlaylistGroupsChanged::new("t".to_string(), Vec::new(), Vec::new())),
EventMessage::PlaylistWatchChanged(WatchChanges::new("t".to_string(), Vec::new())),
EventMessage::PlaylistWatchDisabled(WatchDisabled::new(
"t".to_string(),
WatchDisabledReason::InvalidPatterns,
+48 -5
View File
@@ -11,7 +11,7 @@ use shared::{
model::{
ConfigTargetDto, ConfigTargetOptions, DeduplicateConfig, HdHomeRunTargetOutputDto, M3uTargetOutputDto,
MappingStage, PlaylistItemType, ProcessingOrder, StrmExportStyle, StrmTargetOutputDto, TargetOutputDto,
TargetType, TraktConfigDto, XtreamTargetOutputDto,
TargetType, TraktConfigDto, WatchContext, WatchContextSet, XtreamTargetOutputDto,
},
};
use std::{borrow::Cow, sync::Arc};
@@ -319,6 +319,28 @@ impl From<&TargetOutput> for TargetOutputDto {
}
}
/// One compiled `watch` entry.
///
/// `contexts` is the DTO's `u8` bitset, copied as is: "does this entry apply
/// to X" is asked for every group, and a bit test is all it costs.
#[derive(Debug, Clone)]
pub struct ConfigWatch {
pub pattern: Arc<regex::Regex>,
pub contexts: WatchContextSet,
}
impl ConfigWatch {
/// Whether matching no current group means something is wrong.
///
/// An entry waiting for a group to appear matches nothing by design, and a
/// `group_removed`-only entry is matched against the previous titles, not
/// the current playlist.
#[must_use]
pub fn reports_unmatched(&self) -> bool {
self.contexts.contains(WatchContext::Channels) && !self.contexts.contains(WatchContext::GroupAdded)
}
}
#[derive(Debug, Clone)]
pub struct ConfigTarget {
pub id: u16,
@@ -335,7 +357,7 @@ pub struct ConfigTarget {
pub favourites: Option<Vec<ConfigFavourites>>,
pub processing_order: ProcessingOrder,
pub execution_plan: TargetExecutionPlan,
pub watch: Option<Vec<Arc<regex::Regex>>>,
pub watch: Option<Vec<ConfigWatch>>,
pub use_memory_cache: bool,
}
@@ -414,10 +436,10 @@ impl From<&ConfigTargetDto> for ConfigTarget {
// `playlist.watch.disabled`.
watch: dto.watch.as_ref().map(|list| {
list.iter()
.filter_map(|s| match shared::model::REGEX_CACHE.get_or_compile(s) {
Ok(re) => Some(re),
.filter_map(|entry| match shared::model::REGEX_CACHE.get_or_compile(&entry.pattern) {
Ok(pattern) => Some(ConfigWatch { pattern, contexts: entry.on }),
Err(e) => {
log::warn!("Invalid watch regex pattern '{s}': {e}");
log::warn!("Invalid watch regex pattern '{}': {e}", entry.pattern);
None
}
})
@@ -432,6 +454,27 @@ impl From<&ConfigTargetDto> for ConfigTarget {
mod tests {
use super::*;
#[test]
fn watch_contexts_compile_to_flags() {
let dto = ConfigTargetDto {
name: "t".to_string(),
watch: Some(vec![
shared::model::ConfigWatchDto::channels("^A"),
shared::model::ConfigWatchDto::new(
"^B",
[shared::model::WatchContext::GroupAdded, shared::model::WatchContext::Channels],
),
]),
..ConfigTargetDto::default()
};
let target = ConfigTarget::from(&dto);
let watch = target.watch.expect("watch");
assert_eq!(watch[0].contexts, dto.watch.as_ref().unwrap()[0].on);
assert!(watch[0].reports_unmatched());
assert!(watch[1].contexts.contains(WatchContext::GroupAdded));
assert!(!watch[1].reports_unmatched());
}
#[test]
fn effective_curation_retains_canonical_owner_and_credentials_without_legacy_output() {
let mut dto: ConfigTargetDto = serde_json::from_value(serde_json::json!({"name": "discovery", "output": [{"type": "m3u"}],
+131 -32
View File
@@ -15,7 +15,7 @@ use chrono::Utc;
use shared::{
model::{
notification::{registry, EventId, Severity},
DiskAlert, MsgKind,
DiskAlert, MsgKind, WatchChange, WatchChanges, WatchItems,
},
utils::human_readable_byte_size,
};
@@ -113,6 +113,17 @@ impl NotificationEvent {
event.fields = fields_for(content);
event
}
/// A watch notification from a borrowed payload.
///
/// The event bus hands the bridge a `&WatchChanges`; going through
/// [`Self::from_content`] would mean cloning every title list into a
/// `MessageContent` just to read it back.
pub fn from_watch_changes(changes: &WatchChanges) -> Self {
let mut event = Self::new(registry::PLAYLIST_WATCH_CHANGED, watch_title(changes), watch_body(changes));
event.fields = serde_json::to_value(changes).unwrap_or(serde_json::Value::Null);
event
}
}
/// Canonical id for a legacy message.
@@ -181,11 +192,7 @@ pub fn recording_lifecycle_text(recording: &RecordingLifecycleMessage) -> String
fn title_for(content: &MessageContent) -> String {
match content {
MessageContent::Info(s) | MessageContent::Error(s) => first_line(s),
MessageContent::Watch(w) => {
// The totals, not the list lengths: a large change ships a sample
// and the subject line must still report what actually happened.
format!("{} channel(s) added, {} removed in {}/{}", w.added_total, w.removed_total, w.target, w.group)
}
MessageContent::Watch(w) => watch_title(w),
MessageContent::ProcessingStats(stats) => match (&stats.stats, &stats.errors) {
(None, Some(_)) => "Playlist update failed".to_string(),
(Some(sources), _) => {
@@ -208,25 +215,72 @@ fn title_for(content: &MessageContent) -> String {
}
}
/// One direction of a watch change, rendered for a plain-text channel.
/// Subject line for a watch change.
///
/// `names` may be a sample of `total` - or empty, when the change was too
/// large to list - so the omission is spelled out here rather than smuggled
/// into the list itself.
fn push_watch_section(out: &mut String, label: &str, names: &[String], total: usize) {
if total == 0 {
/// The totals, not the list lengths: a large change ships a sample and the
/// subject line must still report what actually happened.
fn watch_title(changes: &WatchChanges) -> String {
let (mut groups_added, mut groups_removed, mut channel_changes) = (0usize, 0usize, 0usize);
for change in &changes.changes {
match change {
WatchChange::GroupsAdded(items) => groups_added += items.total,
WatchChange::GroupsRemoved(items) => groups_removed += items.total,
WatchChange::ChannelsAdded(group) | WatchChange::ChannelsRemoved(group) => {
channel_changes += group.items.total;
}
}
}
let mut title = format!("Watch changes in {}:", changes.target);
let mut separator = " ";
for (count, what) in
[(groups_added, "group(s) added"), (groups_removed, "group(s) removed"), (channel_changes, "channel change(s)")]
{
if count > 0 {
let _ = write!(title, "{separator}{count} {what}");
separator = ", ";
}
}
title
}
/// Plain-text body for a watch change: group sections, then channel sections.
fn watch_body(changes: &WatchChanges) -> String {
let mut out = format!("Watch changes in {}.", changes.target);
for change in &changes.changes {
match change {
WatchChange::GroupsAdded(items) => push_watch_section(&mut out, "New groups", items),
WatchChange::GroupsRemoved(items) => push_watch_section(&mut out, "Removed groups", items),
WatchChange::ChannelsAdded(group) => {
push_watch_section(&mut out, format_args!("{} - added", group.group), &group.items);
}
WatchChange::ChannelsRemoved(group) => {
push_watch_section(&mut out, format_args!("{} - removed", group.group), &group.items);
}
}
}
out
}
/// One sampled list of a watch change, rendered for a plain-text channel.
///
/// `list.items` may be a sample of `list.total` - or empty, when the change
/// was too large to list - so the omission is spelled out here rather than
/// smuggled into the list itself. The label is `Display` so per-group labels
/// need no temporary `String`.
fn push_watch_section(out: &mut String, label: impl std::fmt::Display, list: &WatchItems) {
if list.total == 0 {
return;
}
let _ = write!(out, "\n\n{label} ({total}):");
if names.is_empty() {
let _ = write!(out, "\n\n{label} ({}):", list.total);
if list.items.is_empty() {
out.push_str("\n(list suppressed for a large update)");
return;
}
for name in names {
for name in &list.items {
out.push('\n');
out.push_str(name);
}
let omitted = total.saturating_sub(names.len());
let omitted = list.total.saturating_sub(list.items.len());
if omitted > 0 {
let _ = write!(out, "\n... {omitted} more not listed");
}
@@ -239,12 +293,7 @@ fn push_watch_section(out: &mut String, label: &str, names: &[String], total: us
fn body_for(content: &MessageContent) -> String {
match content {
MessageContent::Info(s) | MessageContent::Error(s) => s.clone(),
MessageContent::Watch(w) => {
let mut out = format!("Watched group {}/{} changed.", w.target, w.group);
push_watch_section(&mut out, "Added", &w.added, w.added_total);
push_watch_section(&mut out, "Removed", &w.removed, w.removed_total);
out
}
MessageContent::Watch(w) => watch_body(w),
MessageContent::ProcessingStats(stats) => {
let mut out = String::new();
if let Some(sources) = &stats.stats {
@@ -306,7 +355,10 @@ fn first_line(s: &str) -> String {
#[cfg(test)]
mod tests {
use super::{super::messaging::ProcessingStats, *};
use shared::model::{DiskAlertLevel, InputStats, InputType, PlaylistStats, SourceStats, TargetStats};
use shared::model::{
DiskAlertLevel, InputStats, InputType, PlaylistStats, SourceStats, TargetStats, WatchChange, WatchChanges,
WatchGroupItems, WatchItems,
};
fn stats_content() -> MessageContent {
let input = InputStats {
@@ -372,20 +424,67 @@ mod tests {
}
}
fn watch_items(names: &[&str]) -> WatchItems { WatchItems::complete(names.iter().map(|n| Arc::from(*n)).collect()) }
#[test]
fn watch_body_lists_added_and_removed_instead_of_dumping_json() {
fn watch_text_lists_groups_then_channels_instead_of_dumping_json() {
// Regression: the legacy default text for a watch change was a raw
// `serde_json` dump, which is what Pushover received.
let content = MessageContent::Watch(super::super::messaging::WatchChanges::new(
"T".to_string(),
"G".to_string(),
vec!["Channel A".to_string()],
vec!["Channel B".to_string()],
let content = MessageContent::Watch(WatchChanges::new(
"sports".to_string(),
vec![
WatchChange::GroupsAdded(watch_items(&["Event Bundesliga"])),
WatchChange::GroupsRemoved(watch_items(&["DE Kids"])),
WatchChange::ChannelsAdded(WatchGroupItems {
group: "DE Sport HD".into(),
items: watch_items(&["Sky 5"]),
}),
WatchChange::ChannelsRemoved(WatchGroupItems {
group: "DE Sport HD".into(),
items: watch_items(&["Sky 6"]),
}),
],
));
let event = NotificationEvent::from_content(&content);
assert!(event.body.contains("Channel A"), "body missing added channel: {}", event.body);
assert!(event.body.contains("Channel B"), "body missing removed channel: {}", event.body);
assert!(!event.body.starts_with('{'), "body is still a json dump: {}", event.body);
assert_eq!(event.title, "Watch changes in sports: 1 group(s) added, 1 group(s) removed, 2 channel change(s)");
assert_eq!(
event.body,
"Watch changes in sports.\n\nNew groups (1):\nEvent Bundesliga\n\nRemoved groups (1):\nDE Kids\
\n\nDE Sport HD - added (1):\nSky 5\n\nDE Sport HD - removed (1):\nSky 6"
);
}
/// The bridge builds watch notifications from a borrowed payload; it must
/// produce exactly what the owned `MessageContent` path produces.
#[test]
fn from_watch_changes_matches_from_content() {
let changes = WatchChanges::new(
"sports".to_string(),
vec![
WatchChange::GroupsAdded(watch_items(&["New"])),
WatchChange::ChannelsRemoved(WatchGroupItems { group: "G".into(), items: watch_items(&["C"]) }),
],
);
let borrowed = NotificationEvent::from_watch_changes(&changes);
let owned = NotificationEvent::from_content(&MessageContent::Watch(changes));
assert_eq!(borrowed.id, owned.id);
assert_eq!(borrowed.severity, owned.severity);
assert_eq!(borrowed.title, owned.title);
assert_eq!(borrowed.body, owned.body);
assert_eq!(borrowed.fields, owned.fields);
}
#[test]
fn suppressed_watch_list_says_so() {
let content = MessageContent::Watch(WatchChanges::new(
"t".to_string(),
vec![WatchChange::ChannelsAdded(WatchGroupItems {
group: "G".into(),
items: WatchItems { items: Vec::new(), total: 900, truncated: true },
})],
));
let event = NotificationEvent::from_content(&content);
assert!(event.body.contains("G - added (900):\n(list suppressed for a large update)"), "{}", event.body);
}
#[test]
+24 -2
View File
@@ -8,6 +8,10 @@ use reqwest::header;
use shared::model::notification::{EventId, Severity};
use tuliprox_core::model::{ChannelRouting, NtfyMessagingConfig};
/// ntfy's default `message-size-limit`. A larger body is turned into an
/// attachment, or rejected when the server has no attachment cache.
const NTFY_BODY_LIMIT: usize = 4096;
#[derive(Clone)]
pub struct NtfyChannel {
config: NtfyMessagingConfig,
@@ -76,12 +80,30 @@ impl NotificationChannel for NtfyChannel {
}
}
fn capabilities(&self) -> ChannelCapabilities { ChannelCapabilities::default() }
fn capabilities(&self) -> ChannelCapabilities {
ChannelCapabilities { supports_templates: true, max_body_bytes: Some(NTFY_BODY_LIMIT) }
}
}
#[cfg(test)]
mod tests {
use super::{NtfyChannel, Severity};
use super::{NtfyChannel, Severity, NTFY_BODY_LIMIT};
use crate::channel::NotificationChannel;
#[test]
fn the_body_is_capped_at_the_ntfy_message_size_limit() {
let channel = NtfyChannel::new(
tuliprox_core::model::NtfyMessagingConfig {
url: "http://ntfy.local".to_string(),
topic: "t".to_string(),
token: None,
templates: std::collections::HashMap::new(),
routing: tuliprox_core::model::ChannelRouting::default(),
},
reqwest::Client::new(),
);
assert_eq!(channel.capabilities().max_body_bytes, Some(NTFY_BODY_LIMIT));
}
#[test]
fn severity_maps_onto_the_ntfy_priority_scale() {
+86 -6
View File
@@ -12,6 +12,34 @@ use tuliprox_core::{
utils::{telegram_create_instance, telegram_send_message, SendMessageOption, SendMessageParseMode},
};
/// Telegram's message limit, in UTF-16 code units. It applies after entity
/// parsing, so measuring the raw `MarkdownV2` text is conservative.
const TELEGRAM_MESSAGE_LIMIT: usize = 4096;
/// Cut `text` to what Telegram accepts.
///
/// Done here rather than through `ChannelCapabilities::max_body_bytes`: the
/// limit counts UTF-16 units, not bytes, and `MarkdownV2` escaping grows the
/// text after the dispatcher has truncated it. Always borrows.
fn fit_message(text: &str) -> &str {
// A UTF-16 unit is never fewer than one byte, so short text needs no scan.
if text.len() <= TELEGRAM_MESSAGE_LIMIT {
return text;
}
let mut units = 0usize;
for (index, ch) in text.char_indices() {
units += ch.len_utf16();
if units > TELEGRAM_MESSAGE_LIMIT {
let cut = &text[..index];
// An odd run of trailing backslashes is an escape cut off from the
// character it escapes; Telegram rejects that as a parse error.
let backslashes = cut.len() - cut.trim_end_matches('\\').len();
return if backslashes % 2 == 1 { &cut[..cut.len() - 1] } else { cut };
}
}
text
}
#[derive(Clone)]
pub struct TelegramChannel {
config: TelegramMessagingConfig,
@@ -59,18 +87,29 @@ impl NotificationChannel for TelegramChannel {
let mut failed: Vec<String> = Vec::new();
for chat_id in &self.config.chat_ids {
let bot = telegram_create_instance(&self.config.bot_token, chat_id);
let result =
telegram_send_message(&self.app_config, &self.client, &bot, &message, options.as_ref()).await;
let result = telegram_send_message(
&self.app_config,
&self.client,
&bot,
fit_message(&message),
options.as_ref(),
)
.await;
let mut delivered = result.delivered;
if self.config.markdown && msg.templated && result.parse_error && !delivered {
// Template output can carry dynamic fields that break
// MarkdownV2. Retry once, escaped.
let escaped = escape_markdown_v2(raw);
let escaped_options = SendMessageOption { parse_mode: SendMessageParseMode::MarkdownV2 };
delivered =
telegram_send_message(&self.app_config, &self.client, &bot, &escaped, Some(&escaped_options))
.await
.delivered;
delivered = telegram_send_message(
&self.app_config,
&self.client,
&bot,
fit_message(&escaped),
Some(&escaped_options),
)
.await
.delivered;
}
if delivered {
debug!("Notification delivered to Telegram chat {chat_id}");
@@ -89,3 +128,44 @@ impl NotificationChannel for TelegramChannel {
fn capabilities(&self) -> ChannelCapabilities { ChannelCapabilities::default() }
}
#[cfg(test)]
mod tests {
use super::{fit_message, TELEGRAM_MESSAGE_LIMIT};
#[test]
fn a_short_message_is_borrowed_unchanged() {
let text = "Watch changes in sports.";
assert!(std::ptr::eq(fit_message(text), text));
}
#[test]
fn a_long_message_is_cut_to_the_limit() {
let text = "a".repeat(TELEGRAM_MESSAGE_LIMIT + 900);
assert_eq!(fit_message(&text).len(), TELEGRAM_MESSAGE_LIMIT);
}
/// Telegram counts UTF-16 code units; an emoji outside the BMP is two.
#[test]
fn the_limit_counts_utf16_units_not_bytes_or_chars() {
let text = "\u{1F534}".repeat(TELEGRAM_MESSAGE_LIMIT);
let fitted = fit_message(&text);
assert_eq!(fitted.chars().count(), TELEGRAM_MESSAGE_LIMIT / 2);
let cyrillic = "ж".repeat(TELEGRAM_MESSAGE_LIMIT);
assert_eq!(fit_message(&cyrillic).chars().count(), TELEGRAM_MESSAGE_LIMIT, "2-byte chars are 1 unit each");
}
/// Cutting between a `MarkdownV2` escape and the character it escapes would
/// leave a dangling backslash, which Telegram rejects as a parse error.
#[test]
fn a_cut_escape_drops_its_dangling_backslash() {
let text = format!("{}\\.tail", "a".repeat(TELEGRAM_MESSAGE_LIMIT - 1));
assert_eq!(fit_message(&text), "a".repeat(TELEGRAM_MESSAGE_LIMIT - 1));
}
#[test]
fn an_escaped_backslash_at_the_cut_is_kept() {
let text = format!("{}\\\\tail", "a".repeat(TELEGRAM_MESSAGE_LIMIT - 2));
assert_eq!(fit_message(&text), format!("{}\\\\", "a".repeat(TELEGRAM_MESSAGE_LIMIT - 2)));
}
}
+56 -13
View File
@@ -12,8 +12,9 @@
use handlebars::{Context, Handlebars, Helper, HelperResult, Output, RenderContext};
use log::{debug, error, warn};
use serde::Deserialize;
use shared::{
model::{notification::registry, InputFetchMethod},
model::{notification::registry, InputFetchMethod, WatchChanges},
utils::Internable,
};
use std::{
@@ -249,7 +250,14 @@ pub fn context_for(event: &NotificationEvent) -> serde_json::Value {
root.insert("message".to_string(), fields.clone());
}
id if id == registry::PLAYLIST_WATCH_CHANGED => {
root.insert("watch".to_string(), fields.clone());
// Templates get the grouped views. The event only carries JSON
// fields (it may come back from the outbox), so decode them in
// place; a payload that does not decode still renders as is.
let watch = WatchChanges::deserialize(fields)
.ok()
.and_then(|changes| serde_json::to_value(changes.template_view()).ok())
.unwrap_or_else(|| fields.clone());
root.insert("watch".to_string(), watch);
}
id if id == registry::PLAYLIST_UPDATE_COMPLETED || id == registry::PLAYLIST_UPDATE_FAILED => {
root.insert("processing".to_string(), fields.clone());
@@ -316,8 +324,11 @@ fn legacy_kind_label(event: &NotificationEvent) -> String {
#[cfg(test)]
mod tests {
use super::{context_for, validate, TemplateSource};
use shared::model::{DiskAlert, DiskAlertLevel, InputStats, InputType, PlaylistStats, SourceStats, TargetStats};
use super::{context_for, new_handlebars, validate, TemplateSource};
use shared::model::{
DiskAlert, DiskAlertLevel, InputStats, InputType, PlaylistStats, SourceStats, TargetStats, WatchChange,
WatchGroupItems, WatchItems,
};
use tuliprox_core::model::{MessageContent, NotificationEvent, ProcessingStats, WatchChanges};
fn stats_event() -> NotificationEvent {
@@ -412,17 +423,49 @@ mod tests {
assert_eq!(ctx["disk"]["percent"], 95.0);
}
fn watch_event() -> NotificationEvent {
let items = |names: &[&str]| WatchItems::complete(names.iter().map(|n| std::sync::Arc::from(*n)).collect());
NotificationEvent::from_content(&MessageContent::Watch(WatchChanges::new(
"sports".to_string(),
vec![
WatchChange::GroupsAdded(items(&["Event Bundesliga"])),
WatchChange::ChannelsAdded(WatchGroupItems { group: "DE Sport HD".into(), items: items(&["Sky 5"]) }),
],
)))
}
#[test]
fn legacy_watch_key_still_resolves() {
let event = NotificationEvent::from_content(&MessageContent::Watch(WatchChanges::new(
"T".to_string(),
"G".to_string(),
vec!["A".to_string()],
vec![],
)));
let ctx = context_for(&event);
fn watch_key_carries_grouped_views() {
let ctx = context_for(&watch_event());
assert_eq!(ctx["kind"], "Watch");
assert_eq!(ctx["watch"]["target"], "T");
assert_eq!(ctx["watch"]["target"], "sports");
assert_eq!(ctx["watch"]["groups_added"]["items"][0], "Event Bundesliga");
assert_eq!(ctx["watch"]["channels_added"][0]["group"], "DE Sport HD");
assert!(ctx["watch"].get("groups_removed").is_none());
assert_eq!(ctx["watch"]["changes"][0]["kind"], "groups_added");
}
#[test]
fn the_feature_request_template_renders() {
let template = "{{#if watch.groups_added}}New:{{#each watch.groups_added.items}} {{this}}{{/each}}{{/if}}\
{{#each watch.channels_added}}|{{group}}:{{#each items}} {{this}}{{/each}}{{/each}}";
let rendered = new_handlebars().render_template(template, &context_for(&watch_event())).expect("render");
assert_eq!(rendered, "New: Event Bundesliga|DE Sport HD: Sky 5");
}
#[test]
fn the_raw_list_works_with_eq() {
let template = "{{#each watch.changes}}{{#if (eq kind \"groups_added\")}}{{items.[0]}}{{/if}}{{/each}}";
let rendered = new_handlebars().render_template(template, &context_for(&watch_event())).expect("render");
assert_eq!(rendered, "Event Bundesliga");
}
#[test]
fn undecodable_watch_fields_fall_back_to_raw() {
let mut event = watch_event();
event.fields = serde_json::json!({ "target": "old", "group": "G" });
let ctx = context_for(&event);
assert_eq!(ctx["watch"]["group"], "G");
}
#[test]
+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:?}"
);
}
+30 -12
View File
@@ -415,11 +415,10 @@ schedules:
## 5. Messaging (`messaging`)
Tuliprox can proactively notify you via Push-Notifications when updates fail, finish, or when specific channels are
added/removed
from a watched group. **Why is this useful?** Because it allows you to instantly detect upstream provider issues or
simply let you
know when new movies are added to your playlist.
Tuliprox can proactively notify you via Push-Notifications when updates fail, finish, when channels are added to or
removed from a watched group, or when watched groups appear or disappear (see
[`watch`](source.md#329-watch-watch)). **Why is this useful?** Because it allows you to instantly detect upstream
provider issues, or simply let you know when new movies or a new event group are added to your playlist.
### 5.1 Configuration & Opt-In
@@ -458,8 +457,7 @@ negative pattern does, so `["*", "!system.info"]` reads the way it looks.
| `system.shutdown` | info | The server is shutting down cleanly. |
| `playlist.update.completed` | info | A playlist update finished; carries per-source statistics. |
| `playlist.update.failed` | error | A playlist update failed. |
| `playlist.watch.changed` | info | Channels were added to or removed from a watched group. |
| `playlist.groups.changed` | info | Groups were added to or removed from a target. |
| `playlist.watch.changed` | info | Channels or groups were added to or removed from a watched target. |
| `playlist.watch.disabled` | warn | A target's watch configuration is set but not working. |
| `playlist.watch.unmatched` | warn | Watch patterns matched no group in the refreshed playlist. |
| `recording.started` | info | A recording started. |
@@ -701,11 +699,31 @@ The legacy keys below continue to work exactly as before, so existing templates
task. It includes data on memory allocation peaks, active worker threads, and non-blocking diagnostic warnings.
* **Access:** Access properties directly via dot-notation (e.g., `{{processing.memory_peak_mb}}`). Use this to
monitor system health and resource consumption during heavy mapping cycles.
* `{{watch}}`: **Change Tracking Data.** Specifically available for the `watch` event kind. It contains a diff-style
object showing exactly which groups or channels were added, removed, or modified compared to the previous state.
* **Access:** Iterate over the change sets using loops. Common keys include `added`, `removed`, and `modified`.
* **Example:** Use `{{#each watch.added}} • {{name}} {{/each}}` to list all new channels detected in the monitored
groups.
* `{{watch}}`: **Change Tracking Data.** Available for `playlist.watch.changed`. One event per target and update.
* `watch.target`: the target name.
* `watch.groups_added` / `watch.groups_removed`: `{ items, total, truncated }`, absent when there is none.
* `watch.channels_added` / `watch.channels_removed`: list of `{ group, items, total, truncated }`, absent when
there is none.
* `watch.changes`: the raw list; each entry has a `kind` (`groups_added`, `groups_removed`, `channels_added`,
`channels_removed`).
* `items` may be shorter than `total` (`truncated: true`), or empty when an update changed more than 500 channels.
* **Example:**
```handlebars
{{#if watch.groups_added}}
🆕 New groups:
{{#each watch.groups_added.items}}
• {{this}}
{{/each}}
{{/if}}
{{#each watch.channels_added}}
➕ {{group}} ({{total}}):
{{#each items}}
• {{this}}
{{/each}}
{{/each}}
```
* `{{recording}}`: **DVR Lifecycle Data.** Available for `recording_started`, `recording_completed`, and
`recording_failed`. Common fields are `programme_title`, `channel`, `effective_start`, `effective_end`,
`visibility`, `output_filename`, and `failure_reason` for failed recordings.
+30 -11
View File
@@ -1294,7 +1294,7 @@ sources:
| `output` | List | Yes | | Mandatory list of output formats. A single target can generate multiple output representations (e.g., `xtream`, `m3u`, `strm`, `hdhomerun`) from the same transformed result set. |
| `curation` | Object | No | | Optional target-wide catalog policy with Trakt and/or TMDB Trending sources. Mutually exclusive with legacy `output[].trakt`; see [target-wide curation](#target-wide-curation-curation). |
| `favourites` | List | No | | Duplicates final transformed channels into dedicated favorite groups after processing is complete. This adds curated views without changing the original group structure. |
| `watch` | List | No | | Defines watched group patterns. If matching groups change during updates, Tuliprox emits Messaging events so operational changes become observable automatically. |
| `watch` | List | No | | Watch entries for this target: channel changes in matching groups and/or matching groups that appear or disappear. Changes are reported as Messaging events; see [Watch](#329-watch-watch). |
| `use_memory_cache` | Bool | No | `false` | If enabled, the final compiled playlist is cached in RAM. This reduces disk access and improves delivery speed, especially for M3U downloads, but increases memory consumption. |
---
@@ -2279,25 +2279,44 @@ favourites:
### 3.2.9 Watch (`watch`)
For each target with a *unique name*, you can define watched groups.
It is a list of group patterns Tuliprox monitors for content changes during updates.
For each target with a *unique name*, you can define watch entries. Each entry is a regex matched against final group
names and says which changes to report:
If matching groups gain or lose channels, Tuliprox emits a Messaging event such as:
| `on` value | Reported change |
|:----------------|:---------------------------------------------------------------|
| `channels` | Channels added to or removed from a matching group. |
| `group_added` | A matching group appeared that was not in the previous update. |
| `group_removed` | A group that matched in the previous update is gone. |
* channels added
* channels removed
An entry is either a plain string, which means `on: [channels]`, or a map with `pattern` and `on`:
```yaml
watch:
- group: '^Sports'
- group: '^Movies'
# Short form: channel changes in all German sports groups.
- "^DE Sport.*"
# Report new 4K/UHD groups only.
- pattern: "(?i)\\b(4K|UHD)\\b"
on: [group_added]
# Report every group that disappears.
- pattern: ".*"
on: [group_removed]
# Event groups: appearing, disappearing and their channel changes.
- pattern: "^Event.*|^PPV.*"
on: [channels, group_added, group_removed]
```
The first update of a target only records the current state; changes are reported from the second update on. All
changes of one update arrive as a single `playlist.watch.changed` event.
A `playlist.watch.unmatched` warning is sent for entries that watch `channels` (without `group_added`) and match no
group. Entries waiting for a group to appear, or reporting only removals, never trigger it.
#### `watch` Parameters
| Parameter | Type | Required | Default | Technical Impact & Background |
|:----------|:---------------|:--------:|:--------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `group` | String (Regex) | Yes | | Regex pattern matched against final group names. This allows Tuliprox to detect meaningful content changes in selected areas of the playlist and notify operators automatically through the configured messaging backends. |
| Parameter | Type | Required | Default | Technical Impact & Background |
|:----------|:---------------|:--------:|:-------------|:----------------------------------------------------------------------|
| `pattern` | String (Regex) | Yes | | Regex matched against final group names. |
| `on` | List | No | `[channels]` | Any of `channels`, `group_added`, `group_removed`. Must not be empty. |
> **Note:** `watch` is especially useful for monitoring premium groups, VOD collections,
> or unstable provider segments where additions and removals should generate operational alerts.
+4 -1
View File
@@ -383,7 +383,7 @@
"MAPPING": "قائمة معرفات التعيين لتطبيقها على هذا الهدف.",
"NAME": "اسم قابل للقراءة البشرية لهذا الإدخال. يُستخدم للمعالجة الانتقائية عبر وسيطة CLI -t.",
"USE_MEMORY_CACHE": "إذا تم التفعيل، يتم تخزين قائمة التشغيل في ذاكرة الوصول العشوائي لتقليل عبء القرص (يزيد من استخدام الذاكرة).",
"WATCH": "قائمة أنماط المجموعات لمراقبة التغييرات. يتم إرسال الإشعارات عبر المراسلة المكوّنة."
"WATCH": "قائمة أنماط المجموعات للمراقبة. كل نمط هو تعبير نمطي (Regex) يُطابَق مع اسم المجموعة (مثل ^Sport.* أو (?i)event). يبلغ كل إدخال عن تغييرات القنوات و/أو المجموعات الجديدة و/أو المجموعات المحذوفة. يتم إرسال الإشعارات عبر المراسلة المكوّنة."
},
"CONFIG_TARGET_OPTIONS": {
"SHARE_LIVE_STREAMS": "يُفعّل مشاركة البث المباشر. المفتاح الرئيسي يضبط HLS و MPEG-TS معًا؛ يمكن تغيير كل تنسيق أيضًا بشكل منفصل."
@@ -1740,6 +1740,9 @@
"VOD": "VOD",
"VOD_SHORT": "ف",
"WATCH": "مراقبة",
"WATCH_CHANNELS": "القنوات",
"WATCH_GROUP_ADDED": "مجموعات جديدة",
"WATCH_GROUP_REMOVED": "مجموعات محذوفة",
"WEBHOOK_URL": "عنوان URL لـ Webhook",
"WEB_AUTH": "مصادقة",
"WEB_ROOT": "جذر الويب",
+4 -1
View File
@@ -383,7 +383,7 @@
"MAPPING": "List of mapping IDs to apply to this target.",
"NAME": "Human readable name for this entry. Used for selective processing via -t CLI arg.",
"USE_MEMORY_CACHE": "If enabled, the playlist is cached in RAM to reduce disk overhead (increases memory usage).",
"WATCH": "List of group patterns to monitor for changes. Notifications are sent via configured messaging."
"WATCH": "List of group patterns to monitor. Each pattern is a regular expression matched against the group name (e.g. ^Sport.* or (?i)event). Each entry reports channel changes, new groups and/or removed groups. Notifications are sent via configured messaging."
},
"CONFIG_TARGET_OPTIONS": {
"SHARE_LIVE_STREAMS": "Enables live stream sharing. The master toggle sets HLS and MPEG-TS together; each format can also be changed separately."
@@ -1808,6 +1808,9 @@
"VOD": "VOD",
"VOD_SHORT": "V",
"WATCH": "Watch",
"WATCH_CHANNELS": "Channels",
"WATCH_GROUP_ADDED": "New groups",
"WATCH_GROUP_REMOVED": "Removed groups",
"WEBHOOK_URL": "Webhook URL",
"WEB_AUTH": "Auth",
"WEB_ROOT": "Web-Root",
+4 -1
View File
@@ -383,7 +383,7 @@
"MAPPING": "Lista de ID de mapeo a aplicar a este destino.",
"NAME": "Nombre legible para este elemento. Se utiliza para el procesamiento selectivo mediante el argumento CLI -t.",
"USE_MEMORY_CACHE": "Si está habilitado, la lista de reproducción se guarda en caché en RAM para reducir la sobrecarga de disco (aumenta el uso de memoria).",
"WATCH": "Lista de patrones de grupo a monitorizar para detectar cambios. Las notificaciones se envían mediante la mensajería configurada."
"WATCH": "Lista de patrones de grupo a monitorizar. Cada patrón es una expresión regular que se compara con el nombre del grupo (p. ej. ^Sport.* o (?i)event). Cada entrada informa de cambios de canales, grupos nuevos y/o grupos eliminados. Las notificaciones se envían mediante la mensajería configurada."
},
"CONFIG_TARGET_OPTIONS": {
"SHARE_LIVE_STREAMS": "Habilita el uso compartido de streams en directo. El interruptor maestro activa HLS y MPEG-TS juntos; cada formato también se puede cambiar por separado."
@@ -1808,6 +1808,9 @@
"VOD": "VOD",
"VOD_SHORT": "V",
"WATCH": "Vigilancia",
"WATCH_CHANNELS": "Canales",
"WATCH_GROUP_ADDED": "Grupos nuevos",
"WATCH_GROUP_REMOVED": "Grupos eliminados",
"WEBHOOK_URL": "URL del webhook",
"WEB_AUTH": "Autenticación",
"WEB_ROOT": "Web-Root",
+4 -1
View File
@@ -383,7 +383,7 @@
"MAPPING": "Список идентификаторов сопоставления, применяемых к этой цели.",
"NAME": "Понятное человеку имя для этой записи. Используется для выборочной обработки через аргумент CLI -t.",
"USE_MEMORY_CACHE": "Если включено, плейлист кэшируется в оперативной памяти для уменьшения нагрузки на диск (увеличивает использование памяти).",
"WATCH": "Список шаблонов групп для мониторинга изменений. Уведомления отправляются через настроенный мессенджер."
"WATCH": "Список шаблонов групп для мониторинга. Каждый шаблон — регулярное выражение, сравниваемое с названием группы (например, ^Sport.* или (?i)event). Каждая запись сообщает об изменениях каналов, новых и/или удалённых группах. Уведомления отправляются через настроенный мессенджер."
},
"CONFIG_TARGET_OPTIONS": {
"SHARE_LIVE_STREAMS": "Включает совместное использование прямых трансляций. Главный переключатель задаёт HLS и MPEG-TS вместе; каждый формат можно изменить и отдельно."
@@ -1726,6 +1726,9 @@
"VOD": "VOD",
"VOD_SHORT": "V",
"WATCH": "Наблюдение",
"WATCH_CHANNELS": "Каналы",
"WATCH_GROUP_ADDED": "Новые группы",
"WATCH_GROUP_REMOVED": "Удалённые группы",
"WEBHOOK_URL": "URL вебхука",
"WEB_AUTH": "Авторизация",
"WEB_ROOT": "Web-Root",
+2 -1
View File
@@ -643,5 +643,6 @@
"path": "M 12 2 C 6.477 2 2 6.477 2 12 C 2 17.523 6.477 22 12 22 C 17.523 22 22 17.523 22 12 C 22 6.477 17.523 2 12 2 Z M 12 20.2 C 7.471 20.2 3.8 16.529 3.8 12 C 3.8 7.471 7.471 3.8 12 3.8 C 16.529 3.8 20.2 7.471 20.2 12 C 20.2 16.529 16.529 20.2 12 20.2 Z M 11.2 5.8 A 0.8 0.8 0 0 1 12.8 5.8 L 12.8 18.2 A 0.8 0.8 0 0 1 11.2 18.2 Z M 15.39 15.39 A 4.8 4.8 0 1 1 15.39 8.61 A 0.8 0.8 0 0 1 14.26 9.74 A 3.2 3.2 0 1 0 14.26 14.26 A 0.8 0.8 0 0 1 15.39 15.39 Z"
},
{"keys": ["DragHandle"], "path": "M9 3h2v2H9zm4 0h2v2h-2zM9 7h2v2H9zm4 0h2v2h-2zM9 11h2v2H9zm4 0h2v2h-2zM9 15h2v2H9zm4 0h2v2h-2zM9 19h2v2H9zm4 0h2v2h-2z"},
{"keys": ["Columns"], "path": "M3 3h5v18H3V3zm6.5 0h5v18h-5V3zM16 3h5v18h-5V3z"}
{"keys": ["Columns"], "path": "M3 3h5v18H3V3zm6.5 0h5v18h-5V3zM16 3h5v18h-5V3z"},
{"keys": ["Mapping"], "viewport": "0 -960 960 960", "path": "M296-270q-42 35-87.5 32T129-269q-34-28-46.5-73.5T99-436l75-124q-25-22-39.5-53T120-680q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47q-9 0-18-1t-17-3l-77 130q-11 18-7 35.5t17 28.5q13 11 31 12.5t35-12.5l420-361q42-35 88-31.5t80 31.5q34 28 46 73.5T861-524l-75 124q25 22 39.5 53t14.5 67q0 66-47 113t-113 47q-66 0-113-47t-47-113q0-66 47-113t113-47q9 0 17.5 1t16.5 3l78-130q11-18 7-35.5T782-630q-13-11-31-12.5T716-630L296-270Zm40.5-353.5Q360-647 360-680t-23.5-56.5Q313-760 280-760t-56.5 23.5Q200-713 200-680t23.5 56.5Q247-600 280-600t56.5-23.5Zm400 400Q760-247 760-280t-23.5-56.5Q713-360 680-360t-56.5 23.5Q600-313 600-280t23.5 56.5Q647-200 680-200t56.5-23.5ZM280-680Zm400 400Z"}
]
+1
View File
@@ -88,6 +88,7 @@
@forward "components/form";
@forward "components/api_user/api_user_view";
@forward "components/keyvalue_editor";
@forward "components/watch_entries_editor";
@forward "components/loading_screen";
@forward "components/websocket_status";
@forward "components/health_banner";
@@ -0,0 +1,53 @@
.tp__watch-entries-editor {
display: flex;
flex-flow: column;
box-sizing: border-box;
gap: var(--gap-small);
&__form {
position: sticky;
top: 0;
z-index: 1;
display: flex;
flex-flow: column;
box-sizing: border-box;
gap: var(--gap-small);
padding: var(--gap-small);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background-color: var(--card-background-color);
}
&__pattern {
display: flex;
flex-flow: row;
align-items: center;
box-sizing: border-box;
gap: var(--gap-small);
.tp__input {
flex: 1;
}
.svg-icon {
height: 2rem;
width: 2rem;
}
}
&__entries {
display: flex;
flex-flow: row wrap;
box-sizing: border-box;
gap: var(--gap-small);
}
.tp__input.invalid input {
color: var(--attention-color);
}
&__error {
color: var(--attention-color);
font-size: var(--font-size-md);
}
}
@@ -1,3 +1,4 @@
use crate::{app::components::source_editor::watch_entry_label, i18n::use_translation};
use shared::model::ConfigTargetDto;
use std::rc::Rc;
use yew::prelude::*;
@@ -9,13 +10,14 @@ pub struct TargetWatchProps {
#[component]
pub fn TargetWatch(props: &TargetWatchProps) -> Html {
let translate = use_translation();
match props.target.watch.as_ref() {
None => html! {},
Some(watch) => html! {
<div class="tp__target-watch">
<ul>
for item in watch.iter() {
<li key={item.clone()}>{ item }</li>
for entry in watch.iter() {
<li key={entry.pattern.clone()}>{ watch_entry_label(entry, |key| translate.t(key)) }</li>
}
</ul>
</div>
@@ -18,6 +18,7 @@ mod sidebar;
mod target_form;
mod trakt_chart_item_form;
mod trakt_list_item_form;
mod watch_entries_editor;
pub(crate) use alias_item_form::*;
pub(crate) use block_view::*;
@@ -38,3 +39,4 @@ pub(crate) use sidebar::*;
pub(crate) use target_form::*;
pub(crate) use trakt_chart_item_form::*;
pub(crate) use trakt_list_item_form::*;
pub(crate) use watch_entries_editor::*;
@@ -1,8 +1,9 @@
use super::WatchEntriesEditor;
use crate::{
app::components::{
build_options, config::HasFormData, select::Select, selection_parse_first, AppIcon, BlockId, BlockInstance,
Card, ClusterFlagsInput, ClusterFlagsInputMode, DropDownSelection, EditMode, FilterInput, IconButton, Panel,
SourceEditorContext, TextButton, ToggleSwitch,
build_options, config::HasFormData, dto_field_id, select::Select, selection_parse_first, AppIcon, BlockId,
BlockInstance, Card, ClusterFlagsInput, ClusterFlagsInputMode, DropDownSelection, EditMode, FilterInput,
IconButton, Panel, SourceEditorContext, TextButton, ToggleSwitch,
},
config_field, config_field_bool, config_field_child, config_field_custom, edit_field_bool, edit_field_list_option,
edit_field_text, generate_form_reducer,
@@ -14,7 +15,8 @@ use shared::{
error::TuliproxError,
model::{
permission::Permission, ClusterFlags, ConfigTargetDto, ConfigTargetFilterDto, ConfigTargetOptions,
CurationCatalogSelection, CurationConfigDto, ProcessingOrder, TargetBouquetMode, TargetBouquetStatusDto,
ConfigWatchDto, CurationCatalogSelection, CurationConfigDto, ProcessingOrder, TargetBouquetMode,
TargetBouquetStatusDto,
},
utils::Internable,
};
@@ -31,7 +33,6 @@ const LABEL_PERSIST_FILTER: &str = "LABEL.PERSIST_FILTER";
const LABEL_MAPPING: &str = "LABEL.MAPPING";
const LABEL_WATCH: &str = "LABEL.WATCH";
const LABEL_ADD_MAPPING: &str = "LABEL.ADD_MAPPING";
const LABEL_ADD_WATCH: &str = "LABEL.ADD_WATCH";
const LABEL_USE_MEMORY_CACHE: &str = "LABEL.USE_MEMORY_CACHE";
const LABEL_PROCESSING_ORDER: &str = "LABEL.PROCESSING_ORDER";
const LABEL_IGNORE_LOGO: &str = "LABEL.IGNORE_LOGO";
@@ -77,11 +78,15 @@ fn curation_summary(curation: &CurationConfigDto, catalog_selection: &str) -> St
enum TargetFormPage {
Main,
Options,
Mapping,
Watch,
}
impl TargetFormPage {
const MAIN: &str = "Main";
const OPTIONS: &str = "Options";
const MAPPING: &str = "Mapping";
const WATCH: &str = "Watch";
}
impl FromStr for TargetFormPage {
@@ -91,6 +96,8 @@ impl FromStr for TargetFormPage {
match s {
Self::MAIN => Ok(TargetFormPage::Main),
Self::OPTIONS => Ok(TargetFormPage::Options),
Self::MAPPING => Ok(TargetFormPage::Mapping),
Self::WATCH => Ok(TargetFormPage::Watch),
_ => Err(TuliproxError::Config(format!("Unknown target form page: {s}"))),
}
}
@@ -101,6 +108,8 @@ impl Display for TargetFormPage {
match *self {
TargetFormPage::Main => write!(f, "{}", TargetFormPage::MAIN),
TargetFormPage::Options => write!(f, "{}", TargetFormPage::OPTIONS),
TargetFormPage::Mapping => write!(f, "{}", TargetFormPage::MAPPING),
TargetFormPage::Watch => write!(f, "{}", TargetFormPage::WATCH),
}
}
}
@@ -110,6 +119,8 @@ impl Internable for TargetFormPage {
match self {
Self::Main => TargetFormPage::MAIN,
Self::Options => TargetFormPage::OPTIONS,
Self::Mapping => TargetFormPage::MAPPING,
Self::Watch => TargetFormPage::WATCH,
}
.intern()
}
@@ -211,7 +222,7 @@ generate_form_reducer!(
ProcessingOrder => processing_order: ProcessingOrder,
Filter => filter: ConfigTargetFilterDto,
Mapping => mapping: Option<Vec<String>>,
Watch => watch: Option<Vec<String>>,
Watch => watch: Option<Vec<ConfigWatchDto>>,
UseMemoryCache => use_memory_cache: bool,
}
);
@@ -576,8 +587,6 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html {
options={processing_orders.clone()}
/>
}})}
{ edit_field_list_option!(target_form_state, translate.t(LABEL_MAPPING), mapping, ConfigTargetFormAction::Mapping, translate.t(LABEL_ADD_MAPPING)) }
{ edit_field_list_option!(target_form_state, translate.t(LABEL_WATCH), watch, ConfigTargetFormAction::Watch, translate.t(LABEL_ADD_WATCH)) }
</Card>
}
} else {
@@ -601,19 +610,51 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html {
translate.t(LABEL_PROCESSING_ORDER),
target_form_state.form.processing_order.to_string()
) }
{ config_field_custom!(
translate.t(LABEL_MAPPING),
target_form_state.form.mapping.as_ref().map_or_else(String::new, |values| values.join(", "))
) }
{ config_field_custom!(
translate.t(LABEL_WATCH),
target_form_state.form.watch.as_ref().map_or_else(String::new, |values| values.join(", "))
) }
</Card>
}
}
};
let render_mapping = || {
if props.allow_write {
html! {
<Card class="tp__config-view__card">
{ edit_field_list_option!(target_form_state, translate.t(LABEL_MAPPING), mapping, ConfigTargetFormAction::Mapping, translate.t(LABEL_ADD_MAPPING)) }
</Card>
}
} else {
html! {
<Card class="tp__config-view__card">
{ config_field_custom!(
translate.t(LABEL_MAPPING),
target_form_state.form.mapping.as_ref().map_or_else(String::new, |values| values.join(", "))
) }
</Card>
}
}
};
// Watch entries get their own page: a fixed input area on top, the
// configured entries below.
let render_watch = || {
let state = target_form_state.clone();
html! {
<Card class="tp__config-view__card">
{ config_field_child!(translate.t(LABEL_WATCH), dto_field_id(&target_form_state.form, "watch"), {
html! {
<WatchEntriesEditor
entries={Rc::new(target_form_state.form.watch.clone().unwrap_or_default())}
readonly={!props.allow_write}
on_change={Callback::from(move |entries: Vec<ConfigWatchDto>| {
state.dispatch(ConfigTargetFormAction::Watch((!entries.is_empty()).then_some(entries)));
})}
/>
}
})}
</Card>
}
};
let render_edit_mode = || {
html! {
<div class="tp__source-editor-form__body">
@@ -637,6 +678,12 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html {
<Panel value={TargetFormPage::Options.intern()} active={view_visible.intern()}>
{render_options()}
</Panel>
<Panel value={TargetFormPage::Mapping.intern()} active={view_visible.intern()}>
{render_mapping()}
</Panel>
<Panel value={TargetFormPage::Watch.intern()} active={view_visible.intern()}>
{render_watch()}
</Panel>
</div>
</div>
}
@@ -647,6 +694,8 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html {
<div class="tp__source-editor-form__sidebar">
<IconButton class={format!("tp__app-sidebar-menu--{}{}", TargetFormPage::Main, if *view_visible == TargetFormPage::Main { " active" } else {""})} icon="Settings" hint={translate.t(LABEL_MAIN)} name={TargetFormPage::Main.to_string()} onclick={&handle_menu_click}></IconButton>
<IconButton class={format!("tp__app-sidebar-menu--{}{}", TargetFormPage::Options, if *view_visible == TargetFormPage::Options { " active" } else {""})} icon="Options" hint={translate.t(LABEL_OPTIONS)} name={TargetFormPage::Options.to_string()} onclick={&handle_menu_click}></IconButton>
<IconButton class={format!("tp__app-sidebar-menu--{}{}", TargetFormPage::Mapping, if *view_visible == TargetFormPage::Mapping { " active" } else {""})} icon="Mapping" hint={translate.t(LABEL_MAPPING)} name={TargetFormPage::Mapping.to_string()} onclick={&handle_menu_click}></IconButton>
<IconButton class={format!("tp__app-sidebar-menu--{}{}", TargetFormPage::Watch, if *view_visible == TargetFormPage::Watch { " active" } else {""})} icon="Visibility" hint={translate.t(LABEL_WATCH)} name={TargetFormPage::Watch.to_string()} onclick={&handle_menu_click}></IconButton>
</div>
}
};
@@ -0,0 +1,286 @@
//! Editor for a target's `watch` entries: a pattern plus the contexts it
//! reports (`channels`, `group_added`, `group_removed`).
use crate::{
app::components::{Chip, IconButton, RadioButtonGroup},
i18n::use_translation,
};
use shared::model::{ConfigWatchDto, Prepare, WatchContext, WatchContextSet};
use std::rc::Rc;
use web_sys::HtmlInputElement;
use yew::prelude::*;
/// Context, its config spelling (the selection value), and its label key.
const CONTEXTS: [(WatchContext, &str, &str); 3] = [
(WatchContext::Channels, "channels", "LABEL.WATCH_CHANNELS"),
(WatchContext::GroupAdded, "group_added", "LABEL.WATCH_GROUP_ADDED"),
(WatchContext::GroupRemoved, "group_removed", "LABEL.WATCH_GROUP_REMOVED"),
];
/// The selection the context picker shows for `on`.
fn context_names(on: WatchContextSet) -> Vec<String> {
CONTEXTS.iter().filter(|(context, _, _)| on.contains(*context)).map(|(_, name, _)| (*name).to_string()).collect()
}
/// The contexts a picker selection stands for.
fn contexts_from_names(names: &[String]) -> WatchContextSet {
let mut on = WatchContextSet::new();
for (context, name, _) in CONTEXTS {
if names.iter().any(|selected| selected == name) {
on.set(context);
}
}
on
}
/// What a fresh input row starts with: the short form.
fn default_contexts() -> WatchContextSet { ConfigWatchDto::channels("").on }
#[derive(Properties, Clone, PartialEq)]
pub struct WatchEntriesEditorProps {
pub entries: Rc<Vec<ConfigWatchDto>>,
#[prop_or_else(Callback::noop)]
pub on_change: Callback<Vec<ConfigWatchDto>>,
#[prop_or(true)]
pub readonly: bool,
}
#[derive(Debug, PartialEq)]
enum CandidateDecision {
Accepted(ConfigWatchDto),
/// Blank, duplicate or without a context: nothing to add, nothing to say.
Rejected,
/// The entry would fail config validation; the message says why.
Invalid(String),
}
/// An entry with no context or a blank or duplicate pattern is never added,
/// so the editor cannot produce a config that `prepare` rejects.
///
/// The pattern is kept verbatim: whitespace is part of a regex, and config
/// load does not trim either. Trimming only detects blank input.
fn candidate(pattern: &str, on: WatchContextSet, entries: &[ConfigWatchDto]) -> CandidateDecision {
if pattern.trim().is_empty() || on.is_empty() || entries.iter().any(|entry| entry.pattern == pattern) {
return CandidateDecision::Rejected;
}
let mut entry = ConfigWatchDto { pattern: pattern.to_string(), on };
match entry.prepare(()) {
Ok(()) => CandidateDecision::Accepted(entry),
Err(err) => CandidateDecision::Invalid(err.to_string()),
}
}
/// Append `pattern` for the short form, `pattern [context, ...]` otherwise.
pub fn push_watch_entry_label(out: &mut String, entry: &ConfigWatchDto, translate: impl Fn(&str) -> String) {
out.push_str(&entry.pattern);
if entry.on == default_contexts() {
return;
}
let mut separator = " [";
for (context, _, key) in CONTEXTS {
if entry.has(context) {
out.push_str(separator);
out.push_str(&translate(key));
separator = ", ";
}
}
out.push(']');
}
/// [`push_watch_entry_label`] into a fresh `String`.
pub fn watch_entry_label(entry: &ConfigWatchDto, translate: impl Fn(&str) -> String) -> String {
let mut label = String::new();
push_watch_entry_label(&mut label, entry, translate);
label
}
#[component]
pub fn WatchEntriesEditor(props: &WatchEntriesEditorProps) -> Html {
// Shared with the callbacks by reference count; a list is only copied
// when an edit actually produces a new one.
let entries = Rc::clone(&props.entries);
let on_change = props.on_change.clone();
let readonly = props.readonly;
let translate = use_translation();
let pattern = use_state(String::new);
let on = use_state(default_contexts);
// Why the last add was refused; cleared as soon as the pattern changes.
let error = use_state(|| None::<String>);
let on_remove = {
let entries = Rc::clone(&entries);
let on_change = on_change.clone();
Callback::from(move |removed: usize| {
let mut updated = entries.as_ref().clone();
if removed < updated.len() {
updated.remove(removed);
on_change.emit(updated);
}
})
};
let on_input = {
let pattern = pattern.clone();
let error = error.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
pattern.set(input.value());
if error.is_some() {
error.set(None);
}
})
};
let add = {
let entries = Rc::clone(&entries);
let pattern = pattern.clone();
let on = on.clone();
let error = error.clone();
Callback::from(move |()| match candidate(&pattern, *on, &entries) {
CandidateDecision::Accepted(entry) => {
let mut updated = entries.as_ref().clone();
updated.push(entry);
on_change.emit(updated);
pattern.set(String::new());
on.set(default_contexts());
}
CandidateDecision::Invalid(message) => error.set(Some(message)),
CandidateDecision::Rejected => {}
})
};
let on_keydown = {
let add = add.clone();
Callback::from(move |e: KeyboardEvent| {
if e.key() == "Enter" {
e.prevent_default();
e.stop_propagation();
add.emit(());
}
})
};
// At least one context stays selected: the group refuses to drop the
// last one, so an entry with an empty `on` cannot be built here.
let context_options = use_memo((), |()| CONTEXTS.iter().map(|(_, name, _)| (*name).to_string()).collect());
let context_labels = Rc::new(CONTEXTS.iter().map(|(_, _, key)| translate.t(key)).collect::<Vec<_>>());
let on_select_contexts = {
let on = on.clone();
Callback::from(move |names: Rc<Vec<String>>| on.set(contexts_from_names(&names)))
};
let on_add_click = Callback::from(move |(_name, e): (String, MouseEvent)| {
e.prevent_default();
e.stop_propagation();
add.emit(());
});
html! {
<div class="tp__watch-entries-editor">
if !readonly {
// Fixed input area: the list below scrolls, this stays put.
<div class="tp__watch-entries-editor__form">
<div class="tp__watch-entries-editor__pattern">
<div class={classes!("tp__input", error.is_some().then_some("invalid"))}>
<div class="tp__input-wrapper">
<input
type="text"
value={(*pattern).clone()}
oninput={on_input}
onkeydown={on_keydown}
placeholder={translate.t("LABEL.ADD_WATCH")}
/>
</div>
</div>
<IconButton name={"AddWatch"} icon="Add" onclick={on_add_click} />
</div>
<RadioButtonGroup
multi_select={true}
none_allowed={false}
options={context_options}
labels={Some(context_labels)}
selected={Rc::new(context_names(*on))}
on_select={on_select_contexts}
/>
if let Some(message) = (*error).as_ref() {
<span class="tp__watch-entries-editor__error">{ message }</span>
}
</div>
}
<div class="tp__watch-entries-editor__entries">
{ for entries.iter().enumerate().map(|(index, entry)| {
html! {
<Chip
label={watch_entry_label(entry, |key| translate.t(key))}
removable={!readonly}
on_remove={if readonly { Callback::noop() } else { on_remove.reform(move |_| index) }}
/>
}
})}
</div>
</div>
}
}
#[cfg(test)]
mod tests {
use super::{candidate, context_names, contexts_from_names, watch_entry_label, CandidateDecision};
use shared::model::{ConfigWatchDto, WatchContext, WatchContextSet};
fn set(contexts: &[WatchContext]) -> WatchContextSet { ConfigWatchDto::new("", contexts.iter().copied()).on }
#[test]
fn blank_pattern_is_rejected() {
assert_eq!(candidate(" ", set(&[WatchContext::Channels]), &[]), CandidateDecision::Rejected);
}
#[test]
fn no_context_is_rejected() {
assert_eq!(candidate("^A", WatchContextSet::new(), &[]), CandidateDecision::Rejected);
}
#[test]
fn duplicate_pattern_is_rejected() {
let existing = [ConfigWatchDto::channels("^A")];
assert_eq!(candidate("^A", set(&[WatchContext::GroupAdded]), &existing), CandidateDecision::Rejected);
}
/// An invalid regex used to be added and only fail on save; the editor
/// now rejects it with the same message config load would give.
#[test]
fn invalid_regex_is_reported() {
match candidate("^(Sport", set(&[WatchContext::Channels]), &[]) {
CandidateDecision::Invalid(message) => {
assert!(message.contains("Invalid watch regular expression"), "{message}");
}
other => panic!("expected Invalid, got {other:?}"),
}
}
/// Whitespace is part of a regex: `"^Sport "` must not become `"^Sport"`.
#[test]
fn valid_candidate_keeps_its_pattern_verbatim() {
assert_eq!(
candidate("^Sport ", set(&[WatchContext::GroupAdded]), &[]),
CandidateDecision::Accepted(ConfigWatchDto::new("^Sport ", [WatchContext::GroupAdded]))
);
}
#[test]
fn selection_round_trips_through_the_contexts_set() {
let on = set(&[WatchContext::GroupRemoved, WatchContext::Channels]);
let names = context_names(on);
assert_eq!(names.as_slice(), ["channels", "group_removed"]);
assert_eq!(contexts_from_names(&names), on);
}
#[test]
fn label_shows_contexts_only_for_object_form() {
let t = |key: &str| key.rsplit('.').next().unwrap_or_default().to_lowercase();
assert_eq!(watch_entry_label(&ConfigWatchDto::channels("^A"), t), "^A");
assert_eq!(
watch_entry_label(&ConfigWatchDto::new("^B", [WatchContext::GroupAdded, WatchContext::Channels]), t),
"^B [watch_channels, watch_group_added]"
);
}
}
-1
View File
@@ -9,7 +9,6 @@ workspace = true
[dependencies]
aes = "0.8.4"
bitflags = "2.13.1"
ciborium = "0.2.2"
hex = "0.4.3"
serde_tuple = "1.1.3"
+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 }
}
}