Skip to main content

playout_runtime/
schedule.rs

1//! The channel schedule: an ordered list of entries (programme / ad / slate).
2//!
3//! The wire/data format here is deliberately **ours to define** — issue
4//! [#748](https://github.com/fishloa/rust-broadcast/issues/748)'s
5//! design-decision comment: SCTE 224 (ESNI) covers policy/blackout
6//! signalling, not channel assembly, and adopting it would be cargo-culting a
7//! standard that does not answer this question. There is no applicable open
8//! standard for "ordered list of sources with planned starts" to transcribe,
9//! so [`Schedule`] is a plain in-memory model, not a wire type: it has no
10//! `Parse`/`Serialize` pair, and it is deliberately minimal — enough to plan
11//! [transitions](crate::transition) and the SCTE-35 cues they imply, nothing
12//! about rights, geography, or blackout rules.
13
14use crate::error::{Error, Result};
15use alloc::string::String;
16use alloc::vec::Vec;
17
18/// What kind of content a [`ScheduleEntry`] carries.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
21#[non_exhaustive]
22pub enum EntryKind {
23    /// Regular programme content.
24    Programme,
25    /// An advertisement — a scheduled ad-break asset.
26    Ad,
27    /// Filler/slate content played when nothing else is scheduled (e.g. a
28    /// hold slide, or gap-filler between programme and ad).
29    Slate,
30}
31
32impl EntryKind {
33    /// Stable label.
34    pub fn name(&self) -> &'static str {
35        match self {
36            EntryKind::Programme => "programme",
37            EntryKind::Ad => "ad",
38            EntryKind::Slate => "slate",
39        }
40    }
41}
42broadcast_common::impl_spec_display!(EntryKind);
43
44/// Caller-supplied opaque identity for a source's codec configuration (its
45/// `avcC`/`hvcC`/`esds`, etc.), used only for equality comparison.
46///
47/// This crate never parses codec-configuration bitstreams — the "no
48/// transcoding or conforming" non-goal in issue #748's design decision — so
49/// it cannot compute this itself. The caller (whatever demuxed the source,
50/// e.g. `transmux`'s init-segment parsing) computes a stable fingerprint of
51/// its own config and passes it here; a change in fingerprint between
52/// consecutive entries is exactly what
53/// [`TransitionPlan::discontinuity`](crate::transition::TransitionPlan::discontinuity)
54/// treats as a discontinuity to signal downstream (e.g. via
55/// `broadcast_hls::mark_init_discontinuities`) — never something this crate
56/// re-encodes or conforms.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
58#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
59pub struct CodecConfigId(pub u64);
60
61/// One entry in the schedule: a source, its kind, and when it is planned to
62/// start on the shared channel timeline.
63#[derive(Debug, Clone, PartialEq, Eq)]
64#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
65pub struct ScheduleEntry {
66    /// Caller-chosen identity for the source this entry plays (e.g. an asset
67    /// id or URI) — opaque to this crate beyond equality/display.
68    pub id: String,
69    /// What kind of content this entry is.
70    pub kind: EntryKind,
71    /// The channel-timeline instant this entry is scheduled to start at, in
72    /// the caller's clock unit.
73    ///
74    /// [`Schedule`]/[`crate::transition::TransitionPlan`] themselves never
75    /// interpret this unit — the rebase arithmetic is unit-agnostic. But if
76    /// this value (via [`crate::transition::TransitionPlan::rebase`]) ends up
77    /// passed to [`crate::scte35::build_splice_insert`] to emit a SCTE-35 cue,
78    /// it **must** already be 90 kHz ticks: `splice_time()`/`break_duration()`
79    /// are fixed 90 kHz fields (ANSI/SCTE 35 2023r1 §9.8.1/§9.8.2), and
80    /// `build_splice_insert` passes its inputs straight through with no unit
81    /// conversion or validation — a 27 MHz, millisecond, or nanosecond
82    /// channel clock produces a technically well-formed but silently
83    /// wrong-by-a-fixed-factor cue (PLAY-W1, #1126). Convert with
84    /// `scte35_splice::time::duration_to_ticks`/`ticks_to_duration` first if
85    /// the channel clock isn't already 90 kHz.
86    pub planned_start: u64,
87    /// The PTS value the source's own first presented sample carries, in the
88    /// same clock unit as [`Self::planned_start`] (and subject to the same
89    /// 90 kHz requirement before it reaches [`crate::scte35`]). Needed to
90    /// compute the PTS-rebase offset at the transition into this entry — see
91    /// [`crate::transition`].
92    pub source_start_pts: u64,
93    /// Codec-config identity, for discontinuity detection at the transition
94    /// into this entry.
95    pub codec_config: CodecConfigId,
96}
97
98/// An ordered schedule: entries strictly increasing by `planned_start`.
99///
100/// This crate does not sort entries for you — [`Schedule::push`] rejects an
101/// out-of-order entry immediately, so a caller building a schedule sees
102/// ordering mistakes at construction time rather than as a silently
103/// misordered playout later.
104#[derive(Debug, Clone, Default, PartialEq, Eq)]
105#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
106pub struct Schedule {
107    entries: Vec<ScheduleEntry>,
108}
109
110impl Schedule {
111    /// An empty schedule.
112    #[must_use]
113    pub fn new() -> Self {
114        Schedule {
115            entries: Vec::new(),
116        }
117    }
118
119    /// Append `entry`. Errors with [`Error::OutOfOrder`] if `entry` does not
120    /// start strictly after the schedule's current last entry.
121    pub fn push(&mut self, entry: ScheduleEntry) -> Result<()> {
122        if let Some(last) = self.entries.last()
123            && entry.planned_start <= last.planned_start
124        {
125            return Err(Error::OutOfOrder {
126                prev: last.planned_start,
127                next: entry.planned_start,
128            });
129        }
130        self.entries.push(entry);
131        Ok(())
132    }
133
134    /// The entries, in schedule order.
135    #[must_use]
136    pub fn entries(&self) -> &[ScheduleEntry] {
137        &self.entries
138    }
139
140    /// Number of entries.
141    #[must_use]
142    pub fn len(&self) -> usize {
143        self.entries.len()
144    }
145
146    /// Whether the schedule has no entries.
147    #[must_use]
148    pub fn is_empty(&self) -> bool {
149        self.entries.is_empty()
150    }
151
152    /// The entry active at `pts` — the last entry whose `planned_start <=
153    /// pts` — or `None` if `pts` precedes every entry (or the schedule is
154    /// empty).
155    #[must_use]
156    pub fn active_at(&self, pts: u64) -> Option<&ScheduleEntry> {
157        self.active_index_at(pts).map(|i| &self.entries[i])
158    }
159
160    /// Index form of [`Self::active_at`], for [`crate::transition`] to also
161    /// reach the *next* entry without a second lookup.
162    pub(crate) fn active_index_at(&self, pts: u64) -> Option<usize> {
163        if self.entries.is_empty() {
164            return None;
165        }
166        let idx = self.entries.partition_point(|e| e.planned_start <= pts);
167        if idx == 0 { None } else { Some(idx - 1) }
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    fn entry(id: &str, kind: EntryKind, planned_start: u64) -> ScheduleEntry {
176        ScheduleEntry {
177            id: id.into(),
178            kind,
179            planned_start,
180            source_start_pts: 0,
181            codec_config: CodecConfigId(1),
182        }
183    }
184
185    #[test]
186    fn push_accepts_strictly_increasing_starts() {
187        let mut sched = Schedule::new();
188        sched
189            .push(entry("prog-1", EntryKind::Programme, 0))
190            .unwrap();
191        sched.push(entry("ad-1", EntryKind::Ad, 1_000)).unwrap();
192        assert_eq!(sched.len(), 2);
193    }
194
195    #[test]
196    fn push_rejects_non_increasing_start() {
197        let mut sched = Schedule::new();
198        sched
199            .push(entry("prog-1", EntryKind::Programme, 1_000))
200            .unwrap();
201        let err = sched.push(entry("ad-1", EntryKind::Ad, 1_000)).unwrap_err();
202        match err {
203            Error::OutOfOrder { prev, next } => {
204                assert_eq!(prev, 1_000);
205                assert_eq!(next, 1_000);
206            }
207            other => panic!("expected OutOfOrder, got {other:?}"),
208        }
209
210        let err = sched.push(entry("ad-2", EntryKind::Ad, 500)).unwrap_err();
211        assert!(matches!(
212            err,
213            Error::OutOfOrder {
214                prev: 1_000,
215                next: 500
216            }
217        ));
218    }
219
220    #[test]
221    fn active_at_finds_the_last_entry_starting_at_or_before_pts() {
222        let mut sched = Schedule::new();
223        sched
224            .push(entry("prog-1", EntryKind::Programme, 0))
225            .unwrap();
226        sched.push(entry("ad-1", EntryKind::Ad, 1_000)).unwrap();
227        sched
228            .push(entry("prog-2", EntryKind::Programme, 2_000))
229            .unwrap();
230
231        assert_eq!(sched.active_at(0).unwrap().id, "prog-1");
232        assert_eq!(sched.active_at(999).unwrap().id, "prog-1");
233        assert_eq!(sched.active_at(1_000).unwrap().id, "ad-1");
234        assert_eq!(sched.active_at(1_999).unwrap().id, "ad-1");
235        assert_eq!(sched.active_at(2_000).unwrap().id, "prog-2");
236        assert_eq!(sched.active_at(1_000_000).unwrap().id, "prog-2");
237    }
238
239    #[test]
240    fn active_at_before_the_first_entry_is_none() {
241        let mut sched = Schedule::new();
242        sched
243            .push(entry("prog-1", EntryKind::Programme, 1_000))
244            .unwrap();
245        assert!(sched.active_at(999).is_none());
246    }
247
248    #[test]
249    fn empty_schedule_has_no_active_entry() {
250        let sched = Schedule::new();
251        assert!(sched.is_empty());
252        assert!(sched.active_at(0).is_none());
253    }
254
255    #[test]
256    fn label_convention() {
257        assert_eq!(EntryKind::Programme.name(), "programme");
258        assert_eq!(alloc::format!("{}", EntryKind::Ad), "ad");
259        assert_eq!(EntryKind::Slate.name(), "slate");
260    }
261}