Skip to main content

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}