mirror of
https://github.com/euzu/tuliprox.git
synced 2026-09-29 04:22:33 +02:00
New Features
Added separate processing and persistence filters, with empty/non-empty checks and expanded filter fields.
Added persistence-stage filter controls in target settings.
Added clear_invalid_epg_ids, preserving playlist entries while clearing unresolved EPG IDs; the legacy setting remains supported.
Added safer, atomic source configuration updates that preserve comments and formatting.
Bug Fixes
Null-like Xtream and Stalker values are now handled correctly.
Duplicate credential warnings provide clearer details.
957 lines
38 KiB
Rust
957 lines
38 KiB
Rust
//! Open-world notification event identity.
|
|
//!
|
|
//! [`MsgKind`](crate::model::MsgKind) is a closed enum: adding one event
|
|
//! kind meant editing eight sites across three crates, and two of those
|
|
//! sites failed silently rather than at compile time. This module is the
|
|
//! replacement extension point.
|
|
//!
|
|
//! An event is identified by a dotted `domain.event` string. Subscriptions
|
|
//! are glob patterns. Adding an event is one [`EventId`] const plus one
|
|
//! emit call - no match arms, no template-context field, no discovery
|
|
//! array to keep in sync.
|
|
//!
|
|
//! # Backward compatibility
|
|
//!
|
|
//! The eight legacy `MsgKind` wire names (`info`, `stats`, `disk_alert`,
|
|
//! ...) stay valid wherever they were accepted before: as `notify_on`
|
|
//! entries and as template filenames. [`EventId::from_wire`] resolves them
|
|
//! through [`LEGACY_ALIASES`], so an existing `config.yml` keeps working
|
|
//! untouched.
|
|
|
|
use std::fmt;
|
|
|
|
/// Stable dotted identity of a notification event.
|
|
///
|
|
/// The inner string is the wire form: it appears in `notify_on` patterns,
|
|
/// in the outbox file, in template filenames and in metric labels, so it
|
|
/// must stay stable across releases once published.
|
|
///
|
|
/// Backed by `&'static str` because every event is declared as a const in
|
|
/// [`registry`]. Plugin-registered events would need an owned variant;
|
|
/// that is deliberately deferred until the plugin host exists.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
|
pub struct EventId(&'static str);
|
|
|
|
impl EventId {
|
|
/// Declare an event id. `const` so the registry is built at compile time.
|
|
#[must_use]
|
|
pub const fn new(id: &'static str) -> Self { Self(id) }
|
|
|
|
#[must_use]
|
|
pub const fn as_str(&self) -> &'static str { self.0 }
|
|
|
|
/// The `domain` part - everything before the first dot.
|
|
///
|
|
/// Used to group events in the UI and to answer "does this subscription
|
|
/// cover the whole domain".
|
|
#[must_use]
|
|
pub fn domain(&self) -> &'static str {
|
|
match self.0.split_once('.') {
|
|
Some((domain, _)) => domain,
|
|
None => self.0,
|
|
}
|
|
}
|
|
|
|
/// Filename-safe form: dots become underscores.
|
|
///
|
|
/// `recording.completed` yields `recording_completed`, which is exactly
|
|
/// the legacy template filename for that kind - so recording templates
|
|
/// are discovered by the canonical id with no alias lookup needed.
|
|
#[must_use]
|
|
pub fn file_stem(&self) -> String { self.0.replace('.', "_") }
|
|
|
|
/// `{prefix}_{file_stem}.templ`, the on-disk template name.
|
|
#[must_use]
|
|
pub fn template_filename(&self, prefix: &str) -> String { format!("{prefix}_{}.templ", self.file_stem()) }
|
|
|
|
/// Resolve a wire string to a known event id.
|
|
///
|
|
/// Accepts the canonical dotted id and every legacy `MsgKind` wire
|
|
/// name. Returns `None` for an id that is not in the registry, which is
|
|
/// what lets config validation reject a typo instead of silently
|
|
/// subscribing to an event that will never fire.
|
|
#[must_use]
|
|
pub fn from_wire(s: &str) -> Option<Self> {
|
|
if let Some((_, id)) = LEGACY_ALIASES.iter().find(|(legacy, _)| legacy.eq_ignore_ascii_case(s)) {
|
|
return Some(*id);
|
|
}
|
|
registry::ALL.iter().find(|d| d.id.0.eq_ignore_ascii_case(s)).map(|d| d.id)
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for EventId {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.0) }
|
|
}
|
|
|
|
impl serde::Serialize for EventId {
|
|
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> { s.serialize_str(self.0) }
|
|
}
|
|
|
|
impl<'de> serde::Deserialize<'de> for EventId {
|
|
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
|
let raw = <std::borrow::Cow<'de, str>>::deserialize(d)?;
|
|
// An unknown id in a persisted outbox entry must not fail the whole
|
|
// file - see `UNKNOWN`. Config validation rejects typos separately,
|
|
// where an error message can actually reach the user.
|
|
Ok(Self::from_wire(&raw).unwrap_or(registry::UNKNOWN))
|
|
}
|
|
}
|
|
|
|
/// How much the operator is expected to care.
|
|
///
|
|
/// Ordered, so a channel can subscribe with `min_severity` and get
|
|
/// everything at or above it.
|
|
#[derive(
|
|
Debug,
|
|
Clone,
|
|
Copy,
|
|
PartialEq,
|
|
Eq,
|
|
Hash,
|
|
PartialOrd,
|
|
Ord,
|
|
Default,
|
|
serde::Serialize,
|
|
serde::Deserialize,
|
|
strum_macros::Display,
|
|
strum_macros::EnumString,
|
|
strum_macros::IntoStaticStr,
|
|
)]
|
|
#[serde(rename_all = "snake_case")]
|
|
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
|
|
pub enum Severity {
|
|
/// Something finished normally. Safe to route nowhere.
|
|
#[default]
|
|
Info,
|
|
/// Degraded but self-correcting, or a threshold approached.
|
|
Warn,
|
|
/// An operation failed. Someone should look, eventually.
|
|
Error,
|
|
/// Service is impaired now. Worth waking someone.
|
|
Critical,
|
|
}
|
|
|
|
impl Severity {
|
|
#[must_use]
|
|
pub const fn wire_name(self) -> &'static str {
|
|
match self {
|
|
Self::Info => "info",
|
|
Self::Warn => "warn",
|
|
Self::Error => "error",
|
|
Self::Critical => "critical",
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn from_wire(s: &str) -> Option<Self> { s.parse().ok() }
|
|
}
|
|
|
|
/// One entry in the event registry.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct EventDescriptor {
|
|
pub id: EventId,
|
|
/// Severity the emitter uses unless it overrides per-occurrence.
|
|
pub severity: Severity,
|
|
/// One line, rendered into the docs table and the UI picker.
|
|
pub description: &'static str,
|
|
}
|
|
|
|
/// Legacy `MsgKind` wire name -> canonical id.
|
|
///
|
|
/// Every entry here is load-bearing for an existing `config.yml`. Removing
|
|
/// one silently stops honouring a `notify_on` line that used to work, so
|
|
/// entries are append-only.
|
|
pub const LEGACY_ALIASES: &[(&str, EventId)] = &[
|
|
("info", registry::SYSTEM_INFO),
|
|
("stats", registry::PLAYLIST_UPDATE_COMPLETED),
|
|
("error", registry::SYSTEM_ERROR),
|
|
("watch", registry::PLAYLIST_WATCH_CHANGED),
|
|
("disk_alert", registry::SYSTEM_DISK_ALERT),
|
|
("diskalert", registry::SYSTEM_DISK_ALERT),
|
|
("recording_started", registry::RECORDING_STARTED),
|
|
("recordingstarted", registry::RECORDING_STARTED),
|
|
("recording_completed", registry::RECORDING_COMPLETED),
|
|
("recordingcompleted", registry::RECORDING_COMPLETED),
|
|
("recording_failed", registry::RECORDING_FAILED),
|
|
("recordingfailed", registry::RECORDING_FAILED),
|
|
];
|
|
|
|
/// The known event ids.
|
|
///
|
|
/// Adding an event is a const here plus an entry in [`ALL`] - and then one
|
|
/// emit call at the site that knows the event happened. Nothing else in
|
|
/// the notification path needs to change.
|
|
pub mod registry {
|
|
use super::{EventDescriptor, EventId, Severity};
|
|
|
|
/// Fallback for an id read back from a persisted outbox that this build
|
|
/// does not know. Lets an outbox written by a newer version round-trip
|
|
/// through an older one instead of failing the whole file.
|
|
pub const UNKNOWN: EventId = EventId::new("unknown");
|
|
|
|
// ---- system ---------------------------------------------------------
|
|
pub const SYSTEM_INFO: EventId = EventId::new("system.info");
|
|
pub const SYSTEM_ERROR: EventId = EventId::new("system.error");
|
|
pub const SYSTEM_DISK_ALERT: EventId = EventId::new("system.disk.alert");
|
|
pub const SYSTEM_STARTED: EventId = EventId::new("system.started");
|
|
pub const SYSTEM_SHUTDOWN: EventId = EventId::new("system.shutdown");
|
|
|
|
// ---- playlist -------------------------------------------------------
|
|
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");
|
|
|
|
// ---- recording ------------------------------------------------------
|
|
pub const RECORDING_STARTED: EventId = EventId::new("recording.started");
|
|
pub const RECORDING_COMPLETED: EventId = EventId::new("recording.completed");
|
|
pub const RECORDING_FAILED: EventId = EventId::new("recording.failed");
|
|
|
|
// ---- provider -------------------------------------------------------
|
|
pub const PROVIDER_ACCOUNT_STATUS: EventId = EventId::new("provider.account.status_changed");
|
|
pub const PROVIDER_ACCOUNT_EXPIRING: EventId = EventId::new("provider.account.expiring");
|
|
pub const PROVIDER_ACCOUNT_EXPIRED: EventId = EventId::new("provider.account.expired");
|
|
pub const PROVIDER_FETCH_FAILED: EventId = EventId::new("provider.fetch.failed");
|
|
pub const PROVIDER_POOL_EXHAUSTED: EventId = EventId::new("provider.pool.exhausted");
|
|
pub const PROVIDER_PRIORITY_FALLBACK: EventId = EventId::new("provider.priority.fallback");
|
|
|
|
// ---- config ---------------------------------------------------------
|
|
pub const CONFIG_CHANGED: EventId = EventId::new("config.changed");
|
|
pub const CONFIG_RELOAD_FAILED: EventId = EventId::new("config.reload_failed");
|
|
|
|
// ---- library --------------------------------------------------------
|
|
pub const LIBRARY_SCAN_COMPLETED: EventId = EventId::new("library.scan.completed");
|
|
pub const LIBRARY_SCAN_FAILED: EventId = EventId::new("library.scan.failed");
|
|
|
|
// ---- metadata -------------------------------------------------------
|
|
pub const METADATA_UPDATE_STARTED: EventId = EventId::new("metadata.update.started");
|
|
pub const METADATA_UPDATE_COMPLETED: EventId = EventId::new("metadata.update.completed");
|
|
pub const METADATA_UPDATE_FAILED: EventId = EventId::new("metadata.update.failed");
|
|
|
|
// ---- users and connections ------------------------------------------
|
|
/// High frequency. Subscribe deliberately.
|
|
pub const USER_CONNECTION_CHANGED: EventId = EventId::new("user.connection.changed");
|
|
/// High frequency. Subscribe deliberately.
|
|
pub const PROVIDER_CONNECTIONS_CHANGED: EventId = EventId::new("provider.connections.changed");
|
|
/// A user was refused a connection because their limits were full. Not
|
|
/// high frequency: reported per refusal, and a refusal is not routine.
|
|
pub const USER_CONNECTION_DENIED: EventId = EventId::new("user.connection.denied");
|
|
|
|
// ---- user accounts ---------------------------------------------------
|
|
pub const USER_CREATED: EventId = EventId::new("user.created");
|
|
pub const USER_UPDATED: EventId = EventId::new("user.updated");
|
|
pub const USER_DELETED: EventId = EventId::new("user.deleted");
|
|
|
|
// ---- authentication --------------------------------------------------
|
|
pub const AUTH_SIGN_IN_SUCCEEDED: EventId = EventId::new("auth.sign_in.succeeded");
|
|
pub const AUTH_SIGN_IN_FAILED: EventId = EventId::new("auth.sign_in.failed");
|
|
pub const AUTH_SIGN_IN_THROTTLED: EventId = EventId::new("auth.sign_in.throttled");
|
|
pub const AUTH_PERMISSION_DENIED: EventId = EventId::new("auth.permission.denied");
|
|
|
|
// ---- streams ---------------------------------------------------------
|
|
pub const STREAM_PROBE_FAILED: EventId = EventId::new("stream.probe.failed");
|
|
|
|
// ---- dvr ------------------------------------------------------------
|
|
pub const RECORDING_QUEUE_CHANGED: EventId = EventId::new("recording.queue.changed");
|
|
pub const RECORDING_RULES_CHANGED: EventId = EventId::new("recording.rules.changed");
|
|
|
|
// ---- scheduler -------------------------------------------------------
|
|
pub const SCHEDULED_TASK_FAILED: EventId = EventId::new("scheduled_task.failed");
|
|
|
|
// ---- notification self-reporting ------------------------------------
|
|
/// A notification was permanently lost. Must never route back through
|
|
/// the channel that dropped it.
|
|
pub const NOTIFICATION_DEAD_LETTERED: EventId = EventId::new("notification.dead_lettered");
|
|
|
|
/// Every registered event, in display order.
|
|
pub const ALL: &[EventDescriptor] = &[
|
|
EventDescriptor { id: SYSTEM_INFO, severity: Severity::Info, description: "A general informational message." },
|
|
EventDescriptor { id: SYSTEM_ERROR, severity: Severity::Error, description: "A general error message." },
|
|
EventDescriptor {
|
|
id: SYSTEM_DISK_ALERT,
|
|
severity: Severity::Warn,
|
|
description: "Disk usage crossed the warn or critical threshold.",
|
|
},
|
|
EventDescriptor {
|
|
id: SYSTEM_STARTED,
|
|
severity: Severity::Info,
|
|
description: "The server finished starting up.",
|
|
},
|
|
EventDescriptor {
|
|
id: SYSTEM_SHUTDOWN,
|
|
severity: Severity::Info,
|
|
description: "The server is shutting down cleanly.",
|
|
},
|
|
EventDescriptor {
|
|
id: PLAYLIST_UPDATE_COMPLETED,
|
|
severity: Severity::Info,
|
|
description: "A playlist update finished; carries per-source statistics.",
|
|
},
|
|
EventDescriptor {
|
|
id: PLAYLIST_UPDATE_FAILED,
|
|
severity: Severity::Error,
|
|
description: "A playlist update failed.",
|
|
},
|
|
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.",
|
|
},
|
|
EventDescriptor {
|
|
id: PLAYLIST_WATCH_DISABLED,
|
|
severity: Severity::Warn,
|
|
description: "A target's watch configuration is set but not working.",
|
|
},
|
|
EventDescriptor {
|
|
id: PLAYLIST_WATCH_UNMATCHED,
|
|
severity: Severity::Warn,
|
|
description: "Watch patterns matched no group in the refreshed playlist.",
|
|
},
|
|
EventDescriptor { id: RECORDING_STARTED, severity: Severity::Info, description: "A recording started." },
|
|
EventDescriptor { id: RECORDING_COMPLETED, severity: Severity::Info, description: "A recording completed." },
|
|
EventDescriptor { id: RECORDING_FAILED, severity: Severity::Error, description: "A recording failed." },
|
|
EventDescriptor {
|
|
id: PROVIDER_ACCOUNT_STATUS,
|
|
severity: Severity::Warn,
|
|
description: "A provider reported a changed account status.",
|
|
},
|
|
EventDescriptor {
|
|
id: PROVIDER_ACCOUNT_EXPIRING,
|
|
severity: Severity::Warn,
|
|
description: "A provider account is approaching its expiry date.",
|
|
},
|
|
EventDescriptor {
|
|
id: PROVIDER_ACCOUNT_EXPIRED,
|
|
severity: Severity::Error,
|
|
description: "A provider account has expired.",
|
|
},
|
|
EventDescriptor {
|
|
id: PROVIDER_FETCH_FAILED,
|
|
severity: Severity::Error,
|
|
description: "An input's playlist could not be fetched.",
|
|
},
|
|
EventDescriptor {
|
|
id: PROVIDER_POOL_EXHAUSTED,
|
|
severity: Severity::Warn,
|
|
description: "Every provider behind an input was at capacity.",
|
|
},
|
|
EventDescriptor {
|
|
id: PROVIDER_PRIORITY_FALLBACK,
|
|
severity: Severity::Warn,
|
|
description: "An input started being served from a different provider priority group.",
|
|
},
|
|
EventDescriptor {
|
|
id: CONFIG_CHANGED,
|
|
severity: Severity::Info,
|
|
description: "A configuration file was changed and reloaded.",
|
|
},
|
|
EventDescriptor {
|
|
id: CONFIG_RELOAD_FAILED,
|
|
severity: Severity::Error,
|
|
description: "A configuration file changed but could not be reloaded.",
|
|
},
|
|
EventDescriptor {
|
|
id: LIBRARY_SCAN_COMPLETED,
|
|
severity: Severity::Info,
|
|
description: "A local library scan finished.",
|
|
},
|
|
EventDescriptor {
|
|
id: LIBRARY_SCAN_FAILED,
|
|
severity: Severity::Error,
|
|
description: "A local library scan could not complete.",
|
|
},
|
|
EventDescriptor {
|
|
id: METADATA_UPDATE_STARTED,
|
|
severity: Severity::Info,
|
|
description: "A metadata update started for an input.",
|
|
},
|
|
EventDescriptor {
|
|
id: METADATA_UPDATE_COMPLETED,
|
|
severity: Severity::Info,
|
|
description: "A metadata update finished for an input.",
|
|
},
|
|
EventDescriptor {
|
|
id: METADATA_UPDATE_FAILED,
|
|
severity: Severity::Error,
|
|
description: "A metadata update cycle ended with tasks it could not finish.",
|
|
},
|
|
EventDescriptor {
|
|
id: USER_CONNECTION_CHANGED,
|
|
severity: Severity::Info,
|
|
description: "A user connected or disconnected. High frequency - subscribe deliberately.",
|
|
},
|
|
EventDescriptor {
|
|
id: PROVIDER_CONNECTIONS_CHANGED,
|
|
severity: Severity::Info,
|
|
description: "A provider's active connection count changed. High frequency - subscribe deliberately.",
|
|
},
|
|
EventDescriptor {
|
|
id: USER_CONNECTION_DENIED,
|
|
severity: Severity::Warn,
|
|
description: "A user was refused a connection because their limits were full.",
|
|
},
|
|
EventDescriptor {
|
|
id: USER_CREATED,
|
|
severity: Severity::Info,
|
|
description: "An API-proxy user account was created.",
|
|
},
|
|
EventDescriptor {
|
|
id: USER_UPDATED,
|
|
severity: Severity::Info,
|
|
description: "An API-proxy user account was changed.",
|
|
},
|
|
EventDescriptor {
|
|
id: USER_DELETED,
|
|
severity: Severity::Warn,
|
|
description: "An API-proxy user account was deleted.",
|
|
},
|
|
EventDescriptor {
|
|
id: AUTH_SIGN_IN_SUCCEEDED,
|
|
severity: Severity::Info,
|
|
description: "A principal signed in and was issued a token.",
|
|
},
|
|
EventDescriptor {
|
|
id: AUTH_SIGN_IN_FAILED,
|
|
severity: Severity::Warn,
|
|
description: "A sign-in was rejected. Deduplicated per principal and address, so a password-guessing run notifies once rather than per attempt.",
|
|
},
|
|
EventDescriptor {
|
|
id: AUTH_SIGN_IN_THROTTLED,
|
|
severity: Severity::Warn,
|
|
description: "A sign-in was refused without checking credentials because the caller is backing off after repeated failures.",
|
|
},
|
|
EventDescriptor {
|
|
id: AUTH_PERMISSION_DENIED,
|
|
severity: Severity::Warn,
|
|
description: "An authenticated principal asked for something its permissions do not cover.",
|
|
},
|
|
EventDescriptor {
|
|
id: STREAM_PROBE_FAILED,
|
|
severity: Severity::Warn,
|
|
description: "A stream probe returned no metadata; the stream may be dead. Deduplicated per input, so a provider outage notifies once rather than per channel.",
|
|
},
|
|
EventDescriptor {
|
|
id: RECORDING_QUEUE_CHANGED,
|
|
severity: Severity::Info,
|
|
description: "The recording queue changed.",
|
|
},
|
|
EventDescriptor {
|
|
id: RECORDING_RULES_CHANGED,
|
|
severity: Severity::Info,
|
|
description: "The recording rule set changed.",
|
|
},
|
|
EventDescriptor {
|
|
id: SCHEDULED_TASK_FAILED,
|
|
severity: Severity::Error,
|
|
description: "A scheduled task could not complete.",
|
|
},
|
|
EventDescriptor {
|
|
id: NOTIFICATION_DEAD_LETTERED,
|
|
severity: Severity::Error,
|
|
description: "A notification was permanently undeliverable and has been dropped.",
|
|
},
|
|
];
|
|
|
|
/// Look up the descriptor for an id.
|
|
#[must_use]
|
|
pub fn describe(id: EventId) -> Option<&'static EventDescriptor> { ALL.iter().find(|d| d.id == id) }
|
|
|
|
/// Default severity for an id; `Info` for anything unregistered.
|
|
#[must_use]
|
|
pub fn default_severity(id: EventId) -> Severity { describe(id).map_or(Severity::Info, |d| d.severity) }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subscriptions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// One `notify_on` entry.
|
|
///
|
|
/// Grammar, deliberately small enough to explain in a config comment:
|
|
///
|
|
/// | pattern | matches |
|
|
/// |--------------------------|---------------------------------------------|
|
|
/// | `*` | every event |
|
|
/// | `recording.*` | every event under `recording` |
|
|
/// | `recording.completed` | that event only |
|
|
/// | `provider.*.expired` | one wildcard segment |
|
|
/// | `!recording.started` | excludes, whatever else matched |
|
|
///
|
|
/// A leading `!` negates. A subscription matches when at least one positive
|
|
/// pattern matches and no negative pattern does, so `["*", "!system.info"]`
|
|
/// reads the way it looks.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct EventPattern {
|
|
segments: Vec<Segment>,
|
|
/// `true` when the pattern is a `!` exclusion.
|
|
pub negated: bool,
|
|
raw: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
enum Segment {
|
|
/// A literal segment.
|
|
Literal(String),
|
|
/// `*` in an interior position - matches exactly one segment.
|
|
One,
|
|
/// A trailing `.*` - matches one or more remaining segments.
|
|
Rest,
|
|
}
|
|
|
|
impl fmt::Display for EventPattern {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.raw) }
|
|
}
|
|
|
|
impl EventPattern {
|
|
/// Parse a pattern. Never fails: an empty pattern simply matches nothing,
|
|
/// which is the safe reading of a blank config line.
|
|
#[must_use]
|
|
pub fn parse(raw: &str) -> Self {
|
|
let trimmed = raw.trim();
|
|
let (negated, body) = match trimmed.strip_prefix('!') {
|
|
Some(rest) => (true, rest.trim()),
|
|
None => (false, trimmed),
|
|
};
|
|
let parts: Vec<&str> = if body.is_empty() { Vec::new() } else { body.split('.').collect() };
|
|
let last = parts.len().saturating_sub(1);
|
|
let segments = parts
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, part)| match *part {
|
|
"*" if i == last => Segment::Rest,
|
|
"*" => Segment::One,
|
|
literal => Segment::Literal(literal.to_string()),
|
|
})
|
|
.collect();
|
|
Self { segments, negated, raw: trimmed.to_string() }
|
|
}
|
|
|
|
/// The pattern as written, for round-tripping back into config.
|
|
#[must_use]
|
|
pub fn as_str(&self) -> &str { &self.raw }
|
|
|
|
/// Does this pattern cover `id`? Ignores negation - the caller combines.
|
|
#[must_use]
|
|
pub fn matches(&self, id: EventId) -> bool { Self::match_segments(&self.segments, id.as_str()) }
|
|
|
|
fn match_segments(pattern: &[Segment], id: &str) -> bool {
|
|
let mut parts = id.split('.');
|
|
for (i, segment) in pattern.iter().enumerate() {
|
|
match segment {
|
|
// A trailing `*` swallows every remaining segment, and
|
|
// requires at least one so `recording.*` does not match a
|
|
// bare `recording`.
|
|
Segment::Rest => {
|
|
// A lone `*` pattern matches everything, including a
|
|
// single-segment id.
|
|
return if i == 0 { true } else { parts.next().is_some() };
|
|
}
|
|
Segment::One => {
|
|
if parts.next().is_none() {
|
|
return false;
|
|
}
|
|
}
|
|
Segment::Literal(want) => match parts.next() {
|
|
Some(got) if got.eq_ignore_ascii_case(want) => {}
|
|
_ => return false,
|
|
},
|
|
}
|
|
}
|
|
// Every pattern segment consumed; the id must be exhausted too.
|
|
parts.next().is_none()
|
|
}
|
|
}
|
|
|
|
/// A parsed `notify_on` list.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct EventSubscription {
|
|
patterns: Vec<EventPattern>,
|
|
}
|
|
|
|
impl EventSubscription {
|
|
#[must_use]
|
|
pub fn parse<I: IntoIterator<Item = S>, S: AsRef<str>>(raw: I) -> Self {
|
|
Self { patterns: raw.into_iter().map(|s| EventPattern::parse(s.as_ref())).collect() }
|
|
}
|
|
|
|
/// The parsed patterns, for round-tripping back into config.
|
|
#[must_use]
|
|
pub fn patterns(&self) -> &[EventPattern] { &self.patterns }
|
|
|
|
/// `true` when nothing is subscribed - the caller can skip all work.
|
|
#[must_use]
|
|
pub fn is_empty(&self) -> bool { self.patterns.is_empty() }
|
|
|
|
/// At least one positive pattern matches and no negative pattern does.
|
|
#[must_use]
|
|
pub fn matches(&self, id: EventId) -> bool {
|
|
let mut included = false;
|
|
for pattern in &self.patterns {
|
|
if pattern.matches(id) {
|
|
if pattern.negated {
|
|
return false;
|
|
}
|
|
included = true;
|
|
}
|
|
}
|
|
included
|
|
}
|
|
|
|
/// Patterns that match no registered event.
|
|
///
|
|
/// Config validation surfaces these as a warning: a typo in `notify_on`
|
|
/// is otherwise indistinguishable from an event that simply never fires.
|
|
#[must_use]
|
|
pub fn unmatched_patterns(&self) -> Vec<&str> {
|
|
self.patterns
|
|
.iter()
|
|
.filter(|p| !registry::ALL.iter().any(|d| p.matches(d.id)))
|
|
.map(EventPattern::as_str)
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{registry, EventId, EventPattern, EventSubscription, Severity, LEGACY_ALIASES};
|
|
|
|
#[test]
|
|
fn every_legacy_msgkind_wire_name_still_resolves() {
|
|
// Load-bearing for existing config.yml files: each of these was a
|
|
// valid `notify_on` entry before the open-world refactor.
|
|
for legacy in [
|
|
"info",
|
|
"stats",
|
|
"error",
|
|
"watch",
|
|
"disk_alert",
|
|
"recording_started",
|
|
"recording_completed",
|
|
"recording_failed",
|
|
] {
|
|
assert!(EventId::from_wire(legacy).is_some(), "legacy wire name {legacy} no longer resolves");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_alias_targets_are_all_registered() {
|
|
for (legacy, id) in LEGACY_ALIASES {
|
|
assert!(registry::describe(*id).is_some(), "alias {legacy} points at unregistered id {id}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn canonical_ids_resolve_and_round_trip() {
|
|
for descriptor in registry::ALL {
|
|
let resolved = EventId::from_wire(descriptor.id.as_str());
|
|
assert_eq!(resolved, Some(descriptor.id), "id {} did not round-trip", descriptor.id);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_wire_name_does_not_resolve() {
|
|
assert_eq!(EventId::from_wire("recording.definitely_not_a_real_event"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn recording_template_filenames_match_the_legacy_names() {
|
|
// The legacy on-disk names were `telegram_recording_completed.templ`
|
|
// and friends. The canonical dotted ids produce the same stems, so
|
|
// existing template files keep being discovered.
|
|
assert_eq!(registry::RECORDING_COMPLETED.template_filename("telegram"), "telegram_recording_completed.templ");
|
|
assert_eq!(registry::RECORDING_STARTED.template_filename("discord"), "discord_recording_started.templ");
|
|
assert_eq!(registry::RECORDING_FAILED.template_filename("rest"), "rest_recording_failed.templ");
|
|
}
|
|
|
|
#[test]
|
|
fn domain_is_the_part_before_the_first_dot() {
|
|
assert_eq!(registry::RECORDING_COMPLETED.domain(), "recording");
|
|
assert_eq!(registry::SYSTEM_DISK_ALERT.domain(), "system");
|
|
assert_eq!(registry::UNKNOWN.domain(), "unknown");
|
|
}
|
|
|
|
#[test]
|
|
fn star_matches_every_registered_event() {
|
|
let sub = EventSubscription::parse(["*"]);
|
|
for descriptor in registry::ALL {
|
|
assert!(sub.matches(descriptor.id), "`*` failed to match {}", descriptor.id);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn prefix_pattern_matches_the_domain_and_nothing_else() {
|
|
let sub = EventSubscription::parse(["recording.*"]);
|
|
assert!(sub.matches(registry::RECORDING_COMPLETED));
|
|
assert!(sub.matches(registry::RECORDING_FAILED));
|
|
assert!(!sub.matches(registry::SYSTEM_INFO));
|
|
assert!(!sub.matches(registry::PLAYLIST_UPDATE_COMPLETED));
|
|
}
|
|
|
|
#[test]
|
|
fn prefix_pattern_requires_at_least_one_further_segment() {
|
|
// `recording.*` must not match a hypothetical bare `recording`.
|
|
assert!(!EventPattern::parse("recording.*").matches(EventId::new("recording")));
|
|
}
|
|
|
|
#[test]
|
|
fn exact_pattern_matches_only_itself() {
|
|
let sub = EventSubscription::parse(["recording.completed"]);
|
|
assert!(sub.matches(registry::RECORDING_COMPLETED));
|
|
assert!(!sub.matches(registry::RECORDING_STARTED));
|
|
}
|
|
|
|
#[test]
|
|
fn interior_wildcard_matches_exactly_one_segment() {
|
|
let pattern = EventPattern::parse("provider.*.expired");
|
|
assert!(pattern.matches(registry::PROVIDER_ACCOUNT_EXPIRED));
|
|
// One segment, not several.
|
|
assert!(!pattern.matches(EventId::new("provider.a.b.expired")));
|
|
// And not zero.
|
|
assert!(!pattern.matches(EventId::new("provider.expired")));
|
|
}
|
|
|
|
#[test]
|
|
fn negation_removes_from_a_wider_match() {
|
|
let sub = EventSubscription::parse(["*", "!system.info"]);
|
|
assert!(sub.matches(registry::SYSTEM_ERROR));
|
|
assert!(!sub.matches(registry::SYSTEM_INFO), "negated pattern did not exclude");
|
|
}
|
|
|
|
#[test]
|
|
fn negation_wins_regardless_of_order() {
|
|
let before = EventSubscription::parse(["!recording.started", "recording.*"]);
|
|
let after = EventSubscription::parse(["recording.*", "!recording.started"]);
|
|
assert!(!before.matches(registry::RECORDING_STARTED));
|
|
assert!(!after.matches(registry::RECORDING_STARTED));
|
|
assert!(before.matches(registry::RECORDING_COMPLETED));
|
|
assert!(after.matches(registry::RECORDING_COMPLETED));
|
|
}
|
|
|
|
#[test]
|
|
fn a_subscription_of_only_negations_matches_nothing() {
|
|
// No positive pattern means nothing was opted in to.
|
|
let sub = EventSubscription::parse(["!system.info"]);
|
|
assert!(!sub.matches(registry::SYSTEM_ERROR));
|
|
assert!(!sub.matches(registry::SYSTEM_INFO));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_subscription_matches_nothing() {
|
|
let sub = EventSubscription::parse(Vec::<String>::new());
|
|
assert!(sub.is_empty());
|
|
assert!(!sub.matches(registry::SYSTEM_INFO));
|
|
}
|
|
|
|
#[test]
|
|
fn blank_pattern_matches_nothing_rather_than_everything() {
|
|
// A stray empty line in config must not silently subscribe to all.
|
|
let sub = EventSubscription::parse([" "]);
|
|
assert!(!sub.matches(registry::SYSTEM_INFO));
|
|
}
|
|
|
|
#[test]
|
|
fn unmatched_patterns_flags_typos_only() {
|
|
let sub = EventSubscription::parse(["recording.*", "recroding.completed", "*"]);
|
|
assert_eq!(sub.unmatched_patterns(), vec!["recroding.completed"]);
|
|
}
|
|
|
|
#[test]
|
|
fn severity_orders_from_info_up_to_critical() {
|
|
assert!(Severity::Info < Severity::Warn);
|
|
assert!(Severity::Warn < Severity::Error);
|
|
assert!(Severity::Error < Severity::Critical);
|
|
}
|
|
|
|
#[test]
|
|
fn severity_wire_names_round_trip() {
|
|
for severity in [Severity::Info, Severity::Warn, Severity::Error, Severity::Critical] {
|
|
assert_eq!(Severity::from_wire(severity.wire_name()), Some(severity));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_event_id_deserializes_to_unknown_instead_of_failing() {
|
|
// A newer build's outbox entry must not poison the whole file when
|
|
// read back by an older one.
|
|
let id: EventId = serde_json::from_str("\"some.future.event\"").expect("must not fail");
|
|
assert_eq!(id, registry::UNKNOWN);
|
|
}
|
|
|
|
#[test]
|
|
fn event_id_serializes_as_its_wire_string() {
|
|
let json = serde_json::to_string(®istry::RECORDING_COMPLETED).expect("serialize");
|
|
assert_eq!(json, "\"recording.completed\"");
|
|
}
|
|
|
|
/// The docs carry a table of every event id. A registry entry that never
|
|
/// reaches the table is undiscoverable, and a table row for an id that no
|
|
/// longer exists is a lie - so the two are checked against each other
|
|
/// rather than maintained by hand.
|
|
#[test]
|
|
fn every_registered_event_appears_in_the_docs_table() {
|
|
const DOCS: &str = include_str!("../../../docs/src/configuration/config.md");
|
|
let table = DOCS
|
|
.split_once("<!-- BEGIN GENERATED EVENT TABLE -->")
|
|
.and_then(|(_, rest)| rest.split_once("<!-- END GENERATED EVENT TABLE -->"))
|
|
.map(|(table, _)| table)
|
|
.expect("docs must contain the generated event table markers");
|
|
|
|
for descriptor in registry::ALL {
|
|
let row = format!("| `{}` | {} | {} |", descriptor.id, descriptor.severity, descriptor.description);
|
|
assert!(
|
|
table.contains(&row),
|
|
"docs/src/configuration/config.md is missing this row - add it inside the generated table:\n{row}"
|
|
);
|
|
}
|
|
|
|
// And nothing in the table that the registry no longer knows about.
|
|
for line in table.lines().filter(|line| line.trim_start().starts_with("| `")) {
|
|
let id = line.trim_start().trim_start_matches("| `").split('`').next().unwrap_or_default();
|
|
assert!(
|
|
EventId::from_wire(id).is_some(),
|
|
"docs list `{id}`, which is not a registered event - remove the row or register the event"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn registry_has_no_duplicate_ids() {
|
|
let mut seen = std::collections::HashSet::new();
|
|
for descriptor in registry::ALL {
|
|
assert!(seen.insert(descriptor.id), "duplicate registry entry for {}", descriptor.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Quiet hours
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A local-time window during which a channel stays silent.
|
|
///
|
|
/// Notifications landing inside the window are *deferred* by the outbox,
|
|
/// never dropped: an overnight outage that nobody is told about afterwards
|
|
/// is worse than one that arrives late.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct QuietHours {
|
|
/// Minutes since local midnight.
|
|
start_min: u16,
|
|
end_min: u16,
|
|
}
|
|
|
|
impl QuietHours {
|
|
/// Parse `HH:MM-HH:MM`. Returns `None` for anything malformed, which
|
|
/// config validation turns into an error the operator sees.
|
|
#[must_use]
|
|
pub fn parse(raw: &str) -> Option<Self> {
|
|
let (start, end) = raw.trim().split_once('-')?;
|
|
Some(Self { start_min: parse_hhmm(start)?, end_min: parse_hhmm(end)? })
|
|
}
|
|
|
|
/// Is `minutes_since_midnight` inside the window?
|
|
///
|
|
/// Handles the wrapping case (`23:00-07:00`), which is the one people
|
|
/// actually configure.
|
|
#[must_use]
|
|
pub fn contains(&self, minutes_since_midnight: u16) -> bool {
|
|
if self.start_min == self.end_min {
|
|
// A zero-width window silences nothing. Treating it as "always"
|
|
// would mute a channel completely on a typo.
|
|
return false;
|
|
}
|
|
if self.start_min < self.end_min {
|
|
(self.start_min..self.end_min).contains(&minutes_since_midnight)
|
|
} else {
|
|
minutes_since_midnight >= self.start_min || minutes_since_midnight < self.end_min
|
|
}
|
|
}
|
|
|
|
/// Minutes from `minutes_since_midnight` until the window ends.
|
|
#[must_use]
|
|
pub fn minutes_until_end(&self, minutes_since_midnight: u16) -> u16 {
|
|
if !self.contains(minutes_since_midnight) {
|
|
return 0;
|
|
}
|
|
if self.end_min > minutes_since_midnight {
|
|
self.end_min - minutes_since_midnight
|
|
} else {
|
|
(24 * 60 - minutes_since_midnight) + self.end_min
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_hhmm(raw: &str) -> Option<u16> {
|
|
let (h, m) = raw.trim().split_once(':')?;
|
|
let h: u16 = h.trim().parse().ok()?;
|
|
let m: u16 = m.trim().parse().ok()?;
|
|
if h > 23 || m > 59 {
|
|
return None;
|
|
}
|
|
Some(h * 60 + m)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod quiet_hours_tests {
|
|
use super::QuietHours;
|
|
|
|
#[test]
|
|
fn a_same_day_window_contains_only_its_own_range() {
|
|
let window = QuietHours::parse("09:00-17:00").expect("parse");
|
|
assert!(!window.contains(8 * 60 + 59));
|
|
assert!(window.contains(9 * 60));
|
|
assert!(window.contains(16 * 60 + 59));
|
|
// End is exclusive, so 17:00 sharp is out.
|
|
assert!(!window.contains(17 * 60));
|
|
}
|
|
|
|
#[test]
|
|
fn an_overnight_window_wraps_past_midnight() {
|
|
// The case people actually configure.
|
|
let window = QuietHours::parse("23:00-07:00").expect("parse");
|
|
assert!(window.contains(23 * 60));
|
|
assert!(window.contains(0));
|
|
assert!(window.contains(6 * 60 + 59));
|
|
assert!(!window.contains(7 * 60));
|
|
assert!(!window.contains(12 * 60));
|
|
}
|
|
|
|
#[test]
|
|
fn a_zero_width_window_silences_nothing() {
|
|
// Treating it as "always" would mute the channel entirely on a typo.
|
|
let window = QuietHours::parse("08:00-08:00").expect("parse");
|
|
for minute in [0, 8 * 60, 12 * 60, 23 * 60 + 59] {
|
|
assert!(!window.contains(minute), "zero-width window muted {minute}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn minutes_until_end_handles_both_directions() {
|
|
let same_day = QuietHours::parse("09:00-17:00").expect("parse");
|
|
assert_eq!(same_day.minutes_until_end(10 * 60), 7 * 60);
|
|
assert_eq!(same_day.minutes_until_end(18 * 60), 0, "outside the window there is nothing to wait for");
|
|
|
|
let overnight = QuietHours::parse("23:00-07:00").expect("parse");
|
|
assert_eq!(overnight.minutes_until_end(23 * 60), 8 * 60);
|
|
assert_eq!(overnight.minutes_until_end(60), 6 * 60);
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_windows_are_rejected_rather_than_guessed() {
|
|
for bad in ["", "23:00", "25:00-07:00", "23:60-07:00", "23-07", "abc-def", "23:00_07:00"] {
|
|
assert!(QuietHours::parse(bad).is_none(), "accepted malformed window `{bad}`");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn whitespace_is_tolerated() {
|
|
assert_eq!(QuietHours::parse(" 23:00 - 07:00 "), QuietHours::parse("23:00-07:00"));
|
|
}
|
|
}
|