aede_core/parental/
policy.rs

1//! Strict, bounded parental policy independent of HTTP and account credentials.
2use super::feeds::canonical_feed;
3use crate::model::EntityKind;
4use crate::user::EntityRef;
5use serde::{Deserialize, Serialize};
6
7/// One half-open local-clock window, split at midnight when necessary.
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(deny_unknown_fields)]
10pub struct Window {
11    /// Inclusive minute after midnight, from 0 through 1439.
12    pub start_minute: u16,
13    /// Exclusive minute after midnight, from 1 through 1440.
14    pub end_minute: u16,
15}
16/// Listening windows and optional budget for one weekday.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct DayPolicy {
20    /// At most eight non-overlapping windows; empty means no listening.
21    pub windows: Vec<Window>,
22    /// Optional maximum seconds; zero means no listening.
23    pub limit_seconds: Option<u32>,
24}
25impl Default for DayPolicy {
26    fn default() -> Self {
27        Self {
28            windows: vec![Window {
29                start_minute: 0,
30                end_minute: 1440,
31            }],
32            limit_seconds: None,
33        }
34    }
35}
36/// Administrator-owned restrictions for one stable account owner.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct Policy {
40    /// Disabled policies retain their settings but do not constrain listening.
41    pub enabled: bool,
42    /// IANA time-zone name used for civil dates, weeks and clock windows.
43    pub timezone: String,
44    /// Canonical, unfederated artist references, retained across catalogue scans.
45    pub blocked_artists: Vec<String>,
46    /// Whether independent radio listening is permitted.
47    pub radio_allowed: bool,
48    /// Whether podcasts are available; older policies retain their allowed default.
49    #[serde(default = "allow_podcasts")]
50    pub podcasts_allowed: bool,
51    /// Canonical RSS addresses whose episodes are refused.
52    pub blocked_podcasts: Vec<String>,
53    /// Monday first, Sunday last.
54    pub days: [DayPolicy; 7],
55    /// Optional maximum seconds in the local Monday-to-Monday week.
56    pub week_limit_seconds: Option<u32>,
57}
58impl Default for Policy {
59    fn default() -> Self {
60        Self {
61            enabled: false,
62            timezone: "UTC".into(),
63            blocked_artists: Vec::new(),
64            radio_allowed: true,
65            podcasts_allowed: true,
66            blocked_podcasts: Vec::new(),
67            days: std::array::from_fn(|_| DayPolicy::default()),
68            week_limit_seconds: None,
69        }
70    }
71}
72fn allow_podcasts() -> bool {
73    true
74}
75impl Policy {
76    /// Validate every setting without accepting an unknown zone or excess budget.
77    pub fn validate(&self) -> Result<(), String> {
78        if self.timezone.len() > 128 || jiff::tz::TimeZone::get(&self.timezone).is_err() {
79            return Err("invalid parental time zone".into());
80        }
81        if self.blocked_artists.len() > 256
82            || self.blocked_podcasts.len() > 128
83            || self.week_limit_seconds.is_some_and(|limit| limit > 604_800)
84        {
85            return Err("parental policy exceeds its bounds".into());
86        }
87        for token in &self.blocked_artists {
88            let reference =
89                EntityRef::parse_token(token).ok_or("invalid blocked artist reference")?;
90            if reference.kind != EntityKind::Artist
91                || reference.key.is_empty()
92                || token.len() > 16_384
93                || reference.key.starts_with("@phemios/")
94                || token.chars().any(char::is_control)
95            {
96                return Err("invalid blocked artist reference".into());
97            }
98        }
99        for feed in &self.blocked_podcasts {
100            canonical_feed(feed)?;
101        }
102        for day in &self.days {
103            if day.windows.len() > 8 || day.limit_seconds.is_some_and(|limit| limit > 86_400) {
104                return Err("parental day exceeds its bounds".into());
105            }
106            let mut windows = day.windows.iter().collect::<Vec<_>>();
107            windows.sort_by_key(|window| window.start_minute);
108            let mut previous_end = 0;
109            for window in windows {
110                if window.start_minute >= window.end_minute
111                    || window.end_minute > 1440
112                    || window.start_minute < previous_end
113                {
114                    return Err(
115                        "parental windows must be ordered, non-overlapping intervals".into(),
116                    );
117                }
118                previous_end = window.end_minute;
119            }
120        }
121        Ok(())
122    }
123    /// Canonicalize equivalent addresses and deterministic order after validation.
124    pub fn canonicalize(mut self) -> Result<Self, String> {
125        self.validate()?;
126        for feed in &mut self.blocked_podcasts {
127            *feed = canonical_feed(feed)?;
128        }
129        for day in &mut self.days {
130            day.windows.sort_by_key(|window| window.start_minute);
131        }
132        self.blocked_artists.sort();
133        self.blocked_artists.dedup();
134        self.blocked_podcasts.sort();
135        self.blocked_podcasts.dedup();
136        Ok(self)
137    }
138}
139/// The source category captured by an admitted audio transport.
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
142pub enum Media {
143    /// Catalogue audio; reference admission is additionally checked on its visible graph.
144    Music {
145        /// Optional unfederated current track reference.
146        track: Option<String>,
147    },
148    /// An independent radio stream.
149    Radio,
150    /// An episode belonging to this original RSS subscription address.
151    Podcast {
152        /// RSS address, never the unrelated enclosure URL.
153        feed: String,
154    },
155}
156/// Revisioned policy projection without credentials or another owner's usage.
157#[derive(Debug, Clone, Serialize)]
158pub struct PolicySnapshot {
159    /// Immutable account owner.
160    pub owner: String,
161    /// Monotone policy generation; absent policies start at zero.
162    pub revision: u64,
163    /// Stable supervising administrator; absent after the rules are disabled.
164    pub locked_by: Option<String>,
165    /// Complete editable policy.
166    pub policy: Policy,
167}
168/// Current admission and conservative reserved listening budget.
169#[derive(Debug, Clone, Serialize)]
170pub struct Status {
171    /// Immutable account owner.
172    pub owner: String,
173    /// Current policy generation.
174    pub revision: u64,
175    /// Current complete policy.
176    pub policy: Policy,
177    /// Union of settled and reserved audio in the local day, rounded upwards.
178    pub day_used_seconds: u64,
179    /// Union of settled and reserved audio in the local week, rounded upwards.
180    pub week_used_seconds: u64,
181    /// Smallest remaining day/week budget, or no limit.
182    pub remaining_seconds: Option<u64>,
183    /// Whether the supplied media category may start now.
184    pub allowed: bool,
185    /// Stable refusal code, absent on success.
186    pub reason: Option<String>,
187    /// Next local schedule/day boundary as Unix seconds, if relevant.
188    pub next_change_at: Option<u64>,
189}
190
191#[cfg(test)]
192#[path = "policy_tests.rs"]
193mod tests;