ll_hls_runtime/server/store.rs
1//! Per-stream, protocol-neutral in-RAM rolling window of the segmenter's
2//! init/segments/parts, with a runtime-agnostic change notification
3//! ([`event_listener::Event`]) so a blocking-reload wait works under any
4//! async runtime — not `tokio::sync::watch`.
5//!
6//! [`MediaStore`] holds bytes + timing only — no playlist/manifest syntax.
7//! Rendering a manifest (LL-HLS `#EXT-M3U8`) is [`super::media_playlist_m3u8`]'s
8//! concern, layered on top.
9
10use std::collections::VecDeque;
11use std::sync::Mutex;
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::time::SystemTime;
14
15use event_listener::{Event, EventListener};
16use transmux::ll_hls::{PartInfo, SegmentInfo};
17use transmux::pipeline::TrackSpec;
18
19/// Minimum live-parts cap, regardless of the timing-derived bound — keeps a
20/// usable window even for pathologically small `target_duration_secs`/
21/// `part_target_ms` configs.
22const MIN_MAX_LIVE_PARTS: usize = 8;
23
24/// Safety margin (in parts) added on top of one nominal segment's worth of
25/// parts, absorbing jitter in part sizes/timing without needing an exact fit.
26const MAX_LIVE_PARTS_SAFETY_MARGIN: usize = 4;
27
28/// Bound on `Inner::live_parts` derived from segment timing: roughly one
29/// nominal segment's worth of parts (`target_duration_secs / part_target`),
30/// plus a small safety margin, floored at [`MIN_MAX_LIVE_PARTS`].
31///
32/// Without a cap, a segment that never closes (GOP much longer than
33/// `target_duration_secs`, or a source that stops sending keyframes) would
34/// grow `live_parts` unboundedly — `add_segment`'s clear-on-close only trims
35/// it when a segment *does* close. This bound keeps RAM use flat regardless;
36/// an LL-HLS playlist need only advertise the most recent parts of the open
37/// segment (RFC 8216bis has no requirement to retain every part ever
38/// produced for an in-progress segment).
39pub(crate) fn compute_max_live_parts(target_duration_secs: f64, part_target_ms: u32) -> usize {
40 let part_target_secs = f64::from(part_target_ms) / 1000.0;
41 let nominal_parts = if part_target_secs > 0.0 {
42 (target_duration_secs / part_target_secs).ceil() as usize
43 } else {
44 0
45 };
46 (nominal_parts + MAX_LIVE_PARTS_SAFETY_MARGIN).max(MIN_MAX_LIVE_PARTS)
47}
48
49struct Inner {
50 init: Option<Vec<u8>>,
51 segments: VecDeque<SegmentInfo>,
52 live_parts: Vec<PartInfo>,
53 /// Parts of *just-closed* segments, kept briefly (bounded, oldest-evicted)
54 /// after `add_segment` moves them out of `live_parts`. They are no longer
55 /// rendered in the playlist (the segment is advertised as a whole `seg-…`),
56 /// but stay **fetchable** so an in-flight LL-HLS preload-hint request for a
57 /// segment's *final* part still resolves: the segmenter emits that final
58 /// part and closes the segment in the same pipeline step, so without this
59 /// the part is evicted microseconds after it appears — before the blocked
60 /// part request can wake — and every segment boundary 404s its hinted part.
61 recent_parts: VecDeque<PartInfo>,
62 window_segments: usize,
63 /// Current ingest health, set by the feeding pipeline's supervisor (e.g.
64 /// `multimux::origin::supervisor`) and read by adapters/metrics.
65 health: HealthState,
66 /// The largest `SegmentInfo.duration` ever seen by `add_segment`, over
67 /// the whole lifetime of this store (never reset when the window slides
68 /// or a segment is evicted). RFC 8216bis §4.4.3.1 requires
69 /// `#EXT-X-TARGETDURATION` to be at least the rounded duration of every
70 /// Media Segment ever advertised — since a real segment can exceed the
71 /// *configured* target duration (the segmenter cuts on the next keyframe
72 /// after the target, not exactly at it), an all-time max is the only way
73 /// to guarantee the MUST holds for every segment this store has ever
74 /// produced, including ones already evicted from the window.
75 max_segment_duration: f64,
76 /// The track specs the feeding pipeline built its segmenter from (issue
77 /// #663 P4) — set once via [`MediaStore::set_track_specs`], before the
78 /// first sample lands. Protocol-neutral (any `Output` can read it), but
79 /// only [`crate`]'s DASH sibling in `multimux::output::dash` actually
80 /// needs it today: a DASH `Representation` must advertise a real RFC 6381
81 /// `codecs` string, which the LL-HLS playlist never needs.
82 track_specs: Vec<TrackSpec>,
83}
84
85/// Ingest health of the pipeline feeding a [`MediaStore`], set by the
86/// caller's supervisor loop (e.g. `multimux::origin::supervisor::supervise`)
87/// as it connects/reconnects the source.
88///
89/// `Failed` is reserved for an unrecoverable connect error class; a
90/// well-behaved supervisor never gives up on a route (sources like cameras
91/// come back), so in practice it cycles `Connecting` -> `Live` <->
92/// `Reconnecting`.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94#[non_exhaustive]
95pub enum HealthState {
96 /// Never yet connected; the supervisor's first connect attempt is in
97 /// flight.
98 Connecting,
99 /// Connected and actively receiving media.
100 Live,
101 /// Lost the source (connect failure, pipeline error, or source EOF) and
102 /// the supervisor is retrying with backoff.
103 Reconnecting,
104 /// Unrecoverable — the supervisor has given up on this route.
105 Failed,
106}
107
108impl HealthState {
109 /// The spec/field-enum label (workspace #204 convention): a stable,
110 /// lowercase token per state, suitable for logs/metrics.
111 pub fn name(&self) -> &'static str {
112 match self {
113 HealthState::Connecting => "connecting",
114 HealthState::Live => "live",
115 HealthState::Reconnecting => "reconnecting",
116 HealthState::Failed => "failed",
117 }
118 }
119}
120
121impl std::fmt::Display for HealthState {
122 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123 f.write_str(self.name())
124 }
125}
126
127/// One closed segment's identity/timing — a protocol-neutral snapshot entry
128/// returned by [`MediaStore::window_segments`] (issue #663 P4).
129#[derive(Debug, Clone, Copy, PartialEq)]
130pub struct SegmentWindowEntry {
131 /// This segment's sequence number — matches the `seg-{track}-{seq}.m4s`
132 /// filename [`MediaStore::resolve_resource`](super::MediaStore::resolve_resource)
133 /// serves.
134 pub segment_seq: u32,
135 /// This segment's actual duration, in seconds.
136 pub duration_secs: f64,
137}
138
139/// In-RAM rolling window for one served stream: bytes + timing, shared by
140/// every adapter serving that stream.
141pub struct MediaStore {
142 inner: Mutex<Inner>,
143 target_duration_secs: f64,
144 part_target_ms: u32,
145 max_live_parts: usize,
146 /// Monotonic version bumped by every mutation (`add_*`/`set_init`/
147 /// `set_health` that actually changes health) — never reset, only ever
148 /// grows (wrapping on overflow, which at any realistic mutation rate
149 /// never happens within a process lifetime).
150 progress_version: AtomicU64,
151 /// Runtime-agnostic wakeup: `listen()` registers a listener *before*
152 /// returning (so a `notify` racing the caller's next check is never
153 /// missed — the standard `event-listener` idiom), and every mutation
154 /// calls `notify(usize::MAX)` to wake every parked waiter.
155 progress_event: Event,
156 /// Wall-clock time this store was constructed — used as the live
157 /// presentation's `availabilityStartTime` anchor (issue #663 P4; see
158 /// [`Self::created_at`]). Not bumped/mutated after construction, so it
159 /// needs no lock.
160 created_at: SystemTime,
161}
162
163impl MediaStore {
164 /// New empty store; `window_segments` = full segments retained.
165 pub fn new(target_duration_secs: f64, part_target_ms: u32, window_segments: usize) -> Self {
166 MediaStore {
167 inner: Mutex::new(Inner {
168 init: None,
169 segments: VecDeque::new(),
170 live_parts: Vec::new(),
171 recent_parts: VecDeque::new(),
172 window_segments,
173 health: HealthState::Connecting,
174 max_segment_duration: 0.0,
175 track_specs: Vec::new(),
176 }),
177 target_duration_secs,
178 part_target_ms,
179 max_live_parts: compute_max_live_parts(target_duration_secs, part_target_ms),
180 progress_version: AtomicU64::new(0),
181 progress_event: Event::new(),
182 created_at: SystemTime::now(),
183 }
184 }
185
186 fn bump(&self) {
187 self.progress_version.fetch_add(1, Ordering::SeqCst);
188 self.progress_event.notify(usize::MAX);
189 }
190
191 /// Store the fMP4 init segment.
192 pub fn set_init(&self, bytes: Vec<u8>) {
193 self.inner
194 .lock()
195 .unwrap_or_else(std::sync::PoisonError::into_inner)
196 .init = Some(bytes);
197 self.bump();
198 }
199
200 /// Append a completed part to the in-progress segment.
201 ///
202 /// Caps `live_parts` at `max_live_parts` worth of entries, dropping the
203 /// *oldest* live part(s) first if the cap is exceeded — this bounds RAM
204 /// use even if the current segment never closes (see
205 /// `compute_max_live_parts`).
206 pub fn add_part(&self, part: PartInfo) {
207 let mut g = self
208 .inner
209 .lock()
210 .unwrap_or_else(std::sync::PoisonError::into_inner);
211 g.live_parts.push(part);
212 while g.live_parts.len() > self.max_live_parts {
213 g.live_parts.remove(0);
214 }
215 drop(g);
216 self.bump();
217 }
218
219 /// Count of currently-retained live parts (test accessor for the
220 /// `live_parts` cap).
221 #[cfg(test)]
222 pub(crate) fn live_part_count(&self) -> usize {
223 self.inner
224 .lock()
225 .unwrap_or_else(std::sync::PoisonError::into_inner)
226 .live_parts
227 .len()
228 }
229
230 /// Close a full segment into the window (evicting the oldest). Its
231 /// in-progress parts move out of `live_parts` into a bounded `recent_parts`
232 /// buffer — still fetchable (so an in-flight preload-hint request for the
233 /// segment's final part resolves) but no longer rendered as open parts.
234 /// `recent_parts` is capped like `live_parts`, oldest-first.
235 pub fn add_segment(&self, seg: SegmentInfo) {
236 let mut g = self
237 .inner
238 .lock()
239 .unwrap_or_else(std::sync::PoisonError::into_inner);
240 let seq = seg.segment_seq;
241 g.max_segment_duration = g.max_segment_duration.max(seg.duration);
242 let (closed, still_live): (Vec<PartInfo>, Vec<PartInfo>) =
243 core::mem::take(&mut g.live_parts)
244 .into_iter()
245 .partition(|p| p.segment_seq <= seq);
246 g.live_parts = still_live;
247 for p in closed {
248 g.recent_parts.push_back(p);
249 }
250 while g.recent_parts.len() > self.max_live_parts {
251 g.recent_parts.pop_front();
252 }
253 g.segments.push_back(seg);
254 while g.segments.len() > g.window_segments {
255 g.segments.pop_front();
256 }
257 drop(g);
258 self.bump();
259 }
260
261 /// The fMP4 init segment bytes, if present — the one accessor kept
262 /// public beyond `add_*`/`set_*`/`health`/`listen`, since callers (e.g.
263 /// `multimux`'s pipeline/supervisor tests) commonly need to assert media
264 /// has actually landed without going through `resolve_resource`.
265 pub fn init_bytes(&self) -> Option<Vec<u8>> {
266 self.inner
267 .lock()
268 .unwrap_or_else(std::sync::PoisonError::into_inner)
269 .init
270 .clone()
271 }
272
273 /// A full segment's bytes by sequence number.
274 pub(crate) fn segment_bytes(&self, seq: u32) -> Option<Vec<u8>> {
275 let g = self
276 .inner
277 .lock()
278 .unwrap_or_else(std::sync::PoisonError::into_inner);
279 g.segments
280 .iter()
281 .find(|s| s.segment_seq == seq)
282 .map(|s| s.bytes.clone())
283 }
284
285 /// A part's bytes by (segment seq, part index). Checks the in-progress
286 /// segment's `live_parts` first, then the just-closed `recent_parts` — the
287 /// latter so an LL-HLS client's in-flight preload-hint request for a
288 /// segment's final part still resolves after `add_segment` closed it. Parts
289 /// older than the `recent_parts` bound are no longer individually
290 /// addressable (only the whole segment is).
291 pub(crate) fn part_bytes(&self, seq: u32, part_index: u32) -> Option<Vec<u8>> {
292 let g = self
293 .inner
294 .lock()
295 .unwrap_or_else(std::sync::PoisonError::into_inner);
296 let matches = |p: &&PartInfo| p.segment_seq == seq && p.part_index == part_index;
297 g.live_parts
298 .iter()
299 .find(matches)
300 .or_else(|| g.recent_parts.iter().find(matches))
301 .map(|p| p.bytes.clone())
302 }
303
304 /// `(in-progress segment seq, count of live parts available for it)` —
305 /// used to resolve blocking `_HLS_msn`/`_HLS_part` requests.
306 ///
307 /// The second value is a **count**, not the last part's index: the
308 /// blocking-reload resolver treats "part `P` ready" as `count > P` (0
309 /// means no parts of the in-progress segment are available yet).
310 ///
311 /// `pub` (not `pub(crate)`) since issue #663 P4.2: `multimux::output::ll_dash`
312 /// needs the in-progress segment's identity to address its live parts
313 /// (`part-{track}-{seq}.{idx}.m4s`) from a `SegmentTemplate` — the same
314 /// cross-crate exception already made for
315 /// [`Self::target_duration_secs`]/[`Self::part_target_ms`]/
316 /// [`Self::track_specs`].
317 pub fn latest_progress(&self) -> (u32, u32) {
318 let g = self
319 .inner
320 .lock()
321 .unwrap_or_else(std::sync::PoisonError::into_inner);
322 let last_closed_seg = g.segments.back().map(|s| s.segment_seq).unwrap_or(0);
323 let in_progress_seg = g
324 .live_parts
325 .last()
326 .map(|p| p.segment_seq)
327 .unwrap_or(last_closed_seg);
328 let part_count = g
329 .live_parts
330 .iter()
331 .filter(|p| p.segment_seq == in_progress_seg)
332 .count() as u32;
333 (in_progress_seg, part_count)
334 }
335
336 /// The current monotonic progress version — bumped by every mutation.
337 /// Mostly useful for a caller wanting to detect "did anything change"
338 /// without registering a listener (e.g. a cheap pre-check).
339 pub fn progress_version(&self) -> u64 {
340 self.progress_version.load(Ordering::SeqCst)
341 }
342
343 /// Register for the next change notification. **Register before
344 /// re-checking the condition you're waiting on** (see this module's
345 /// `super`-level doc for the wait-loop shape) — `event-listener`
346 /// guarantees any `notify` call that happens after `listen()` returns
347 /// will wake this listener, so there is no missed-wakeup race as long as
348 /// the re-check happens after `listen()`, not before.
349 ///
350 /// The returned [`EventListener`] is a plain `Future<Output = ()>` — any
351 /// async runtime (or none, via its blocking `.wait()`) can drive it; this
352 /// is what keeps `server` runtime-agnostic (unlike a
353 /// `tokio::sync::watch::Receiver`, which only ever paired with tokio).
354 pub fn listen(&self) -> EventListener {
355 self.progress_event.listen()
356 }
357
358 /// Set the route's ingest health. Bumps the progress notification
359 /// **only when the state actually changes**, so a caller blocked on
360 /// [`Self::listen`] (e.g. an LL-HLS blocking playlist reload) wakes on a
361 /// health transition too, not just new media.
362 pub fn set_health(&self, state: HealthState) {
363 let mut g = self
364 .inner
365 .lock()
366 .unwrap_or_else(std::sync::PoisonError::into_inner);
367 if g.health != state {
368 g.health = state;
369 drop(g);
370 self.bump();
371 }
372 }
373
374 /// The current ingest health (default [`HealthState::Connecting`] until
375 /// the supervisor sets it).
376 pub fn health(&self) -> HealthState {
377 self.inner
378 .lock()
379 .unwrap_or_else(std::sync::PoisonError::into_inner)
380 .health
381 }
382
383 /// The full-segment target duration, in seconds, this store was built
384 /// with — timing configuration a manifest renderer needs (e.g. LL-HLS's
385 /// `#EXT-X-TARGETDURATION`, or a DASH `Output`'s `minimumUpdatePeriod`/
386 /// `timeShiftBufferDepth`). `pub` (not `pub(crate)`) since issue #663 P4:
387 /// `multimux::output::dash` needs it too, not just this crate's own
388 /// `engine`.
389 pub fn target_duration_secs(&self) -> f64 {
390 self.target_duration_secs
391 }
392
393 /// The part target duration, in milliseconds, this store was built with.
394 /// `pub` for the same cross-`Output` reason as
395 /// [`Self::target_duration_secs`].
396 pub fn part_target_ms(&self) -> u32 {
397 self.part_target_ms
398 }
399
400 /// Wall-clock time this store was constructed (issue #663 P4) — used as
401 /// a live DASH presentation's `availabilityStartTime` anchor. An
402 /// approximation (the *route*'s start time, not the first segment's
403 /// exact cut time — the first segment typically closes
404 /// `target_duration_secs` or so later), acceptable for a manifest
405 /// attribute that only needs to establish a consistent, monotonic
406 /// timeline, not wall-clock precision.
407 pub fn created_at(&self) -> SystemTime {
408 self.created_at
409 }
410
411 /// Store the track specs the feeding pipeline built its segmenter from —
412 /// called once, before the first sample is pushed. See
413 /// `Inner::track_specs` for why this exists (DASH's `codecs` string
414 /// needs real codec identity; LL-HLS never reads this).
415 pub fn set_track_specs(&self, specs: Vec<TrackSpec>) {
416 self.inner
417 .lock()
418 .unwrap_or_else(std::sync::PoisonError::into_inner)
419 .track_specs = specs;
420 }
421
422 /// The track specs set by [`Self::set_track_specs`], empty if never
423 /// called (e.g. in a test that only exercises playlist rendering).
424 pub fn track_specs(&self) -> Vec<TrackSpec> {
425 self.inner
426 .lock()
427 .unwrap_or_else(std::sync::PoisonError::into_inner)
428 .track_specs
429 .clone()
430 }
431
432 /// Snapshot of the closed segments currently retained in the rolling
433 /// window, oldest first — enough for a manifest renderer to enumerate
434 /// fetchable segments (issue #663 P4: DASH's `SegmentTemplate`/
435 /// `$Number$` addressing) without depending on the LL-HLS-specific
436 /// playlist rendering in the playlist renderer.
437 pub fn window_segments(&self) -> Vec<SegmentWindowEntry> {
438 self.inner
439 .lock()
440 .unwrap_or_else(std::sync::PoisonError::into_inner)
441 .segments
442 .iter()
443 .map(|s| SegmentWindowEntry {
444 segment_seq: s.segment_seq,
445 duration_secs: s.duration,
446 })
447 .collect()
448 }
449
450 /// The largest `SegmentInfo.duration` ever seen by [`Self::add_segment`],
451 /// `0.0` if no segment has closed yet. The playlist renderer combines
452 /// this with [`Self::target_duration_secs`] to compute a
453 /// spec-conformant `#EXT-X-TARGETDURATION` (RFC 8216bis §4.4.3.1).
454 pub(crate) fn max_segment_duration(&self) -> f64 {
455 self.inner
456 .lock()
457 .unwrap_or_else(std::sync::PoisonError::into_inner)
458 .max_segment_duration
459 }
460
461 /// The sequence number of the most-recently-closed segment, `0` if none
462 /// has closed yet. Unlike [`Self::latest_progress`]'s first element
463 /// (which reflects the *in-progress* segment once any of its parts have
464 /// landed), this is specifically the last **closed** segment — used to
465 /// implement RFC 8216bis §6.2.5.2's bare-`_HLS_msn` blocking-reload
466 /// semantics, which must wait for segment `msn` to be a fully-present
467 /// Media Segment, not merely an in-progress one with live parts.
468 pub(crate) fn last_closed_segment_seq(&self) -> u32 {
469 self.inner
470 .lock()
471 .unwrap_or_else(std::sync::PoisonError::into_inner)
472 .segments
473 .back()
474 .map(|s| s.segment_seq)
475 .unwrap_or(0)
476 }
477
478 /// Run `f` against a consistent snapshot of the closed `segments` and the
479 /// in-progress segment's `live_parts`, taken under a single lock
480 /// acquisition — used by the playlist renderer, which needs both
481 /// collections together.
482 pub(crate) fn with_segments_and_parts<R>(
483 &self,
484 f: impl FnOnce(&VecDeque<SegmentInfo>, &[PartInfo]) -> R,
485 ) -> R {
486 let g = self
487 .inner
488 .lock()
489 .unwrap_or_else(std::sync::PoisonError::into_inner);
490 f(&g.segments, &g.live_parts)
491 }
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497 use event_listener::Listener;
498
499 fn seg(seq: u32, parts: u32) -> SegmentInfo {
500 SegmentInfo {
501 bytes: vec![seq as u8; 8],
502 duration: 4.0,
503 segment_seq: seq,
504 part_count: parts,
505 }
506 }
507 fn part(seq: u32, idx: u32) -> PartInfo {
508 PartInfo {
509 bytes: vec![idx as u8; 4],
510 duration: 0.5,
511 independent: idx == 0,
512 segment_seq: seq,
513 part_index: idx,
514 }
515 }
516
517 #[test]
518 fn window_evicts_oldest_and_serves_bytes() {
519 let s = MediaStore::new(4.0, 500, 2);
520 s.set_init(vec![0xAA; 10]);
521 s.add_segment(seg(1, 8));
522 s.add_segment(seg(2, 8));
523 s.add_segment(seg(3, 8)); // evicts seq 1
524 assert!(s.segment_bytes(1).is_none(), "seq 1 evicted");
525 assert!(s.segment_bytes(2).is_some());
526 assert!(s.segment_bytes(3).is_some());
527 assert_eq!(s.init_bytes().unwrap(), vec![0xAA; 10]);
528 }
529
530 #[test]
531 fn recent_parts_bounded_across_many_closes() {
532 // Closing many segments must not grow recent_parts unboundedly.
533 let s = MediaStore::new(4.0, 500, 4);
534 s.set_init(vec![0; 4]);
535 let cap = compute_max_live_parts(4.0, 500);
536 for seq in 1..=20u32 {
537 for idx in 0..4u32 {
538 s.add_part(part(seq, idx));
539 }
540 s.add_segment(seg(seq, 4));
541 }
542 // Only the most recent ~cap parts remain individually fetchable; a very
543 // old one has been evicted from recent_parts.
544 assert!(s.part_bytes(1, 0).is_none(), "old closed part evicted");
545 assert!(
546 s.part_bytes(20, 3).is_some(),
547 "most-recent closed part retained (within the {cap}-part bound)"
548 );
549 }
550
551 #[test]
552 fn progress_version_bumps_on_new_data() {
553 let s = MediaStore::new(4.0, 500, 4);
554 let before = s.progress_version();
555 s.add_part(part(1, 0));
556 assert_ne!(s.progress_version(), before, "progress version changed");
557 }
558
559 #[test]
560 fn listen_wakes_on_new_data() {
561 // Proves `listen()` actually observes a `notify()` triggered by a
562 // mutation — not just that the version counter moves (that's
563 // `progress_version_bumps_on_new_data`). Registers first (the
564 // documented no-missed-wakeup ordering), then mutates, then blocks on
565 // the listener with `EventListener::wait` (bounded so a broken wakeup
566 // fails the test instead of hanging it).
567 let s = MediaStore::new(4.0, 500, 4);
568 let listener = s.listen();
569 s.add_part(part(1, 0));
570 assert!(
571 listener
572 .wait_deadline(std::time::Instant::now() + std::time::Duration::from_secs(2))
573 .is_some(),
574 "listener must wake within 2s of add_part"
575 );
576 }
577
578 #[test]
579 fn health_defaults_to_connecting() {
580 let s = MediaStore::new(4.0, 500, 4);
581 assert_eq!(s.health(), HealthState::Connecting);
582 }
583
584 #[test]
585 fn set_health_updates_and_bumps_progress_only_on_change() {
586 let s = MediaStore::new(4.0, 500, 4);
587 let before = s.progress_version();
588
589 // No-op: setting the same state again must not bump.
590 s.set_health(HealthState::Connecting);
591 assert_eq!(
592 s.progress_version(),
593 before,
594 "unchanged state does not bump progress"
595 );
596
597 s.set_health(HealthState::Live);
598 assert_eq!(s.health(), HealthState::Live);
599 assert_ne!(
600 s.progress_version(),
601 before,
602 "state change bumps progress so blocked readers wake"
603 );
604
605 let mid = s.progress_version();
606 s.set_health(HealthState::Reconnecting);
607 assert_eq!(s.health(), HealthState::Reconnecting);
608 assert_ne!(s.progress_version(), mid);
609 }
610
611 #[test]
612 fn max_segment_duration_tracks_lifetime_max_not_just_current_window() {
613 let s = MediaStore::new(4.0, 500, 2);
614 s.set_init(vec![0; 4]);
615 assert_eq!(s.max_segment_duration(), 0.0, "nothing closed yet");
616
617 let mut over = seg(1, 8);
618 over.duration = 4.0;
619 s.add_segment(over);
620 assert_eq!(s.max_segment_duration(), 4.0);
621
622 // A real segment that overshoots the configured target (the
623 // segmenter cuts on the next keyframe after the target, so this is
624 // routine, not pathological).
625 let mut over = seg(2, 8);
626 over.duration = 7.5;
627 s.add_segment(over);
628 assert_eq!(s.max_segment_duration(), 7.5);
629
630 // Window slides (window_segments=2 evicts seq 1 and seq 2 eventually)
631 // but the lifetime max must NOT reset/shrink back down.
632 let mut small = seg(3, 8);
633 small.duration = 3.0;
634 s.add_segment(small); // evicts seq 1 from the window
635 let mut small2 = seg(4, 8);
636 small2.duration = 3.0;
637 s.add_segment(small2); // evicts seq 2 from the window
638 assert!(
639 s.segment_bytes(2).is_none(),
640 "seq 2 (the 7.5s segment) evicted from the window"
641 );
642 assert_eq!(
643 s.max_segment_duration(),
644 7.5,
645 "lifetime max must survive window eviction"
646 );
647 }
648
649 #[test]
650 fn last_closed_segment_seq_tracks_the_newest_close() {
651 let s = MediaStore::new(4.0, 500, 4);
652 assert_eq!(s.last_closed_segment_seq(), 0, "nothing closed yet");
653 s.add_segment(seg(1, 8));
654 assert_eq!(s.last_closed_segment_seq(), 1);
655 s.add_segment(seg(2, 8));
656 assert_eq!(s.last_closed_segment_seq(), 2);
657 }
658
659 #[test]
660 fn health_state_name_and_display_agree() {
661 for (state, label) in [
662 (HealthState::Connecting, "connecting"),
663 (HealthState::Live, "live"),
664 (HealthState::Reconnecting, "reconnecting"),
665 (HealthState::Failed, "failed"),
666 ] {
667 assert_eq!(state.name(), label);
668 assert_eq!(state.to_string(), label);
669 }
670 }
671
672 // --- issue #663 P4: DASH-facing accessors ---
673
674 #[test]
675 fn track_specs_round_trip_and_default_empty() {
676 use transmux::pipeline::CodecConfig;
677
678 let s = MediaStore::new(4.0, 500, 4);
679 assert!(
680 s.track_specs().is_empty(),
681 "no specs set yet -> empty, not a panic/placeholder"
682 );
683
684 let spec = TrackSpec::new(
685 1,
686 90_000,
687 CodecConfig::Vp8 {
688 width: 0,
689 height: 0,
690 },
691 );
692 s.set_track_specs(vec![spec.clone()]);
693 let got = s.track_specs();
694 assert_eq!(got.len(), 1);
695 assert_eq!(got[0].track_id, spec.track_id);
696 assert_eq!(got[0].timescale, spec.timescale);
697 }
698
699 #[test]
700 fn window_segments_reflects_closed_segments_oldest_first_and_evicts() {
701 let s = MediaStore::new(4.0, 500, 2);
702 assert!(s.window_segments().is_empty(), "nothing closed yet");
703
704 s.add_segment(seg(1, 4));
705 s.add_segment(seg(2, 4));
706 let window = s.window_segments();
707 assert_eq!(
708 window.iter().map(|e| e.segment_seq).collect::<Vec<_>>(),
709 vec![1, 2],
710 "oldest first"
711 );
712 assert_eq!(window[0].duration_secs, 4.0);
713
714 s.add_segment(seg(3, 4)); // evicts seq 1 (window_segments == 2)
715 assert_eq!(
716 s.window_segments()
717 .iter()
718 .map(|e| e.segment_seq)
719 .collect::<Vec<_>>(),
720 vec![2, 3],
721 "eviction reflected in the snapshot"
722 );
723 }
724
725 #[test]
726 fn created_at_is_set_at_construction() {
727 let before = SystemTime::now();
728 let s = MediaStore::new(4.0, 500, 4);
729 let after = SystemTime::now();
730 assert!(s.created_at() >= before && s.created_at() <= after);
731 }
732}