1use super::intervals::{Interval, duration, union};
3use super::storage::{Owner, Reservation, valid_lease, valid_owner};
4use super::{Media, ParentalStore, Status};
5use serde::Serialize;
6
7#[derive(Debug, Clone, Serialize)]
9pub struct LeaseGrant {
10 pub lease_id: String,
12 pub revision: u64,
14 pub expires_at: u64,
16 pub granted_seconds: u32,
18}
19fn milliseconds(now: u64) -> Result<u64, String> {
20 now.checked_mul(1000)
21 .ok_or_else(|| "invalid parental clock".into())
22}
23fn category(policy: &super::Policy, media: &Media) -> Result<Option<&'static str>, String> {
24 match media {
25 Media::Radio if !policy.radio_allowed => Ok(Some("radio_blocked")),
26 Media::Podcast { .. } if !policy.podcasts_allowed => Ok(Some("podcast_blocked")),
27 Media::Podcast { feed } => {
28 let feed = super::canonical_feed(feed)?;
29 Ok(policy
30 .blocked_podcasts
31 .iter()
32 .any(|blocked| super::canonical_feed(blocked).is_ok_and(|value| value == feed))
33 .then_some("podcast_blocked"))
34 }
35 Media::Music { track: Some(token) } => {
36 let reference = crate::user::EntityRef::parse_token(token)
37 .ok_or("invalid parental audio reference")?;
38 if reference.kind != crate::model::EntityKind::Track
39 || reference.key.is_empty()
40 || token.len() > 16_384
41 || token.chars().any(char::is_control)
42 {
43 return Err("invalid parental audio reference".into());
44 }
45 Ok(None)
46 }
47 _ => Ok(None),
48 }
49}
50impl ParentalStore {
51 pub fn check_schedule(&self, owner: &str, now: u64, media: &Media) -> Result<(), String> {
53 if !valid_owner(owner) {
54 return Err("invalid parental owner".into());
55 }
56 let fallback = Owner::default();
57 let row = self.owners.get(owner).unwrap_or(&fallback);
58 if !row.policy.enabled {
59 return Ok(());
60 }
61 if let Some(reason) = category(&row.policy, media)? {
62 return Err(reason.into());
63 }
64 if !super::schedule::clock(&row.policy, now)?.scheduled {
65 return Err("outside_schedule".into());
66 }
67 Ok(())
68 }
69
70 pub fn status(&self, owner: &str, now: u64, media: &Media) -> Result<Status, String> {
72 if !valid_owner(owner) {
73 return Err("invalid parental owner".into());
74 }
75 let fallback = Owner::default();
76 let row = self.owners.get(owner).unwrap_or(&fallback);
77 let clock = super::schedule::clock(&row.policy, now)?;
78 let intervals = row.intervals();
79 let day_used = duration(
80 &intervals,
81 milliseconds(clock.day_start)?,
82 milliseconds(clock.day_end)?,
83 );
84 let week_used = duration(
85 &intervals,
86 milliseconds(clock.week_start)?,
87 milliseconds(clock.week_end)?,
88 );
89 let day_remaining = row.policy.days[clock.day].limit_seconds.map(|limit| {
90 u64::from(limit)
91 .saturating_mul(1000)
92 .saturating_sub(day_used)
93 });
94 let week_remaining = row.policy.week_limit_seconds.map(|limit| {
95 u64::from(limit)
96 .saturating_mul(1000)
97 .saturating_sub(week_used)
98 });
99 let reason = if !row.policy.enabled {
100 None
101 } else if let Some(reason) = category(&row.policy, media)? {
102 Some(reason)
103 } else if !clock.scheduled {
104 Some("outside_schedule")
105 } else if day_remaining == Some(0) {
106 Some("daily_limit")
107 } else if week_remaining == Some(0) {
108 Some("weekly_limit")
109 } else {
110 None
111 };
112 let remaining = match (day_remaining, week_remaining) {
113 (Some(a), Some(b)) => Some(a.min(b)),
114 (a, b) => a.or(b),
115 };
116 Ok(Status {
117 owner: owner.into(),
118 revision: row.revision,
119 policy: row.policy.clone(),
120 day_used_seconds: day_used.div_ceil(1000),
121 week_used_seconds: week_used.div_ceil(1000),
122 remaining_seconds: if row.policy.enabled {
123 remaining.map(|value| value / 1000)
124 } else {
125 None
126 },
127 allowed: reason.is_none(),
128 reason: reason.map(str::to_owned),
129 next_change_at: row.policy.enabled.then_some(clock.next),
130 })
131 }
132 pub fn reserve(
135 &mut self,
136 owner: &str,
137 lease_id: &str,
138 media: Media,
139 requested_seconds: u32,
140 now: u64,
141 ) -> Result<LeaseGrant, String> {
142 if !valid_owner(owner) || !valid_lease(lease_id) || !(1..=10).contains(&requested_seconds) {
143 return Err("invalid parental listening reservation".into());
144 }
145 let mut candidate = self.clone();
146 let row = candidate.owners.entry(owner.into()).or_default();
147 let now_ms = milliseconds(now)?;
148 row.prune(now_ms);
149 if row.reservations.iter().any(|value| value.id == lease_id) {
150 return Err("parental listening leases are occupied".into());
151 }
152 row.make_room(now_ms);
153 if row.reservations.len() >= 8 {
154 return Err("parental listening leases are occupied".into());
155 }
156 let clock = super::schedule::clock(&row.policy, now)?;
157 if row.policy.enabled {
158 if let Some(reason) = category(&row.policy, &media)? {
159 return Err(reason.into());
160 }
161 if !clock.scheduled {
162 return Err("outside_schedule".into());
163 }
164 }
165 let available_seconds = if row.policy.enabled {
166 requested_seconds.min(u32::try_from(clock.next.saturating_sub(now)).unwrap_or(u32::MAX))
167 } else {
168 requested_seconds
169 };
170 let start_ms = milliseconds(now)?;
171 let held = row.intervals();
172 let day_used = duration(
173 &held,
174 milliseconds(clock.day_start)?,
175 milliseconds(clock.day_end)?,
176 );
177 let week_used = duration(
178 &held,
179 milliseconds(clock.week_start)?,
180 milliseconds(clock.week_end)?,
181 );
182 let mut granted = 0;
183 let mut refusal = "daily_limit";
184 for seconds in 1..=available_seconds {
185 let end_ms = start_ms
186 .checked_add(u64::from(seconds) * 1000)
187 .ok_or("invalid parental reservation clock")?;
188 let extra = u64::from(seconds) * 1000 - duration(&held, start_ms, end_ms);
192 if row.policy.enabled
193 && row.policy.days[clock.day]
194 .limit_seconds
195 .is_some_and(|limit| day_used + extra > u64::from(limit) * 1000)
196 {
197 refusal = "daily_limit";
198 break;
199 }
200 if row.policy.enabled
201 && row
202 .policy
203 .week_limit_seconds
204 .is_some_and(|limit| week_used + extra > u64::from(limit) * 1000)
205 {
206 refusal = "weekly_limit";
207 break;
208 }
209 granted = seconds;
210 }
211 if granted == 0 {
212 return Err(refusal.into());
213 }
214 let grant = LeaseGrant {
215 lease_id: lease_id.into(),
216 revision: row.revision,
217 expires_at: now
218 .checked_add(u64::from(granted))
219 .ok_or("invalid parental expiry")?,
220 granted_seconds: granted,
221 };
222 row.reservations.push(Reservation {
223 id: lease_id.into(),
224 media,
225 interval: Interval {
226 start_ms,
227 end_ms: milliseconds(grant.expires_at)?,
228 },
229 consumed_ms: 0,
230 });
231 candidate.validate()?;
232 *self = candidate;
233 Ok(grant)
234 }
235 pub fn settle(
239 &mut self,
240 owner: &str,
241 lease_id: &str,
242 consumed_ms: u64,
243 now: u64,
244 close: bool,
245 ) -> Result<(), String> {
246 let mut candidate = self.clone();
247 candidate.settle_inner(owner, lease_id, consumed_ms, now, close)?;
248 candidate.validate()?;
249 *self = candidate;
250 Ok(())
251 }
252 fn settle_inner(
253 &mut self,
254 owner: &str,
255 lease_id: &str,
256 consumed_ms: u64,
257 now: u64,
258 close: bool,
259 ) -> Result<(), String> {
260 let row = self
261 .owners
262 .get_mut(owner)
263 .ok_or("parental listening reservation is unavailable")?;
264 let index = row
265 .reservations
266 .iter()
267 .position(|value| value.id == lease_id)
268 .ok_or("parental listening reservation is unavailable")?;
269 let reservation = &mut row.reservations[index];
270 if consumed_ms < reservation.consumed_ms
271 || consumed_ms > reservation.interval.end_ms - reservation.interval.start_ms
272 {
273 return Err("invalid parental consumption counter".into());
274 }
275 let now_ms = milliseconds(now)?;
276 if now_ms < reservation.interval.start_ms {
277 return Err("parental clock moved backwards".into());
278 }
279 reservation.consumed_ms = consumed_ms;
280 if close {
281 let value = row.reservations.remove(index);
282 let end_ms = if now_ms > value.interval.end_ms {
283 value.interval.end_ms
284 } else {
285 value.interval.start_ms + consumed_ms.max(now_ms - value.interval.start_ms)
288 };
289 if end_ms > value.interval.start_ms {
290 row.settled.push(Interval {
291 start_ms: value.interval.start_ms,
292 end_ms,
293 });
294 }
295 row.settled = union(std::mem::take(&mut row.settled));
296 }
297 row.prune(now_ms);
298 self.validate()
299 }
300 pub fn renew(
303 &mut self,
304 owner: &str,
305 lease_id: &str,
306 consumed_ms: u64,
307 requested_seconds: u32,
308 now: u64,
309 ) -> Result<LeaseGrant, String> {
310 let media = self
311 .owners
312 .get(owner)
313 .and_then(|row| row.reservations.iter().find(|value| value.id == lease_id))
314 .map(|value| value.media.clone())
315 .ok_or("parental listening reservation is unavailable")?;
316 let mut candidate = self.clone();
317 candidate.settle(owner, lease_id, consumed_ms, now, true)?;
318 let grant = candidate.reserve(owner, lease_id, media, requested_seconds, now)?;
319 *self = candidate;
320 Ok(grant)
321 }
322}
323#[cfg(test)]
324#[path = "leases_tests.rs"]
325mod tests;