Skip to main content

moq_hls/export/
mod.rs

1//! Export: serve a MoQ broadcast as HLS or DASH, fetching media on demand.
2//!
3//! A [`Broadcaster`] subscribes to one broadcast's catalog and its single timeline track (see
4//! [`hang::timeline`]). That is the *only* standing traffic: playlists and manifests are a
5//! pure function of the timeline records (each names a complete aligned segment with
6//! per-track group ranges), and media bytes move only when an HTTP client requests a segment,
7//! which FETCHes exactly the groups that segment covers from the relay cache and transmuxes
8//! them to CMAF. A broadcast whose catalog advertises no timeline can't be served this way and
9//! is skipped.
10//!
11//! The same machinery serves two kinds of consumer:
12//!
13//! * the HTTP serve path (pull): [`Broadcaster::rendition`] /
14//!   [`Broadcaster::master_playlist`] / [`Broadcaster::manifest`] and
15//!   [`Rendition::playlist`] / [`Rendition::init`] / [`Rendition::segment`], rendered or
16//!   fetched per request, with or without the `server` feature's router; and
17//! * a recorder (push): the [`renditions::Consumer`] and [`segments::Consumer`] cursors, which
18//!   yield every rendition and every finalized segment in order, for mirroring a broadcast to
19//!   storage.
20
21pub mod master;
22mod mpd;
23mod playlist;
24mod rendition;
25mod upstream;
26
27pub mod renditions;
28pub mod segments;
29
30use std::sync::{Arc, Mutex};
31use std::time::{Duration, SystemTime};
32
33use moq_mux::catalog::{self, CatalogFormat, Stream};
34use rand::RngExt;
35
36pub(crate) use playlist::render_media;
37pub use rendition::{Kind, Rendition};
38pub(crate) use upstream::Upstream;
39
40/// Backoff bounds for the initial catalog subscription.
41///
42/// The usual failure is a publisher that has announced its broadcast but not yet written its
43/// catalog track, so this waits for external state rather than repeating a failed request.
44/// Escalating for that reason: a source that stays silent for an hour must not be polled four times
45/// a second for an hour.
46///
47/// Deliberately no give-up budget, and no attempt to judge which failures are worth waiting on. The
48/// broadcast closing is what ends the wait, and a relay-side broadcast outlives its publisher's
49/// session, so any deadline here is a window in which a publisher outage leaves the broadcaster
50/// permanently empty with nothing to recover it.
51const CATALOG_RETRY_MIN: Duration = Duration::from_millis(250);
52const CATALOG_RETRY_MAX: Duration = Duration::from_secs(5);
53
54/// Export tuning shared across renditions.
55///
56/// Construct via [`Config::default`] and set the fields you need, so new options
57/// stay additive.
58#[derive(Clone, Debug)]
59#[non_exhaustive]
60pub struct Config {
61	/// Minimum duration of media listed in each rendition's playlist window. Older timeline
62	/// records are evicted once the remaining segments still cover this span; keep it within
63	/// the relay's group-cache retention, since segments are fetched from there on request.
64	pub window: Duration,
65}
66
67impl Default for Config {
68	fn default() -> Self {
69		Self {
70			window: Duration::from_secs(16),
71		}
72	}
73}
74
75/// All renditions of one broadcast, kept in sync with its catalog.
76pub struct Broadcaster {
77	// Read only by the serve path's `is_closed`/`closed` (server-gated); a recording consumer
78	// observes close through its `renditions()` cursor ending instead.
79	#[cfg_attr(not(feature = "server"), allow(dead_code))]
80	broadcast: moq_net::broadcast::Consumer,
81	/// The current rendition set, reconciled from the catalog by the watcher task.
82	renditions: renditions::Producer,
83	/// Aborts the catalog watcher when the broadcaster is dropped; `Drop` then retires the
84	/// renditions themselves (see [`renditions::Producer::clear`]). Set once, right after
85	/// construction.
86	watcher: Mutex<Option<tokio::task::JoinHandle<()>>>,
87	/// The broadcast's timeline watcher, spawned by the catalog watcher once the catalog
88	/// advertises the timeline track. On drop it is aborted only when no cursor still needs
89	/// its records (see `Drop`).
90	timeline_watcher: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
91}
92
93impl Broadcaster {
94	/// Resolve `source`'s catalog broadcast and start tracking its renditions.
95	pub async fn new(source: moq_mux::Source, config: Config) -> crate::Result<Arc<Self>> {
96		let broadcast = source.broadcast().await?;
97		let upstream = Upstream {
98			source,
99			broadcast: broadcast.clone(),
100		};
101		let renditions = renditions::Producer::new(config.window);
102		let timeline_watcher = Arc::new(Mutex::new(None));
103		let broadcaster = Arc::new(Self {
104			broadcast,
105			renditions: renditions.clone(),
106			watcher: Mutex::new(None),
107			timeline_watcher: timeline_watcher.clone(),
108		});
109		// The watcher owns its own producer clone; the `Broadcaster`'s `Drop` aborts it so the
110		// standing catalog subscription stops when nobody's serving from this broadcaster.
111		let watcher = tokio::spawn(watch_catalog(upstream, renditions, timeline_watcher));
112		*broadcaster.watcher.lock().unwrap() = Some(watcher);
113		Ok(broadcaster)
114	}
115
116	/// Whether the source broadcast has closed (ended or dropped).
117	pub fn is_closed(&self) -> bool {
118		self.broadcast.is_closed()
119	}
120
121	/// Resolve once the source broadcast closes, so a pool can evict a dead broadcaster.
122	pub async fn closed(&self) {
123		self.broadcast.closed().await;
124	}
125
126	/// Look up a rendition by kind and name. Video and audio are separate axes, so a
127	/// video and an audio rendition may share a name without colliding.
128	pub fn rendition(&self, kind: Kind, name: &str) -> Option<Arc<Rendition>> {
129		self.renditions.get(kind, name)
130	}
131
132	/// A cursor over rendition add/remove events, for a consumer that mirrors or records the
133	/// whole broadcast. It replays the current renditions, then yields changes, and returns
134	/// `None` once the source closes.
135	pub fn renditions(&self) -> renditions::Consumer {
136		self.renditions.subscribe()
137	}
138
139	/// Resolve once at least one rendition has a playable media playlist. How long to wait is
140	/// the caller's policy: wrap this in a timeout (or select against it) as needed.
141	pub async fn ready(&self) {
142		self.renditions.ready().await;
143	}
144
145	/// Version every segment URL with `generation`, a label for the publisher run the
146	/// broadcast carries now, or stop versioning with `None` (the default).
147	///
148	/// A restarted publisher is spliced into the same broadcast and restarts its segment
149	/// numbers, so `seg/0.m4s` would name different bytes on each run. Supply a new generation
150	/// before the new run's media flows. Replacing one drops every listed segment and every
151	/// init built from the old run's media, while the first only labels the run already flowing. A segment URL carrying any other generation
152	/// is refused. Init URLs need none: they carry a hash of their bytes.
153	///
154	/// Fails unless `generation` is non-empty ASCII letters, digits, `-`, and `_`.
155	pub fn set_generation(&self, generation: Option<&str>) -> crate::Result<()> {
156		if let Some(generation) = generation
157			&& (generation.is_empty()
158				|| !generation
159					.bytes()
160					.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'))
161		{
162			return Err(crate::Error::InvalidGeneration(generation.to_string()));
163		}
164		self.renditions.fanout().set_generation(generation.map(Arc::from));
165		Ok(())
166	}
167
168	/// Render the multivariant (master) playlist from the current renditions.
169	///
170	/// `query` is an optional query string (without the leading `?`, e.g. `jwt=<token>`)
171	/// appended to every child media-playlist URL, so a credential the master was fetched
172	/// with propagates to the rendition playlists a stock player loads next. It is an
173	/// argument rather than a [`Config`] field because one broadcaster fans out to viewers
174	/// holding different tokens; a credential in `Config` would embed one viewer's in
175	/// another's playlist.
176	pub fn master_playlist(&self, query: Option<&str>) -> String {
177		let mut video = Vec::new();
178		let mut audio = Vec::new();
179		for rendition in self.renditions.snapshot() {
180			if !rendition.is_playable() {
181				continue;
182			}
183			match rendition.kind {
184				Kind::Video => video.push(master::VideoVariant {
185					uri: master::rendition_uri(Kind::Video, &rendition.name, query),
186					bandwidth: rendition.bandwidth(),
187					width: rendition.width,
188					height: rendition.height,
189					codec: rendition.codec.clone(),
190				}),
191				Kind::Audio => audio.push(master::AudioVariant {
192					name: rendition.name.clone(),
193					uri: master::rendition_uri(Kind::Audio, &rendition.name, query),
194					bandwidth: rendition.bandwidth(),
195					codec: rendition.codec.clone(),
196				}),
197			}
198		}
199		master::render(&video, &audio)
200	}
201
202	/// Render the DASH manifest (MPD) from the current renditions and their views of the
203	/// broadcast timeline, or `None` while nothing is listable yet (every `SegmentTimeline`
204	/// would be empty, which players reject).
205	///
206	/// Live broadcasts render a `dynamic` presentation anchored at the catalog's declared
207	/// wall clock (or, absent one, at an anchor estimated from the first record's arrival); a
208	/// finished broadcast renders `static`. `query` propagates to every child URL exactly as
209	/// in [`master_playlist`](Self::master_playlist).
210	///
211	/// Each representation names its init by a hash of the bytes, so a rendition is left out
212	/// until its init is known. An inline-parameter-set codec only learns it from media: await
213	/// each rendition's [`init`](Rendition::init) first.
214	pub fn manifest(&self, query: Option<&str>) -> Option<String> {
215		let mut video = Vec::new();
216		let mut audio = Vec::new();
217		let mut availability_start = None;
218		for rendition in self.renditions.snapshot() {
219			// Every rendition shares the catalog's one root clock, so any of them can
220			// supply the declared wall anchor.
221			if availability_start.is_none() {
222				availability_start = rendition.wall_clock(moq_net::Timestamp::ZERO);
223			}
224			// Without its init there is nothing to name in `initialization`; the serve path
225			// builds every init before rendering (see `build_inits`).
226			let Some(representation) = rendition.representation() else {
227				continue;
228			};
229			match rendition.kind {
230				Kind::Video => video.push(representation),
231				Kind::Audio => audio.push(representation),
232			}
233		}
234
235		let representations = || video.iter().chain(&audio);
236		if representations().all(|rep| rep.segments.is_empty()) {
237			return None;
238		}
239		let finished = representations().any(|rep| rep.ended);
240		if !finished {
241			// A dynamic presentation needs pts 0 anchored to the wall clock. The fallback is
242			// set whenever a record has been pushed, which listing a segment implies.
243			availability_start = availability_start.or_else(|| self.renditions.anchor());
244			availability_start?;
245		}
246
247		Some(mpd::render_manifest(
248			&mpd::Manifest {
249				availability_start,
250				publish: SystemTime::now(),
251				window: self.renditions.window(),
252				finished,
253				video,
254				audio,
255			},
256			query,
257		))
258	}
259
260	/// Build every rendition's init segment, so the manifest can name each by its hash. A
261	/// rendition whose init can't be built yet is left out of the render.
262	#[cfg(feature = "server")]
263	pub(crate) async fn build_inits(&self) {
264		for rendition in self.renditions.snapshot() {
265			if let Err(err) = rendition.init().await {
266				tracing::warn!(rendition = %rendition.name, %err, "failed to build init segment");
267			}
268		}
269	}
270
271	/// Resolve once the broadcast has something listable (its first complete segment, or an
272	/// already-ended timeline). Every rendition's window is fed from the same timeline, so the
273	/// first rendition's readiness stands in for the broadcast's. Bounding the wait is the
274	/// caller's policy.
275	#[cfg_attr(not(feature = "server"), allow(dead_code))]
276	pub(crate) async fn playable(&self) {
277		if let Some(rendition) = self.renditions.snapshot().into_iter().next() {
278			rendition.playable().await;
279		}
280	}
281
282	/// Whether the current catalog contains no servable renditions (serve path).
283	#[cfg_attr(not(feature = "server"), allow(dead_code))]
284	pub(crate) fn is_empty(&self) -> bool {
285		self.renditions.is_empty()
286	}
287}
288
289impl Drop for Broadcaster {
290	fn drop(&mut self) {
291		if let Some(watcher) = self.watcher.lock().unwrap().take() {
292			watcher.abort();
293		}
294		// A recording cursor (holding an `Arc<Rendition>`) must keep receiving timeline
295		// records after this broadcaster is gone, or its final segments would be truncated;
296		// the timeline watcher then ends on its own when the timeline (or broadcast) does.
297		// With no such holder, abort it now so teardown can't leak a standing subscription.
298		let held = self.renditions.any_held_externally();
299		// Release the map either way: a surviving `renditions::Consumer` would otherwise pin
300		// every `Rendition` forever.
301		self.renditions.clear();
302		if !held && let Some(watcher) = self.timeline_watcher.lock().unwrap().take() {
303			watcher.abort();
304		}
305	}
306}
307
308async fn watch_catalog(
309	upstream: Upstream,
310	renditions: renditions::Producer,
311	timeline_watcher: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
312) {
313	// The already-resolved handle rather than a fresh lookup through `source`, so the catalog,
314	// the timeline subscription, and the closed check below all refer to the same resolution. A
315	// same-name replacement between two lookups would otherwise split them.
316	let broadcast = &upstream.broadcast;
317	let mut delay = CATALOG_RETRY_MIN;
318	let mut consumer = loop {
319		match catalog::Consumer::<()>::new(broadcast, CatalogFormat::Hang).await {
320			Ok(consumer) => break consumer,
321			Err(err) => {
322				tracing::warn!(%err, "failed to subscribe to broadcast catalog, retrying");
323				let wait = delay.mul_f64(0.5 + rand::rng().random::<f64>() / 2.0);
324				delay = (delay * 2).min(CATALOG_RETRY_MAX);
325				tokio::select! {
326					_ = tokio::time::sleep(wait) => {}
327					_ = kio::wait(|waiter| broadcast.poll_closed(waiter)) => {
328						renditions.close();
329						return;
330					}
331				}
332			}
333		}
334	};
335
336	let mut timeline_started = false;
337	loop {
338		match kio::wait(|waiter| consumer.poll_next(waiter)).await {
339			Ok(Some(catalog)) => {
340				renditions.sync(&upstream, &catalog);
341				// The broadcast has one timeline; subscribe once it's advertised and fan its
342				// records out to every rendition.
343				if !timeline_started && let Some(archive) = catalog.archive.clone() {
344					timeline_started = true;
345					let watcher = tokio::spawn(watch_timeline(broadcast.clone(), archive, renditions.fanout()));
346					*timeline_watcher.lock().unwrap() = Some(watcher);
347				}
348			}
349			Ok(None) => break,
350			Err(err) => {
351				tracing::warn!(%err, "broadcast catalog stream ended with error");
352				break;
353			}
354		}
355	}
356
357	// The source is done (or errored): let recording cursors finish.
358	renditions.close();
359}
360
361/// The broadcast's timeline watcher: read the single timeline track and fan each record out
362/// to every rendition's window.
363async fn watch_timeline(
364	broadcast: moq_net::broadcast::Consumer,
365	section: hang::catalog::Archive,
366	renditions: renditions::Fanout,
367) {
368	match watch(&broadcast, &section, &renditions).await {
369		// The timeline finished cleanly: the publisher is done, so every window can end
370		// (ENDLIST) and recording cursors drain to completion.
371		Ok(()) => renditions.end_windows(),
372		// A transient error (subscription reset, relay hiccup): don't mark the windows ended;
373		// the serve path keeps serving the frozen windows.
374		Err(err) => {
375			tracing::warn!(track = %section.track, %err, "timeline watcher error; leaving the playlists live")
376		}
377	}
378	// The timeline stream is over either way: close so recording cursors terminate instead of
379	// parking forever (the serve path still reads the last windows).
380	renditions.close_windows();
381}
382
383async fn watch(
384	broadcast: &moq_net::broadcast::Consumer,
385	section: &hang::catalog::Archive,
386	renditions: &renditions::Fanout,
387) -> crate::Result<()> {
388	let mut timeline = moq_mux::timeline::Consumer::<()>::subscribe(broadcast, section).await?;
389	while let Some(event) = timeline.next().await? {
390		match event {
391			moq_mux::timeline::Event::Push { index, entry } => renditions.push(index, entry),
392			moq_mux::timeline::Event::Pop(range) => renditions.pop(range),
393			moq_mux::timeline::Event::Skip(_) => renditions.skip(),
394			_ => unreachable!("unknown timeline event"),
395		}
396	}
397	Ok(())
398}
399
400#[cfg(test)]
401mod tests {
402	/// Build an origin producer, spawning its driver on the ambient runtime.
403	fn produce_origin() -> moq_net::origin::Producer {
404		let (producer, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::default());
405		if tokio::runtime::Handle::try_current().is_ok() {
406			tokio::spawn(moq_net::time::run(driver));
407		} else {
408			// A sync test: nothing polls the driver, and dropping it would tear
409			// the origin down, so leak it and rely on the synchronous half.
410			std::mem::forget(driver);
411		}
412		producer
413	}
414
415	use super::*;
416
417	enum TestConfig {
418		Video(hang::catalog::VideoConfig),
419		Audio(hang::catalog::AudioConfig),
420	}
421
422	impl From<hang::catalog::VideoConfig> for TestConfig {
423		fn from(config: hang::catalog::VideoConfig) -> Self {
424			Self::Video(config)
425		}
426	}
427
428	impl From<hang::catalog::AudioConfig> for TestConfig {
429		fn from(config: hang::catalog::AudioConfig) -> Self {
430			Self::Audio(config)
431		}
432	}
433
434	struct TestRendition {
435		catalog: moq_mux::catalog::Producer,
436		name: String,
437		config: Option<TestConfig>,
438	}
439
440	impl TestRendition {
441		fn unwrap(self) -> Self {
442			self
443		}
444
445		fn set(&mut self, config: impl Into<TestConfig>) -> moq_mux::Result<()> {
446			let config = config.into();
447			let mut catalog = self.catalog.modify()?;
448			match &config {
449				TestConfig::Video(config) => {
450					catalog.video.renditions.insert(self.name.clone(), config.clone());
451				}
452				TestConfig::Audio(config) => {
453					catalog.audio.renditions.insert(self.name.clone(), config.clone());
454				}
455			}
456			catalog.commit()?;
457			self.config = Some(config);
458			Ok(())
459		}
460	}
461
462	impl Drop for TestRendition {
463		fn drop(&mut self) {
464			let Ok(mut catalog) = self.catalog.modify() else {
465				return;
466			};
467			match self.config {
468				Some(TestConfig::Video(_)) => {
469					catalog.video.renditions.remove(&self.name);
470				}
471				Some(TestConfig::Audio(_)) => {
472					catalog.audio.renditions.remove(&self.name);
473				}
474				None => {}
475			}
476		}
477	}
478
479	trait RenditionTestExt {
480		fn test_video(&self, name: &str) -> TestRendition;
481		fn test_audio(&self, name: &str) -> TestRendition;
482	}
483
484	impl RenditionTestExt for moq_mux::catalog::Producer {
485		fn test_video(&self, name: &str) -> TestRendition {
486			TestRendition {
487				catalog: self.clone(),
488				name: name.to_string(),
489				config: None,
490			}
491		}
492
493		fn test_audio(&self, name: &str) -> TestRendition {
494			self.test_video(name)
495		}
496	}
497
498	trait CatalogTestExt {
499		fn enroll_test(&self, track: &str) -> moq_mux::Result<moq_mux::timeline::Recorder>;
500		fn media_producer<C: moq_mux::container::Container>(
501			&self,
502			track: moq_net::track::Producer,
503			container: C,
504		) -> moq_mux::Result<moq_mux::container::Producer<C>>
505		where
506			moq_mux::Error: From<C::Error>;
507	}
508
509	impl CatalogTestExt for moq_mux::catalog::Producer {
510		fn enroll_test(&self, track: &str) -> moq_mux::Result<moq_mux::timeline::Recorder> {
511			let recorder = self.timeline().pacing_track(track)?;
512			let mut producer = self.clone();
513			let mut catalog = producer.modify()?;
514			catalog.archive.get_or_insert_with(|| self.timeline().section());
515			catalog.commit()?;
516			Ok(recorder)
517		}
518
519		fn media_producer<C: moq_mux::container::Container>(
520			&self,
521			track: moq_net::track::Producer,
522			container: C,
523		) -> moq_mux::Result<moq_mux::container::Producer<C>>
524		where
525			moq_mux::Error: From<C::Error>,
526		{
527			let recorder = self.enroll_test(track.name())?;
528			Ok(moq_mux::container::Producer::new(track, container).with_recorder(recorder))
529		}
530	}
531
532	fn frame(micros: u64, keyframe: bool) -> moq_mux::container::Frame {
533		payload_frame(micros, keyframe, &[0xDE, 0xAD, 0xBE, 0xEF])
534	}
535
536	fn payload_frame(micros: u64, keyframe: bool, payload: &'static [u8]) -> moq_mux::container::Frame {
537		moq_mux::container::Frame {
538			timestamp: moq_net::Timestamp::from_micros(micros).unwrap(),
539			payload: bytes::Bytes::from_static(payload),
540			keyframe,
541			duration: None,
542		}
543	}
544
545	fn vp8_frame(micros: u64, keyframe: bool) -> moq_mux::container::Frame {
546		let payload = if keyframe {
547			// Key frame tag, start code, and 320x240 geometry.
548			&[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00][..]
549		} else {
550			&[0x31, 0x00, 0x00][..]
551		};
552		moq_mux::container::Frame {
553			timestamp: moq_net::Timestamp::from_micros(micros).unwrap(),
554			payload: bytes::Bytes::copy_from_slice(payload),
555			keyframe,
556			duration: None,
557		}
558	}
559
560	fn video_config() -> hang::catalog::VideoConfig {
561		let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8);
562		config.coded_width = Some(320);
563		config.coded_height = Some(240);
564		config.framerate = Some(30.0);
565		config
566	}
567
568	// Let the origin's spawned attach task run so a created broadcast is routable.
569	async fn settle() {
570		for _ in 0..10 {
571			tokio::task::yield_now().await;
572		}
573	}
574
575	#[tokio::test]
576	async fn escaping_broadcast_reference_is_not_advertised() {
577		let origin = produce_origin();
578		let _broadcast = origin.create_broadcast("a/pub").expect("publish allowed");
579		_broadcast.announce(Default::default()).expect("publish allowed");
580		settle().await;
581		let source = moq_mux::Source::new(origin.consume(), "a/pub");
582		let upstream = Upstream {
583			broadcast: source.broadcast().await.unwrap(),
584			source,
585		};
586		let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8);
587		config.broadcast = Some(moq_net::path::Relative::new("../../source").to_owned());
588		let mut catalog = moq_mux::catalog::hang::Catalog::default();
589		catalog.archive = Some(hang::catalog::Archive::new(hang::timeline::DEFAULT_NAME));
590		catalog.video.renditions.insert("video".to_string(), config);
591
592		let renditions = renditions::Producer::new(Config::default().window);
593		renditions.sync(&upstream, &catalog);
594		assert!(renditions.get(Kind::Video, "video").is_none());
595	}
596
597	/// An upstream over an empty published broadcast, for the `sync` reconciliation tests.
598	async fn empty_upstream(origin: &moq_net::origin::Producer, path: &str) -> Upstream {
599		let source = moq_mux::Source::new(origin.consume(), path);
600		Upstream {
601			broadcast: source.broadcast().await.unwrap(),
602			source,
603		}
604	}
605
606	/// A catalog with one video and one audio rendition, both servable.
607	fn catalog_with_both() -> moq_mux::catalog::hang::Catalog {
608		let mut audio = hang::catalog::AudioConfig::new(
609			hang::catalog::AudioCodec::AAC(hang::catalog::AAC { profile: 2 }),
610			44_100,
611			2,
612		);
613		audio.description = Some(bytes::Bytes::from_static(&[0x12, 0x10]));
614		audio.bitrate = Some(96_000);
615
616		let mut catalog = moq_mux::catalog::hang::Catalog::default();
617		catalog.archive = Some(hang::catalog::Archive::new(hang::timeline::DEFAULT_NAME));
618		catalog.video.renditions.insert("video0".to_string(), video_config());
619		catalog.audio.renditions.insert("audio0".to_string(), audio);
620		catalog
621	}
622
623	/// The next rendition event, or `None` if the set is quiet.
624	async fn next_event(cursor: &mut renditions::Consumer) -> Option<renditions::Event> {
625		tokio::time::timeout(Duration::from_millis(50), cursor.next())
626			.await
627			.ok()
628			.flatten()
629	}
630
631	/// The average bitrate encoded in a synthesized AAC init segment.
632	fn aac_avg_bitrate(init: &bytes::Bytes) -> u32 {
633		let wire = moq_mux::container::fmp4::Wire::from_init(init).unwrap();
634		let codec = &wire.trak().mdia.minf.stbl.stsd.codecs[0];
635		let moq_mux::container::fmp4::mp4_atom::Codec::Mp4a(mp4a) = codec else {
636			panic!("expected mp4a, got {codec:?}");
637		};
638		mp4a.esds.es_desc.dec_config.avg_bitrate
639	}
640
641	// A publisher's estimator republishes the catalog every time its measured bitrate or jitter
642	// moves, writing those two fields and nothing else. That must not churn the rendition:
643	// rebuilding resets the playlist window, the cached init segment and EXT-X-MEDIA-SEQUENCE,
644	// and makes every recording cursor see a Removed followed by an Added.
645	#[tokio::test]
646	async fn estimate_only_catalog_update_keeps_the_rendition() {
647		let origin = produce_origin();
648		let _broadcast = origin.create_broadcast("live").expect("publish allowed");
649		_broadcast.announce(Default::default()).expect("publish allowed");
650		settle().await;
651		let upstream = empty_upstream(&origin, "live").await;
652
653		let mut catalog = catalog_with_both();
654		let renditions = renditions::Producer::new(Config::default().window);
655		let mut cursor = renditions.subscribe();
656		renditions.sync(&upstream, &catalog);
657
658		let video = renditions.get(Kind::Video, "video0").expect("video rendition");
659		let audio = renditions.get(Kind::Audio, "audio0").expect("audio rendition");
660		let audio_init = audio.init().await.unwrap().expect("AAC init segment");
661		assert_eq!(
662			aac_avg_bitrate(&audio_init),
663			96_000,
664			"the muxer keeps the declared catalog bitrate"
665		);
666		assert_eq!(video.bandwidth(), 2_000_000, "the catalog carries no video bitrate yet");
667		assert_eq!(audio.bandwidth(), 96_000);
668		assert!(matches!(
669			next_event(&mut cursor).await,
670			Some(renditions::Event::Added(_))
671		));
672		assert!(matches!(
673			next_event(&mut cursor).await,
674			Some(renditions::Event::Added(_))
675		));
676
677		// Exactly what a catalog-owned media producer's estimator writes back.
678		let video_config = catalog.video.renditions.get_mut("video0").unwrap();
679		video_config.bitrate = Some(1_800_000);
680		video_config.jitter = Some(Duration::from_millis(33));
681		catalog.audio.renditions.get_mut("audio0").unwrap().bitrate = Some(110_000);
682		renditions.sync(&upstream, &catalog);
683
684		assert!(
685			Arc::ptr_eq(&video, &renditions.get(Kind::Video, "video0").unwrap()),
686			"the video rendition survives an estimate-only update"
687		);
688		assert!(
689			Arc::ptr_eq(&audio, &renditions.get(Kind::Audio, "audio0").unwrap()),
690			"the audio rendition survives an estimate-only update"
691		);
692		assert_eq!(
693			video.bandwidth(),
694			1_800_000,
695			"the master playlist advertises the estimate"
696		);
697		assert_eq!(audio.bandwidth(), 110_000);
698		assert_eq!(
699			audio.init().await.unwrap().expect("cached AAC init segment"),
700			audio_init,
701			"an estimate update preserves the cached init segment"
702		);
703		assert!(next_event(&mut cursor).await.is_none(), "no rendition churn");
704
705		// A bitrate the publisher retracts falls back to the advertised default.
706		catalog.video.renditions.get_mut("video0").unwrap().bitrate = None;
707		renditions.sync(&upstream, &catalog);
708		assert_eq!(video.bandwidth(), 2_000_000);
709	}
710
711	// The estimator also measures the framerate and publishes it once it settles, so an
712	// imported source gains one about a second in. Rebuilding for it would re-time the fMP4
713	// (its timescale derives from the framerate) and churn every recording cursor mid-stream.
714	#[tokio::test]
715	async fn estimated_framerate_keeps_the_rendition() {
716		let origin = produce_origin();
717		let _broadcast = origin.create_broadcast("live").expect("publish allowed");
718		_broadcast.announce(Default::default()).expect("publish allowed");
719		settle().await;
720		let upstream = empty_upstream(&origin, "live").await;
721
722		let mut catalog = catalog_with_both();
723		catalog.video.renditions.get_mut("video0").unwrap().framerate = None;
724		let renditions = renditions::Producer::new(Config::default().window);
725		let mut cursor = renditions.subscribe();
726		renditions.sync(&upstream, &catalog);
727		let video = renditions.get(Kind::Video, "video0").expect("video rendition");
728		while next_event(&mut cursor).await.is_some() {}
729
730		for framerate in [Some(30.0), Some(60.0)] {
731			catalog.video.renditions.get_mut("video0").unwrap().framerate = framerate;
732			renditions.sync(&upstream, &catalog);
733			assert!(
734				Arc::ptr_eq(&video, &renditions.get(Kind::Video, "video0").unwrap()),
735				"the video rendition survives a framerate estimate of {framerate:?}"
736			);
737			assert!(next_event(&mut cursor).await.is_none(), "no rendition churn");
738		}
739
740		// The settled estimate still reaches the fallback cadence of a duration-less frame,
741		// at the timescale the rendition was built with (90 kHz without a framerate).
742		let muxer = video.muxer().unwrap();
743		assert_eq!(muxer.timescale().as_u64(), 90_000);
744		let fragment = muxer.fragment(0, &[vp8_frame(0, true)]).unwrap();
745		assert_eq!(first_sample_duration(&fragment), Some(1_500), "16.7 ms at 90 kHz");
746
747		// An NTSC estimate is 1501.5 ticks at 90 kHz: it snaps to whole ticks and still muxes.
748		catalog.video.renditions.get_mut("video0").unwrap().framerate = Some(60_000.0 / 1001.0);
749		renditions.sync(&upstream, &catalog);
750		let fragment = video.muxer().unwrap().fragment(0, &[vp8_frame(0, true)]).unwrap();
751		assert_eq!(first_sample_duration(&fragment), Some(1_502));
752	}
753
754	/// The `trun` duration of a fragment's first sample.
755	fn first_sample_duration(fragment: &bytes::Bytes) -> Option<u32> {
756		use moq_mux::container::fmp4::mp4_atom::{self, DecodeMaybe};
757
758		let mut cursor = std::io::Cursor::new(fragment.as_ref());
759		while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor).unwrap() {
760			if let mp4_atom::Any::Moof(moof) = atom {
761				return moof.traf[0].trun[0].entries[0].duration;
762			}
763		}
764		panic!("no moof in fragment");
765	}
766
767	#[tokio::test]
768	async fn estimate_before_first_init_reaches_aac_descriptor() {
769		let origin = produce_origin();
770		let _broadcast = origin.create_broadcast("live").expect("publish allowed");
771		_broadcast.announce(Default::default()).expect("publish allowed");
772		settle().await;
773		let upstream = empty_upstream(&origin, "live").await;
774
775		let mut catalog = catalog_with_both();
776		catalog.audio.renditions.get_mut("audio0").unwrap().bitrate = None;
777		let renditions = renditions::Producer::new(Config::default().window);
778		renditions.sync(&upstream, &catalog);
779		let audio = renditions.get(Kind::Audio, "audio0").expect("audio rendition");
780
781		catalog.audio.renditions.get_mut("audio0").unwrap().bitrate = Some(110_000);
782		renditions.sync(&upstream, &catalog);
783		assert!(
784			Arc::ptr_eq(&audio, &renditions.get(Kind::Audio, "audio0").unwrap()),
785			"the estimate updates the existing rendition"
786		);
787		let init = audio.init().await.unwrap().expect("AAC init segment");
788
789		assert_eq!(audio.bandwidth(), 110_000);
790		assert_eq!(
791			aac_avg_bitrate(&init),
792			110_000,
793			"the latest estimate reaches an init that was not cached yet"
794		);
795	}
796
797	// The other half: a change that alters how the rendition decodes still rebuilds it, so the
798	// cached init segment can never describe media it no longer matches.
799	#[tokio::test]
800	async fn decoder_config_change_rebuilds_the_rendition() {
801		let origin = produce_origin();
802		let _broadcast = origin.create_broadcast("live").expect("publish allowed");
803		_broadcast.announce(Default::default()).expect("publish allowed");
804		settle().await;
805		let upstream = empty_upstream(&origin, "live").await;
806
807		let mut catalog = catalog_with_both();
808		let renditions = renditions::Producer::new(Config::default().window);
809		let mut cursor = renditions.subscribe();
810		renditions.sync(&upstream, &catalog);
811
812		let video = renditions.get(Kind::Video, "video0").expect("video rendition");
813		let audio = renditions.get(Kind::Audio, "audio0").expect("audio rendition");
814		assert!(matches!(
815			next_event(&mut cursor).await,
816			Some(renditions::Event::Added(_))
817		));
818		assert!(matches!(
819			next_event(&mut cursor).await,
820			Some(renditions::Event::Added(_))
821		));
822
823		// A resolution change and a sample-rate change both invalidate the init segment.
824		catalog.video.renditions.get_mut("video0").unwrap().coded_width = Some(640);
825		catalog.audio.renditions.get_mut("audio0").unwrap().sample_rate = 48_000;
826		renditions.sync(&upstream, &catalog);
827
828		let rebuilt = renditions.get(Kind::Video, "video0").expect("video rendition");
829		assert!(!Arc::ptr_eq(&video, &rebuilt), "the video rendition is rebuilt");
830		assert_eq!(rebuilt.width, Some(640));
831		assert!(
832			!Arc::ptr_eq(&audio, &renditions.get(Kind::Audio, "audio0").unwrap()),
833			"the audio rendition is rebuilt"
834		);
835
836		// Both cursors see the replacement as a removal followed by an addition.
837		let mut removed = 0;
838		let mut added = 0;
839		while let Some(event) = next_event(&mut cursor).await {
840			match event {
841				renditions::Event::Removed { .. } => removed += 1,
842				renditions::Event::Added(_) => added += 1,
843			}
844		}
845		assert_eq!((removed, added), (2, 2));
846	}
847
848	// The whole fetch-on-demand path in process: a broadcast publishes media through the
849	// catalog (which records the timeline), the Broadcaster renders playlists from the
850	// timeline alone, and a segment request fetches and transmuxes exactly its groups.
851	#[tokio::test]
852	async fn master_lists_only_playable_renditions() {
853		let origin = produce_origin();
854		let mut broadcast = origin.create_broadcast("live").expect("publish allowed");
855		broadcast.announce(Default::default()).expect("publish allowed");
856		settle().await;
857		let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
858
859		let reserved = catalog.reserve();
860		let mut registration = catalog.test_video("video0").unwrap();
861		registration.set(video_config()).unwrap();
862		drop(reserved);
863
864		let track = broadcast.create_track("video0", None).unwrap();
865		let mut media = catalog
866			.media_producer(
867				track,
868				moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data),
869			)
870			.unwrap();
871
872		let source = moq_mux::Source::new(origin.consume(), "live");
873		let broadcaster = Broadcaster::new(source, Config::default()).await.unwrap();
874		let rendition = tokio::time::timeout(Duration::from_secs(5), async {
875			loop {
876				if let Some(rendition) = broadcaster.rendition(Kind::Video, "video0") {
877					break rendition;
878				}
879				tokio::task::yield_now().await;
880			}
881		})
882		.await
883		.expect("rendition discovered from the catalog");
884
885		assert!(
886			rendition.media_playlist(None).is_none(),
887			"rendition is not playable yet"
888		);
889		assert!(
890			!broadcaster.master_playlist(None).contains("video/video0/media.m3u8"),
891			"master advertised a media playlist that would return 404"
892		);
893		assert!(
894			tokio::time::timeout(Duration::from_millis(10), broadcaster.ready())
895				.await
896				.is_err(),
897			"broadcaster became ready before it had a playable rendition"
898		);
899
900		media.write(vp8_frame(0, true)).unwrap();
901		media.write(vp8_frame(2_000_000, true)).unwrap();
902		media.write(vp8_frame(4_000_000, true)).unwrap();
903		tokio::time::timeout(Duration::from_secs(5), broadcaster.ready())
904			.await
905			.expect("broadcaster becomes ready with a complete segment");
906		assert!(broadcaster.master_playlist(None).contains("video/video0/media.m3u8"));
907
908		drop((media, registration, broadcast));
909	}
910
911	/// The text between `start` and the next `end` in `haystack`.
912	fn between<'a>(haystack: &'a str, start: &str, end: &str) -> &'a str {
913		let rest = &haystack[haystack.find(start).unwrap_or_else(|| panic!("no {start:?}")) + start.len()..];
914		&rest[..rest.find(end).unwrap_or_else(|| panic!("no {end:?}"))]
915	}
916
917	/// The init hash a media playlist maps.
918	fn init_hash(playlist: &str) -> &str {
919		between(playlist, "#EXT-X-MAP:URI=\"init.", ".mp4")
920	}
921
922	/// Publish a VP8 rendition with three GOPs, 2s apart, so segments 0 and 1 are listable.
923	fn publish_vp8(
924		broadcast: &mut moq_net::broadcast::Producer,
925	) -> (
926		moq_mux::catalog::Producer,
927		TestRendition,
928		moq_mux::container::Producer<moq_mux::catalog::hang::Container>,
929	) {
930		let catalog = moq_mux::catalog::Producer::new(broadcast, moq_mux::catalog::Config::default()).unwrap();
931		let reserved = catalog.reserve();
932		let mut registration = catalog.test_video("video0").unwrap();
933		registration.set(video_config()).unwrap();
934		drop(reserved);
935		let track = broadcast.create_track("video0", None).unwrap();
936		let mut media = catalog
937			.media_producer(
938				track,
939				moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data),
940			)
941			.unwrap();
942		for (micros, keyframe) in [(0, true), (1_000_000, false), (2_000_000, true), (4_000_000, true)] {
943			media.write(vp8_frame(micros, keyframe)).unwrap();
944		}
945		(catalog, registration, media)
946	}
947
948	/// A completed segment already decoded its inline codec metadata. Finishing the
949	/// catalog can index a trailing group whose media producer is still live.
950	#[tokio::test(start_paused = true)]
951	async fn a_transmuxed_segment_keeps_its_init_with_an_unfinished_tail() {
952		let origin = produce_origin();
953		let mut broadcast = origin.create_broadcast("live").expect("publish allowed");
954		let (mut catalog, mut registration, _media) = publish_vp8(&mut broadcast);
955		registration
956			.set(hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8))
957			.unwrap();
958		broadcast.announce(moq_net::origin::Route::default()).unwrap();
959		settle().await;
960		let source = moq_mux::Source::new(origin.consume(), "live");
961		let broadcaster = Broadcaster::new(source, Config::default()).await.unwrap();
962		broadcaster.ready().await;
963		let rendition = broadcaster.rendition(Kind::Video, "video0").expect("rendition");
964		let mut cursor = rendition.segments();
965		let segment = cursor.next().await.unwrap().expect("completed segment");
966		assert!(!segment.media.is_empty());
967
968		catalog.finish().unwrap();
969		while !rendition.snapshot().finished {
970			tokio::task::yield_now().await;
971		}
972		let init = tokio::time::timeout(Duration::from_secs(1), rendition.init())
973			.await
974			.expect("init must not wait for the unfinished trailing group")
975			.unwrap()
976			.expect("transmuxed segment has an init");
977		assert_eq!(&init[4..8], b"ftyp");
978	}
979
980	/// An init bootstrap already blocked on the unfinished tail must not hold back a later
981	/// request once a completed segment has cached the init.
982	#[tokio::test(start_paused = true)]
983	async fn a_blocked_init_bootstrap_does_not_hide_a_cached_init() {
984		let origin = produce_origin();
985		let mut broadcast = origin.create_broadcast("live").expect("publish allowed");
986		let (mut catalog, mut registration, _media) = publish_vp8(&mut broadcast);
987		registration
988			.set(hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8))
989			.unwrap();
990		broadcast.announce(moq_net::origin::Route::default()).unwrap();
991		settle().await;
992		let source = moq_mux::Source::new(origin.consume(), "live");
993		let broadcaster = Broadcaster::new(source, Config::default()).await.unwrap();
994		broadcaster.ready().await;
995		let rendition = broadcaster.rendition(Kind::Video, "video0").expect("rendition");
996
997		catalog.finish().unwrap();
998		while !rendition.snapshot().finished {
999			tokio::task::yield_now().await;
1000		}
1001		let mut blocked = std::pin::pin!(rendition.init());
1002		assert!(
1003			tokio::time::timeout(Duration::from_millis(10), blocked.as_mut())
1004				.await
1005				.is_err(),
1006			"the bootstrap reads the unfinished trailing group"
1007		);
1008
1009		let segment = rendition.segments().next().await.unwrap().expect("completed segment");
1010		assert!(!segment.media.is_empty());
1011		tokio::time::timeout(Duration::from_secs(1), rendition.init())
1012			.await
1013			.expect("init must not wait behind the blocked bootstrap")
1014			.unwrap()
1015			.expect("transmuxed segment has an init");
1016	}
1017
1018	// A reconfigure that changes the init bytes changes the init URL, and the old URL stops
1019	// resolving, so a cache can never hand a player the previous init under the new one.
1020	#[tokio::test]
1021	async fn a_reconfigure_versions_the_init_url() {
1022		let origin = produce_origin();
1023		let mut broadcast = origin.create_broadcast("live").expect("publish allowed");
1024		broadcast.announce(Default::default()).expect("publish allowed");
1025		settle().await;
1026		let (_catalog, mut registration, _media) = publish_vp8(&mut broadcast);
1027
1028		let source = moq_mux::Source::new(origin.consume(), "live");
1029		let broadcaster = Broadcaster::new(source, Config::default()).await.unwrap();
1030		let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await;
1031		let old = broadcaster.rendition(Kind::Video, "video0").expect("rendition");
1032		let old_playlist = old
1033			.media_playlist(None)
1034			.expect("an out-of-band init renders without a fetch");
1035		let old_hash = init_hash(&old_playlist).to_string();
1036
1037		let mut config = video_config();
1038		config.coded_width = Some(640);
1039		config.coded_height = Some(480);
1040		registration.set(config).unwrap();
1041
1042		let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
1043		let new = loop {
1044			let current = broadcaster.rendition(Kind::Video, "video0").expect("rendition");
1045			if !Arc::ptr_eq(&current, &old) {
1046				break current;
1047			}
1048			assert!(
1049				tokio::time::Instant::now() < deadline,
1050				"the reconfigure never rebuilt the rendition"
1051			);
1052			tokio::time::sleep(Duration::from_millis(10)).await;
1053		};
1054		let new_init = new.init().await.unwrap().expect("init segment");
1055		let _ = tokio::time::timeout(Duration::from_secs(5), new.playable()).await;
1056		let new_playlist = new.media_playlist(None).expect("playable");
1057		let new_hash = init_hash(&new_playlist);
1058		assert_ne!(new_hash, old_hash, "different init bytes get a different URL");
1059		assert_eq!(new.init_versioned(new_hash).await.unwrap(), Some(new_init));
1060		assert!(new.init_versioned(&old_hash).await.unwrap().is_none());
1061	}
1062
1063	// A generation rides every segment URL. The first one labels the run already listed;
1064	// replacing it drops that run's rows, and the next run lists under the new generation.
1065	#[tokio::test]
1066	async fn a_generation_versions_segment_urls_and_a_new_one_resets_the_window() {
1067		let origin = produce_origin();
1068		let mut broadcast = origin.create_broadcast("live").expect("publish allowed");
1069		broadcast.announce(Default::default()).expect("publish allowed");
1070		settle().await;
1071		let (_catalog, _registration, mut media) = publish_vp8(&mut broadcast);
1072
1073		let source = moq_mux::Source::new(origin.consume(), "live");
1074		let broadcaster = Broadcaster::new(source, Config::default()).await.unwrap();
1075		let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await;
1076		let rendition = broadcaster.rendition(Kind::Video, "video0").expect("rendition");
1077		let unversioned = rendition.media_playlist(None).expect("playable");
1078		assert!(unversioned.contains("\nseg/0.m4s\n"));
1079
1080		assert!(matches!(
1081			broadcaster.set_generation(Some("run.1")),
1082			Err(crate::Error::InvalidGeneration(_))
1083		));
1084		assert!(broadcaster.set_generation(Some("")).is_err());
1085
1086		broadcaster.set_generation(Some("run-1")).unwrap();
1087		let labeled = rendition
1088			.media_playlist(None)
1089			.expect("the first generation keeps the rows");
1090		assert!(labeled.contains("\nseg/run-1.0.m4s\n"));
1091		assert!(labeled.contains("\nseg/run-1.1.m4s\n"));
1092		assert_eq!(
1093			init_hash(&labeled),
1094			init_hash(&unversioned),
1095			"the init needs no generation"
1096		);
1097		let manifest = broadcaster.manifest(None).expect("manifest");
1098		assert!(manifest.contains("media=\"video/video0/seg/run-1.t$Time$.m4s\""));
1099
1100		broadcaster.set_generation(Some("run-2")).unwrap();
1101		assert!(
1102			rendition.media_playlist(None).is_none(),
1103			"a new generation drops the old run's rows"
1104		);
1105
1106		media.write(vp8_frame(6_000_000, true)).unwrap();
1107		let _ = tokio::time::timeout(Duration::from_secs(5), rendition.playable()).await;
1108		let restarted = rendition.media_playlist(None).expect("the new run lists");
1109		assert!(restarted.contains("\nseg/run-2.2.m4s\n"));
1110		assert!(!restarted.contains("run-1"));
1111
1112		broadcaster.set_generation(None).unwrap();
1113		assert!(
1114			rendition.media_playlist(None).is_none(),
1115			"clearing the generation is a change too"
1116		);
1117	}
1118
1119	// A restarted inline-codec publisher can carry new parameter sets under the same catalog, so
1120	// a new generation drops the init built from the old run's media along with its rows.
1121	#[tokio::test]
1122	async fn a_new_generation_rebuilds_an_inline_init() {
1123		let origin = produce_origin();
1124		let mut broadcast = origin.create_broadcast("live").expect("publish allowed");
1125		broadcast.announce(Default::default()).expect("publish allowed");
1126		settle().await;
1127		let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
1128		let reserved = catalog.reserve();
1129		let mut registration = catalog.test_video("video0").unwrap();
1130		let mut config = video_config();
1131		config.coded_width = None;
1132		config.coded_height = None;
1133		registration.set(config).unwrap();
1134		drop(reserved);
1135		let track = broadcast.create_track("video0", None).unwrap();
1136		let mut media = catalog
1137			.media_producer(
1138				track,
1139				moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data),
1140			)
1141			.unwrap();
1142		for micros in [0, 2_000_000, 4_000_000] {
1143			media.write(vp8_frame(micros, true)).unwrap();
1144		}
1145
1146		let source = moq_mux::Source::new(origin.consume(), "live");
1147		let broadcaster = Broadcaster::new(source, Config::default()).await.unwrap();
1148		let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await;
1149		let rendition = broadcaster.rendition(Kind::Video, "video0").expect("rendition");
1150		let _ = tokio::time::timeout(Duration::from_secs(5), rendition.playable()).await;
1151		let old = rendition.init().await.unwrap().expect("init segment");
1152		broadcaster.set_generation(Some("run-1")).unwrap();
1153		broadcaster.set_generation(Some("run-2")).unwrap();
1154
1155		// The new run's keyframes are 640x480 under the same catalog.
1156		for micros in [6_000_000, 8_000_000, 10_000_000] {
1157			media
1158				.write(moq_mux::container::Frame {
1159					timestamp: moq_net::Timestamp::from_micros(micros).unwrap(),
1160					payload: bytes::Bytes::from_static(&[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x80, 0x02, 0xe0, 0x01]),
1161					keyframe: true,
1162					duration: None,
1163				})
1164				.unwrap();
1165		}
1166		let _ = tokio::time::timeout(Duration::from_secs(5), rendition.playable()).await;
1167		let new = rendition.init().await.unwrap().expect("init segment");
1168		assert_ne!(new, old, "the new run's init is built from its own media");
1169		let playlist = rendition.media_playlist(None).expect("playable");
1170		assert_eq!(rendition.init_versioned(init_hash(&playlist)).await.unwrap(), Some(new));
1171	}
1172
1173	#[tokio::test]
1174	async fn serves_playlist_and_segments_from_the_timeline() {
1175		let origin = produce_origin();
1176		let mut broadcast = origin.create_broadcast("live").expect("publish allowed");
1177		broadcast.announce(Default::default()).expect("publish allowed");
1178		settle().await;
1179		let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
1180
1181		let reserved = catalog.reserve();
1182		let mut registration = catalog.test_video("video0").unwrap();
1183		let mut config = video_config();
1184		config.coded_width = None;
1185		config.coded_height = None;
1186		registration.set(config).unwrap();
1187		drop(reserved);
1188
1189		// Three GOPs, 2s apart: groups 0 and 1 are complete, group 2 is the live edge.
1190		let track = broadcast.create_track("video0", None).unwrap();
1191		let mut media = catalog
1192			.media_producer(
1193				track,
1194				moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data),
1195			)
1196			.unwrap();
1197		media.write(vp8_frame(0, true)).unwrap();
1198		media.write(vp8_frame(1_000_000, false)).unwrap();
1199		media.write(vp8_frame(2_000_000, true)).unwrap();
1200		media.write(vp8_frame(3_000_000, false)).unwrap();
1201		media.write(vp8_frame(4_000_000, true)).unwrap();
1202
1203		let source = moq_mux::Source::new(origin.consume(), "live");
1204		let broadcaster = Broadcaster::new(source, Config::default()).await.unwrap();
1205		let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await;
1206		let rendition = broadcaster
1207			.rendition(Kind::Video, "video0")
1208			.expect("rendition discovered from the catalog");
1209		let _ = tokio::time::timeout(Duration::from_secs(5), rendition.playable()).await;
1210
1211		let master = broadcaster.master_playlist(None);
1212		assert!(master.contains("video/video0/media.m3u8"), "master lists the rendition");
1213
1214		let playlist = rendition.snapshot();
1215		assert_eq!(playlist.segments.len(), 2, "the live-edge group is not listed");
1216		assert_eq!(playlist.segments[0].segment, 0);
1217		assert_eq!(playlist.segments[0].duration, Duration::from_secs(2));
1218		assert_eq!(playlist.segments[1].segment, 1);
1219		assert_eq!(
1220			playlist.target_duration, 2,
1221			"the observed segment duration, in whole seconds"
1222		);
1223		assert!(!playlist.finished);
1224
1225		// Without coded dimensions VP8 learns its init from a keyframe, and the playlist names
1226		// the init by hash, so nothing renders until the init is built.
1227		assert!(rendition.media_playlist(None).is_none());
1228		let init = rendition.init().await.unwrap().expect("init segment");
1229		assert_eq!(&init[4..8], b"ftyp");
1230
1231		let rendered = rendition.media_playlist(None).expect("playable");
1232		let hash = init_hash(&rendered);
1233		assert!(rendered.contains("seg/0.m4s\n"));
1234		assert!(rendered.contains("seg/1.m4s\n"));
1235		assert_eq!(rendition.init_versioned(hash).await.unwrap(), Some(init));
1236		assert!(rendition.init_versioned("0000000000000000").await.unwrap().is_none());
1237
1238		// The same render, but carrying a credential into every child URL.
1239		let signed = rendition.media_playlist(Some("jwt=abc.def")).expect("playable");
1240		assert!(signed.contains(&format!("#EXT-X-MAP:URI=\"init.{hash}.mp4?jwt=abc.def\"\n")));
1241		assert!(signed.contains("seg/0.m4s?jwt=abc.def\n"));
1242
1243		let segment = rendition.segment(0).await.unwrap().expect("segment fetched on demand");
1244		assert_eq!(&segment[4..8], b"moof", "a fetched group transmuxes to moof+mdat");
1245
1246		// The live-edge group isn't a segment yet, and unknown groups miss.
1247		assert!(rendition.segment(2).await.unwrap().is_none());
1248		assert!(rendition.segment(99).await.unwrap().is_none());
1249
1250		// Keep the publisher alive for the whole test.
1251		drop((media, registration, broadcast));
1252	}
1253
1254	/// A duration marker times the trailing sample of a fetched HLS segment.
1255	#[tokio::test]
1256	async fn a_duration_marker_times_the_hls_trailing_sample() {
1257		let origin = produce_origin();
1258		let mut broadcast = origin.create_broadcast("live").expect("publish allowed");
1259		broadcast.announce(Default::default()).expect("publish allowed");
1260		settle().await;
1261		let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
1262
1263		let reserved = catalog.reserve();
1264		let mut registration = catalog.test_video("video0").unwrap();
1265		registration.set(video_config()).unwrap();
1266		drop(reserved);
1267
1268		let track = broadcast.create_track("video0", None).unwrap();
1269		let mut media = catalog
1270			.media_producer(
1271				track,
1272				moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Video),
1273			)
1274			.unwrap();
1275		media.write(vp8_frame(0, true)).unwrap();
1276		media.write(vp8_frame(1_000_000, false)).unwrap();
1277		media.write(vp8_frame(2_000_000, true)).unwrap();
1278		media.finish().unwrap();
1279
1280		let source = moq_mux::Source::new(origin.consume(), "live");
1281		let broadcaster = Broadcaster::new(source, Config::default()).await.unwrap();
1282		let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await;
1283		let rendition = broadcaster
1284			.rendition(Kind::Video, "video0")
1285			.expect("rendition discovered from the catalog");
1286		let _ = tokio::time::timeout(Duration::from_secs(5), rendition.playable()).await;
1287
1288		let segment = rendition.segment(0).await.unwrap().expect("segment fetched on demand");
1289		assert_eq!(&segment[4..8], b"moof");
1290		drop((media, registration, broadcast));
1291	}
1292
1293	// The DASH half of the fetch-on-demand path: the manifest renders from the same timeline
1294	// windows as the HLS playlists (dynamic while live, aligned S entries across renditions),
1295	// and $Time$ addressing resolves a segment's pts to the same bytes its number does.
1296	#[tokio::test]
1297	async fn serves_dash_manifest_and_time_addressed_segments() {
1298		let origin = produce_origin();
1299		let mut broadcast = origin.create_broadcast("live").expect("publish allowed");
1300		broadcast.announce(Default::default()).expect("publish allowed");
1301		settle().await;
1302		let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
1303
1304		let reserved = catalog.reserve();
1305		let mut video_registration = catalog.test_video("video0").unwrap();
1306		video_registration.set(video_config()).unwrap();
1307		let mut audio_registration = catalog.test_audio("audio0").unwrap();
1308		let audio_config = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2);
1309		audio_registration.set(audio_config).unwrap();
1310		drop(reserved);
1311
1312		let video_track = broadcast.create_track("video0", None).unwrap();
1313		let mut video = catalog
1314			.media_producer(
1315				video_track,
1316				moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data),
1317			)
1318			.unwrap();
1319		let audio_track = broadcast.create_track("audio0", None).unwrap();
1320		let mut audio = catalog
1321			.media_producer(
1322				audio_track,
1323				moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data),
1324			)
1325			.unwrap();
1326
1327		// Video keyframes every 2s cut the segments; audio contributes one group per cut.
1328		let mut audio_write = |micros: u64| {
1329			let keyframe = audio.needs_keyframe();
1330			audio.write(frame(micros, keyframe)).unwrap();
1331			audio.cut(None).unwrap();
1332		};
1333		video.write(frame(0, true)).unwrap();
1334		audio_write(0);
1335		video.write(frame(2_000_000, true)).unwrap();
1336		audio_write(2_000_000);
1337		video.write(frame(4_000_000, true)).unwrap(); // live edge
1338		audio_write(4_000_000);
1339
1340		let source = moq_mux::Source::new(origin.consume(), "live");
1341		let broadcaster = Broadcaster::new(source, Config::default()).await.unwrap();
1342		let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await;
1343		let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.playable()).await;
1344		let video_rendition = broadcaster.rendition(Kind::Video, "video0").expect("video discovered");
1345		let audio_rendition = broadcaster.rendition(Kind::Audio, "audio0").expect("audio discovered");
1346		let _ = tokio::time::timeout(Duration::from_secs(5), video_rendition.playable()).await;
1347		let _ = tokio::time::timeout(Duration::from_secs(5), audio_rendition.playable()).await;
1348
1349		let manifest = broadcaster.manifest(None).expect("manifest renders once playable");
1350		assert!(manifest.contains(" type=\"dynamic\""), "live broadcast is dynamic");
1351		assert!(
1352			manifest.contains(" availabilityStartTime=\""),
1353			"pts 0 is anchored by the catalog root clock"
1354		);
1355		assert!(manifest.contains("id=\"video/video0\""));
1356		assert!(manifest.contains("id=\"audio/audio0\""));
1357		assert!(manifest.contains("media=\"video/video0/seg/t$Time$.m4s\""));
1358		// Both renditions list the same aligned segment spans (the live edge is not listed).
1359		assert_eq!(manifest.matches("<S t=\"0\" d=\"2000\"/>").count(), 2);
1360		assert_eq!(manifest.matches("<S t=\"2000\" d=\"2000\"/>").count(), 2);
1361		assert!(!manifest.contains("<S t=\"4000\""), "the live-edge group is not listed");
1362
1363		// A credential rides every child URL, and the init is named by its hash.
1364		let signed = broadcaster.manifest(Some("jwt=abc.def")).expect("manifest renders");
1365		let hash = between(&signed, "initialization=\"video/video0/init.", ".mp4?jwt=abc.def\"");
1366		let init = video_rendition.init().await.unwrap().expect("init segment");
1367		assert_eq!(video_rendition.init_versioned(hash).await.unwrap(), Some(init));
1368		assert!(signed.contains("media=\"video/video0/seg/t$Time$.m4s?jwt=abc.def\""));
1369
1370		// $Time$ resolves to the same bytes the aligned number does; unknown times miss.
1371		let by_time = video_rendition
1372			.segment_at(2_000)
1373			.await
1374			.unwrap()
1375			.expect("segment fetched by pts");
1376		let by_number = video_rendition.segment(1).await.unwrap().expect("segment by number");
1377		assert_eq!(by_time, by_number);
1378		assert!(video_rendition.segment_at(999).await.unwrap().is_none());
1379
1380		drop((video, audio, video_registration, audio_registration, broadcast));
1381	}
1382
1383	// The clock migration end to end: the catalog root clock (not the archive section) times
1384	// EXT-X-PROGRAM-DATE-TIME and the DASH availabilityStartTime, converting the timeline
1385	// timescale into the clock's.
1386	#[tokio::test]
1387	async fn root_clock_times_playlists_and_manifest() {
1388		use std::time::{SystemTime, UNIX_EPOCH};
1389
1390		let origin = produce_origin();
1391		let mut broadcast = origin.create_broadcast("live").expect("publish allowed");
1392		broadcast.announce(Default::default()).expect("publish allowed");
1393		settle().await;
1394
1395		// PTS zero at exactly the moq epoch, so every timestamp maps to a fixed string.
1396		let wall = UNIX_EPOCH + Duration::from_millis(hang::catalog::MOQ_EPOCH_UNIX_MILLIS);
1397		let config = moq_mux::catalog::Config::default()
1398			.with_clock(moq_mux::Clock::at(std::time::Instant::now(), wall).expect("a representable wall"));
1399		let catalog = moq_mux::catalog::Producer::new(&mut broadcast, config).unwrap();
1400
1401		let reserved = catalog.reserve();
1402		let mut registration = catalog.test_video("video0").unwrap();
1403		registration.set(video_config()).unwrap();
1404		drop(reserved);
1405
1406		let track = broadcast.create_track("video0", None).unwrap();
1407		let mut media = catalog
1408			.media_producer(
1409				track,
1410				moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data),
1411			)
1412			.unwrap();
1413		media.write(frame(0, true)).unwrap();
1414		media.write(frame(2_000_000, true)).unwrap();
1415		media.write(frame(4_000_000, true)).unwrap(); // live edge
1416
1417		let source = moq_mux::Source::new(origin.consume(), "live");
1418		let broadcaster = Broadcaster::new(source, Config::default()).await.unwrap();
1419		let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await;
1420		let rendition = broadcaster.rendition(Kind::Video, "video0").expect("video discovered");
1421		let _ = tokio::time::timeout(Duration::from_secs(5), rendition.playable()).await;
1422
1423		// The timeline section carries no wall field anymore; the root clock names the epoch.
1424		let snapshot = rendition.snapshot();
1425		assert_eq!(
1426			snapshot.program_date_time,
1427			Some(SystemTime::UNIX_EPOCH + Duration::from_millis(hang::catalog::MOQ_EPOCH_UNIX_MILLIS))
1428		);
1429		let playlist = rendition.media_playlist(None).expect("playlist renders");
1430		assert!(
1431			playlist.contains("#EXT-X-PROGRAM-DATE-TIME:2020-01-01T00:00:00.000Z\n"),
1432			"pts 0 maps through the root clock: {playlist}"
1433		);
1434
1435		let manifest = broadcaster.manifest(None).expect("manifest renders once playable");
1436		assert!(
1437			manifest.contains(" availabilityStartTime=\"2020-01-01T00:00:00.000Z\""),
1438			"the manifest anchors at the root clock: {manifest}"
1439		);
1440
1441		drop((media, registration, broadcast));
1442	}
1443
1444	// Without a catalog clock the DASH manifest still anchors, estimating pts 0 from the first
1445	// record's arrival, and playlists simply omit EXT-X-PROGRAM-DATE-TIME.
1446	#[tokio::test]
1447	async fn manifest_falls_back_to_arrival_anchor_without_a_clock() {
1448		let origin = produce_origin();
1449		let mut broadcast = origin.create_broadcast("live").expect("publish allowed");
1450		broadcast.announce(Default::default()).expect("publish allowed");
1451		settle().await;
1452		let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
1453
1454		let reserved = catalog.reserve();
1455		// Stage the clock's removal before the first snapshot publishes, so no consumer ever
1456		// sees a clock on this broadcast.
1457		catalog.modify().unwrap().clock = None;
1458		let mut registration = catalog.test_video("video0").unwrap();
1459		registration.set(video_config()).unwrap();
1460		drop(reserved);
1461		assert_eq!(catalog.snapshot().clock, None);
1462
1463		let track = broadcast.create_track("video0", None).unwrap();
1464		let mut media = catalog
1465			.media_producer(
1466				track,
1467				moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data),
1468			)
1469			.unwrap();
1470		media.write(frame(0, true)).unwrap();
1471		media.write(frame(2_000_000, true)).unwrap();
1472		media.write(frame(4_000_000, true)).unwrap(); // live edge
1473
1474		let source = moq_mux::Source::new(origin.consume(), "live");
1475		let broadcaster = Broadcaster::new(source, Config::default()).await.unwrap();
1476		let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await;
1477		let rendition = broadcaster.rendition(Kind::Video, "video0").expect("video discovered");
1478		let _ = tokio::time::timeout(Duration::from_secs(5), rendition.playable()).await;
1479
1480		assert_eq!(rendition.snapshot().program_date_time, None);
1481		let playlist = rendition.media_playlist(None).expect("playlist renders");
1482		assert!(!playlist.contains("PROGRAM-DATE-TIME"), "{playlist}");
1483
1484		let manifest = broadcaster.manifest(None).expect("manifest renders once playable");
1485		assert!(
1486			manifest.contains(" availabilityStartTime=\""),
1487			"pts 0 is anchored even without a catalog clock"
1488		);
1489
1490		drop((media, registration, broadcast));
1491	}
1492
1493	// A finished broadcast renders a static presentation, offset to its first listed segment.
1494	#[tokio::test]
1495	async fn dash_manifest_turns_static_when_finished() {
1496		let origin = produce_origin();
1497		let mut broadcast = origin.create_broadcast("live").expect("publish allowed");
1498		broadcast.announce(Default::default()).expect("publish allowed");
1499		settle().await;
1500		let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
1501
1502		let reserved = catalog.reserve();
1503		let mut registration = catalog.test_video("video0").unwrap();
1504		registration.set(video_config()).unwrap();
1505		drop(reserved);
1506
1507		let track = broadcast.create_track("video0", None).unwrap();
1508		let mut media = catalog
1509			.media_producer(
1510				track,
1511				moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data),
1512			)
1513			.unwrap();
1514		media.write(frame(0, true)).unwrap();
1515		media.write(frame(2_000_000, true)).unwrap();
1516
1517		let source = moq_mux::Source::new(origin.consume(), "live");
1518		let broadcaster = Broadcaster::new(source, Config::default()).await.unwrap();
1519		let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await;
1520		let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.playable()).await;
1521
1522		// A clean finish promotes the live edge into the final segment and ends the windows.
1523		media.finish().unwrap();
1524		catalog.finish().unwrap();
1525
1526		let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
1527		let manifest = loop {
1528			let manifest = broadcaster.manifest(None).expect("manifest renders");
1529			if manifest.contains(" type=\"static\"") {
1530				break manifest;
1531			}
1532			assert!(tokio::time::Instant::now() < deadline, "manifest never turned static");
1533			tokio::time::sleep(Duration::from_millis(50)).await;
1534		};
1535		// The clean finish promoted the live edge into a final zero-duration segment (its
1536		// single frame has no successor to bound it). HLS lists it as a trailing EXTINF:0,
1537		// but a SegmentTimeline entry at the presentation end would never be requested, so
1538		// the manifest omits it and the presentation ends at the last bounded segment.
1539		assert!(manifest.contains("<S t=\"0\" d=\"2000\"/>"));
1540		assert!(
1541			!manifest.contains("<S t=\"2000\""),
1542			"the zero-duration tail is not listed"
1543		);
1544		assert!(manifest.contains(" mediaPresentationDuration=\"PT2.000S\""));
1545		assert!(!manifest.contains("presentationTimeOffset"));
1546		assert!(!manifest.contains("availabilityStartTime"));
1547
1548		drop((catalog, media, registration, broadcast));
1549	}
1550
1551	// The property HLS needs from the timeline rework: audio and video renditions share one
1552	// segment numbering, cut at the same boundaries. Video is one group per segment; an audio
1553	// segment packs every audio group inside the video segment's span.
1554	// Most publishers declare no durationMax (a real-time GOP can be minutes long), so the
1555	// exporter has to derive EXT-X-TARGETDURATION from the segments it has: a playlist whose
1556	// EXTINF exceeds its target duration is invalid.
1557	#[tokio::test]
1558	async fn target_duration_covers_the_window_without_a_declared_bound() {
1559		let origin = produce_origin();
1560		let mut broadcast = origin.create_broadcast("live").expect("publish allowed");
1561		broadcast.announce(Default::default()).expect("publish allowed");
1562		settle().await;
1563		let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
1564
1565		let reserved = catalog.reserve();
1566		let mut registration = catalog.test_video("video0").unwrap();
1567		registration
1568			.set(hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8))
1569			.unwrap();
1570		drop(reserved);
1571
1572		// 3s GOPs against the default 1s minimum: every segment is one whole GOP.
1573		let track = broadcast.create_track("video0", None).unwrap();
1574		let mut media = catalog
1575			.media_producer(
1576				track,
1577				moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data),
1578			)
1579			.unwrap();
1580		media.write(frame(0, true)).unwrap();
1581		media.write(frame(3_000_000, true)).unwrap();
1582		media.write(frame(6_000_000, true)).unwrap();
1583
1584		let source = moq_mux::Source::new(origin.consume(), "live");
1585		let broadcaster = Broadcaster::new(source, Config::default()).await.unwrap();
1586		let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await;
1587		let rendition = broadcaster.rendition(Kind::Video, "video0").expect("rendition");
1588		let _ = tokio::time::timeout(Duration::from_secs(5), rendition.playable()).await;
1589
1590		let playlist = rendition.snapshot();
1591		assert_eq!(playlist.segments[0].duration, Duration::from_secs(3));
1592		assert_eq!(
1593			playlist.target_duration, 3,
1594			"no bound was declared, so the target duration must still cover the 3s segment"
1595		);
1596	}
1597
1598	#[tokio::test]
1599	async fn audio_and_video_segments_are_aligned() {
1600		let origin = produce_origin();
1601		let mut broadcast = origin.create_broadcast("live").expect("publish allowed");
1602		broadcast.announce(Default::default()).expect("publish allowed");
1603		settle().await;
1604		let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
1605
1606		let reserved = catalog.reserve();
1607		let mut video_registration = catalog.test_video("video0").unwrap();
1608		video_registration.set(video_config()).unwrap();
1609		let mut audio_registration = catalog.test_audio("audio0").unwrap();
1610		let audio_config = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2);
1611		audio_registration.set(audio_config).unwrap();
1612		drop(reserved);
1613
1614		let video_track = broadcast.create_track("video0", None).unwrap();
1615		let mut video = catalog
1616			.media_producer(
1617				video_track,
1618				moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data),
1619			)
1620			.unwrap();
1621		let audio_track = broadcast.create_track("audio0", None).unwrap();
1622		let mut audio = catalog
1623			.media_producer(
1624				audio_track,
1625				moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data),
1626			)
1627			.unwrap();
1628
1629		// Interleaved by pts, as a muxer would demux them: video keyframes every 2s open the
1630		// segments; audio publishes a 500ms group each cut.
1631		let mut audio_write = |micros: u64| {
1632			let keyframe = audio.needs_keyframe();
1633			audio.write(frame(micros, keyframe)).unwrap();
1634			audio.cut(None).unwrap();
1635		};
1636		video.write(frame(0, true)).unwrap(); // segment 0
1637		for micros in [0u64, 500_000, 1_000_000, 1_500_000] {
1638			audio_write(micros); // audio groups 0..=3 pack into segment 0
1639		}
1640		video.write(frame(2_000_000, true)).unwrap(); // segment 1
1641		for micros in [2_000_000u64, 2_500_000, 3_000_000, 3_500_000] {
1642			audio_write(micros); // audio groups 4..=7 pack into segment 1
1643		}
1644		video.write(frame(4_000_000, true)).unwrap(); // segment 2 (live edge)
1645		audio_write(4_000_000); // audio group 8 opens its slice of segment 2
1646
1647		let source = moq_mux::Source::new(origin.consume(), "live");
1648		let broadcaster = Broadcaster::new(source, Config::default()).await.unwrap();
1649		let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await;
1650		let video_rendition = broadcaster.rendition(Kind::Video, "video0").expect("video discovered");
1651		let audio_rendition = broadcaster.rendition(Kind::Audio, "audio0").expect("audio discovered");
1652		let _ = tokio::time::timeout(Duration::from_secs(5), video_rendition.playable()).await;
1653		let _ = tokio::time::timeout(Duration::from_secs(5), audio_rendition.playable()).await;
1654
1655		// Both playlists list the same segment numbers over the same spans.
1656		let video_playlist = video_rendition.snapshot();
1657		let audio_playlist = audio_rendition.snapshot();
1658		assert_eq!(video_playlist.media_sequence, audio_playlist.media_sequence);
1659		let video_segments: Vec<u64> = video_playlist.segments.iter().map(|s| s.segment).collect();
1660		let audio_segments: Vec<u64> = audio_playlist.segments.iter().map(|s| s.segment).collect();
1661		assert_eq!(video_segments, vec![0, 1]);
1662		assert_eq!(audio_segments, vec![0, 1], "audio lists the same aligned segments");
1663		assert_eq!(
1664			audio_playlist.segments[0].duration,
1665			Duration::from_secs(2),
1666			"cut at the video boundary"
1667		);
1668
1669		// The same URI names the same span of content time on either rendition.
1670		let rendered = audio_rendition.media_playlist(None).expect("playable");
1671		assert!(rendered.contains("seg/0.m4s\n"));
1672		assert!(rendered.contains("seg/1.m4s\n"));
1673
1674		// A video segment is one group; the matching audio segment transmuxes its four groups.
1675		let video_segment = video_rendition.segment(1).await.unwrap().expect("video segment");
1676		assert_eq!(&video_segment[4..8], b"moof");
1677		let audio_segment = audio_rendition.segment(1).await.unwrap().expect("audio segment");
1678		assert_eq!(&audio_segment[4..8], b"moof");
1679		assert!(
1680			audio_segment.len() > video_segment.len(),
1681			"the audio segment packs multiple groups into one fragment"
1682		);
1683
1684		drop((video, audio, video_registration, audio_registration, broadcast));
1685	}
1686
1687	// Dropping the broadcaster must not truncate a recording in progress. A cursor holds its
1688	// rendition alive, so the rendition's own watcher still ends the timeline with `end()` --
1689	// which is what promotes the live-edge record into the final segment. Force-closing the
1690	// rendition here instead would race that (`end()` no-ops on a closed channel) and silently
1691	// drop the last segment of every recording.
1692	#[tokio::test]
1693	async fn dropping_the_broadcaster_keeps_a_cursor_drainable() {
1694		let origin = produce_origin();
1695		let mut broadcast = origin.create_broadcast("live").expect("publish allowed");
1696		broadcast.announce(Default::default()).expect("publish allowed");
1697		settle().await;
1698		let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
1699
1700		let reserved = catalog.reserve();
1701		let mut registration = catalog.test_video("video0").unwrap();
1702		let config = video_config();
1703		registration.set(config).unwrap();
1704		drop(reserved);
1705
1706		// Two GOPs: group 0 is complete, while group 1 stays at the live edge until the
1707		// publisher finishes.
1708		let track = broadcast.create_track("video0", None).unwrap();
1709		let mut media = catalog
1710			.media_producer(
1711				track,
1712				moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data),
1713			)
1714			.unwrap();
1715		media.write(frame(0, true)).unwrap();
1716		media.write(frame(2_000_000, true)).unwrap();
1717
1718		let source = moq_mux::Source::new(origin.consume(), "live");
1719		let broadcaster = Broadcaster::new(source, Config::default()).await.unwrap();
1720		let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await;
1721		let rendition = broadcaster
1722			.rendition(Kind::Video, "video0")
1723			.expect("rendition discovered");
1724		let mut segments = rendition.segments();
1725
1726		// The recorder drops the broadcaster before finalizing its uploads.
1727		drop(broadcaster);
1728
1729		// Finish the source after teardown, forcing the broadcaster's drop to land before the
1730		// rendition watcher's clean-end path.
1731		media.finish().unwrap();
1732		catalog.finish().unwrap();
1733
1734		let mut groups = Vec::new();
1735		while let Some(segment) = tokio::time::timeout(Duration::from_secs(5), segments.next())
1736			.await
1737			.expect("the cursor drains without parking")
1738			.unwrap()
1739		{
1740			groups.push(segment.segment);
1741		}
1742		assert!(
1743			groups.contains(&1),
1744			"the final segment survives dropping the broadcaster, got {groups:?}"
1745		);
1746
1747		drop((catalog, media, registration, broadcast, rendition));
1748	}
1749
1750	/// Whether `haystack` carries `needle` verbatim, i.e. whether a transmuxed segment carries a
1751	/// particular publisher's frame payloads.
1752	fn contains(haystack: &bytes::Bytes, needle: &[u8]) -> bool {
1753		haystack.windows(needle.len()).any(|window| window == needle)
1754	}
1755
1756	/// Reconcile a one-video-rendition catalog snapshot by hand and drive the timeline, which is
1757	/// what `watch_catalog` does with each snapshot.
1758	fn export(
1759		upstream: &Upstream,
1760		config: &hang::catalog::VideoConfig,
1761	) -> (Arc<Rendition>, tokio::task::JoinHandle<()>) {
1762		let mut catalog = moq_mux::catalog::hang::Catalog::default();
1763		catalog.video.renditions.insert("video0".to_string(), config.clone());
1764		let archive = hang::catalog::Archive::new(hang::timeline::DEFAULT_NAME);
1765		catalog.archive = Some(archive.clone());
1766
1767		let renditions = renditions::Producer::new(Config::default().window);
1768		renditions.sync(upstream, &catalog);
1769		let watcher = tokio::spawn(watch_timeline(upstream.broadcast.clone(), archive, renditions.fanout()));
1770		let rendition = renditions.get(Kind::Video, "video0").expect("rendition synced");
1771		(rendition, watcher)
1772	}
1773
1774	// A same-path republish takes the origin leaf over with a brand new broadcast (an ordinary
1775	// publisher is `Hop::UNKNOWN`, which never counts as the same publisher, so even a plain
1776	// reconnect qualifies). Renditions derived from the old broadcast's catalog must not serve the
1777	// replacement's media: its group numbering restarts, so those bytes would be served under the
1778	// replaced broadcast's segment number, duration, and PROGRAM-DATE-TIME.
1779	#[tokio::test]
1780	async fn a_replacement_publisher_is_not_served_under_the_replaced_catalog() {
1781		const OLD: &[u8] = b"OLDOLDOLDOLDOLDO";
1782		const NEW: &[u8] = b"NEWNEWNEWNEWNEWN";
1783
1784		// Publish broadcast "live": a `video0` rendition plus three keyframe groups whose frames
1785		// carry `payload`, so the two epochs' media differs byte for byte. The returned handle
1786		// keeps the publisher alive until it is dropped.
1787		fn publish(
1788			origin: &moq_net::origin::Producer,
1789			payload: &'static [u8],
1790		) -> (Box<dyn std::any::Any>, hang::catalog::VideoConfig) {
1791			let mut broadcast = origin.create_broadcast("live").expect("publish allowed");
1792			broadcast.announce(Default::default()).expect("publish allowed");
1793			let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
1794
1795			let reserved = catalog.reserve();
1796			let mut registration = catalog.test_video("video0").unwrap();
1797			let config = video_config();
1798			registration.set(config.clone()).unwrap();
1799			drop(reserved);
1800
1801			let track = broadcast.create_track("video0", None).unwrap();
1802			let mut media = catalog
1803				.media_producer(
1804					track,
1805					moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data),
1806				)
1807				.unwrap();
1808			media.write(payload_frame(0, true, payload)).unwrap();
1809			media.write(payload_frame(2_000_000, true, payload)).unwrap();
1810			media.write(payload_frame(4_000_000, true, payload)).unwrap();
1811
1812			(Box::new((broadcast, catalog, registration, media)), config)
1813		}
1814
1815		let origin = produce_origin();
1816		let (old, config) = publish(&origin, OLD);
1817		settle().await;
1818
1819		// The export binds to the broadcast it reads the catalog from.
1820		let source = moq_mux::Source::new(origin.consume(), "live");
1821		let upstream = Upstream {
1822			broadcast: source.broadcast().await.unwrap(),
1823			source,
1824		};
1825
1826		// The publisher reconnects before the catalog snapshot is reconciled. That gap is wide in
1827		// practice: `watch_catalog` retries a not-yet-written catalog track with backoff, so
1828		// seconds can pass between resolving the broadcast and syncing its first snapshot.
1829		drop(old);
1830		let (new, _) = publish(&origin, NEW);
1831		settle().await;
1832
1833		let (rendition, watcher) = export(&upstream, &config);
1834		let _ = tokio::time::timeout(Duration::from_secs(1), rendition.playable()).await;
1835		let served = rendition.segment(0).await.unwrap();
1836		if let Some(served) = &served {
1837			assert!(
1838				!contains(served, NEW),
1839				"the replacement publisher's media was served under the replaced broadcast's catalog"
1840			);
1841		}
1842		assert!(
1843			served.is_none(),
1844			"the replaced broadcast is closed, so it serves nothing"
1845		);
1846		watcher.abort();
1847
1848		// Control: an export started after the takeover does serve the new publisher's media, so
1849		// the assertion above is about which epoch a rendition is bound to, not about a broadcast
1850		// that happens to be unreadable.
1851		let source = moq_mux::Source::new(origin.consume(), "live");
1852		let fresh = Upstream {
1853			broadcast: source.broadcast().await.unwrap(),
1854			source,
1855		};
1856		let (rendition, watcher) = export(&fresh, &config);
1857		tokio::time::timeout(Duration::from_secs(5), rendition.playable())
1858			.await
1859			.expect("the new publisher's timeline arrives");
1860		let served = rendition
1861			.segment(0)
1862			.await
1863			.unwrap()
1864			.expect("the new publisher is servable on its own catalog");
1865		assert!(
1866			contains(&served, NEW),
1867			"the new export serves the new publisher's media"
1868		);
1869		watcher.abort();
1870
1871		drop(new);
1872	}
1873
1874	// The sibling half of the case above. A rendition whose catalog names another broadcast is
1875	// bound to whatever serves that path when the rendition is created, not when its first
1876	// segment is requested: the timeline on the catalog broadcast keeps describing the epoch it
1877	// was written for, so a media publisher that reconnects (restarting its group numbering)
1878	// must not answer for segments the old catalog already described.
1879	#[tokio::test]
1880	async fn a_replacement_sibling_is_not_served_under_the_replaced_catalog() {
1881		const OLD: &[u8] = b"OLDOLDOLDOLDOLDO";
1882		const NEW: &[u8] = b"NEWNEWNEWNEWNEWN";
1883
1884		// Publish broadcast "media": a `video0` track of three keyframe groups whose frames carry
1885		// `payload`, so the two epochs' media differs byte for byte. `recorder` enrolls the track
1886		// in the *catalog* broadcast's timeline; the replacement publishes without one, exactly
1887		// as a reconnecting media publisher does before the catalog broadcast catches up.
1888		fn publish_media(
1889			origin: &moq_net::origin::Producer,
1890			payload: &'static [u8],
1891			recorder: Option<moq_mux::timeline::Recorder>,
1892		) -> Box<dyn std::any::Any> {
1893			let broadcast = origin.create_broadcast("media").expect("publish allowed");
1894			broadcast.announce(Default::default()).expect("publish allowed");
1895			let track = broadcast.create_track("video0", None).unwrap();
1896
1897			let mut media = moq_mux::container::Producer::new(
1898				track,
1899				moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data),
1900			);
1901			if let Some(recorder) = recorder {
1902				media = media.with_recorder(recorder);
1903			}
1904			media.write(payload_frame(0, true, payload)).unwrap();
1905			media.write(payload_frame(2_000_000, true, payload)).unwrap();
1906			media.write(payload_frame(4_000_000, true, payload)).unwrap();
1907
1908			Box::new((broadcast, media))
1909		}
1910
1911		let origin = produce_origin();
1912
1913		// The catalog broadcast carries the catalog and the timeline; the media lives next door.
1914		let mut live = origin.create_broadcast("live").expect("publish allowed");
1915		live.announce(Default::default()).expect("publish allowed");
1916		let catalog = moq_mux::catalog::Producer::new(&mut live, moq_mux::catalog::Config::default()).unwrap();
1917		let recorder = catalog.enroll_test("video0").unwrap();
1918
1919		let old_media = publish_media(&origin, OLD, Some(recorder));
1920		settle().await;
1921
1922		let source = moq_mux::Source::new(origin.consume(), "live");
1923		let upstream = Upstream {
1924			broadcast: source.broadcast().await.unwrap(),
1925			source,
1926		};
1927
1928		// A relative reference replaces the base's last segment, so "media" is a sibling of the
1929		// catalog broadcast "live".
1930		let mut config = video_config();
1931		config.broadcast = Some(moq_net::path::Relative::new("media").to_owned());
1932
1933		let (rendition, watcher) = export(&upstream, &config);
1934		tokio::time::timeout(Duration::from_secs(5), rendition.playable())
1935			.await
1936			.expect("the timeline reaches the rendition");
1937
1938		// The media publisher reconnects before any segment is requested, taking the origin leaf
1939		// over with a brand new broadcast whose group numbering restarts from zero.
1940		drop(old_media);
1941		let new_media = publish_media(&origin, NEW, None);
1942		settle().await;
1943
1944		let served = rendition
1945			.segment(0)
1946			.await
1947			.expect("a replaced sibling is an unavailable group, not a server error");
1948		if let Some(served) = &served {
1949			assert!(
1950				!contains(served, NEW),
1951				"the replacement publisher's media was served under the replaced sibling's segment numbers"
1952			);
1953		}
1954		assert!(
1955			served.is_none(),
1956			"the replaced sibling broadcast is closed, so it serves nothing"
1957		);
1958		watcher.abort();
1959
1960		// Control: an export started after the takeover does serve the new publisher's media, so
1961		// the assertion above is about which epoch the rendition is bound to, not about a
1962		// broadcast that happens to be unreadable.
1963		let source = moq_mux::Source::new(origin.consume(), "live");
1964		let fresh = Upstream {
1965			broadcast: source.broadcast().await.unwrap(),
1966			source,
1967		};
1968		let (rendition, watcher) = export(&fresh, &config);
1969		tokio::time::timeout(Duration::from_secs(5), rendition.playable())
1970			.await
1971			.expect("the timeline reaches the fresh rendition");
1972		let served = rendition
1973			.segment(0)
1974			.await
1975			.unwrap()
1976			.expect("the new publisher is servable on a freshly bound rendition");
1977		assert!(
1978			contains(&served, NEW),
1979			"the new export serves the new publisher's media"
1980		);
1981		watcher.abort();
1982
1983		drop((new_media, catalog, live));
1984	}
1985
1986	fn hops(ids: &[u64]) -> moq_net::Hops {
1987		let mut hops = moq_net::Hops::new();
1988		for id in ids {
1989			hops.push(moq_net::Hop::new(*id).unwrap()).unwrap();
1990		}
1991		hops
1992	}
1993
1994	fn sibling_route(first: u64) -> moq_net::origin::Route {
1995		moq_net::origin::Route::default().with_hops(hops(&[first]))
1996	}
1997
1998	/// A standalone media publisher (not an origin leaf) so the sibling is served through a
1999	/// routed front whose first hop can change.
2000	fn write_routed_media(
2001		broadcast: &mut moq_net::broadcast::Producer,
2002		payload: &'static [u8],
2003		recorder: moq_mux::timeline::Recorder,
2004		start_micros: u64,
2005	) -> moq_mux::container::Producer<moq_mux::catalog::hang::Container> {
2006		let track = broadcast.create_track("video0", None).unwrap();
2007		let mut media = moq_mux::container::Producer::new(
2008			track,
2009			moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data),
2010		)
2011		.with_recorder(recorder);
2012		media.write(payload_frame(start_micros, true, payload)).unwrap();
2013		media
2014			.write(payload_frame(start_micros + 2_000_000, true, payload))
2015			.unwrap();
2016		media
2017			.write(payload_frame(start_micros + 4_000_000, true, payload))
2018			.unwrap();
2019		media
2020	}
2021
2022	async fn accept_sibling(server: &moq_net::origin::Dynamic, broadcast: &moq_net::broadcast::Producer) {
2023		let request = tokio::time::timeout(Duration::from_secs(5), server.requested_broadcast())
2024			.await
2025			.expect("the sibling path is requested")
2026			.expect("the handler is open");
2027		request.accept(broadcast);
2028	}
2029
2030	async fn until_empty(rendition: &Rendition) {
2031		let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
2032		loop {
2033			if rendition.snapshot().segments.is_empty() {
2034				return;
2035			}
2036			assert!(
2037				tokio::time::Instant::now() < deadline,
2038				"old sibling rows were not dropped after replacement"
2039			);
2040			tokio::task::yield_now().await;
2041		}
2042	}
2043
2044	// A bound sibling whose publisher is replaced through a different first hop ends with
2045	// Dropped. The exporter must drop rows listed for the old publisher, re-bind, and list
2046	// only rows that arrive after the new bind resolves: fetching an old row from the
2047	// replacement would serve restarted groups under the previous publisher's segment numbers.
2048	#[tokio::test]
2049	async fn a_replaced_first_hop_sibling_drops_old_rows_and_rebinds() {
2050		const OLD: &[u8] = b"OLDOLDOLDOLDOLDO";
2051		const NEW: &[u8] = b"NEWNEWNEWNEWNEWN";
2052
2053		let origin = produce_origin();
2054		let mut live = origin.create_broadcast("live").expect("publish allowed");
2055		live.announce(Default::default()).expect("publish allowed");
2056		let catalog = moq_mux::catalog::Producer::new(&mut live, moq_mux::catalog::Config::default()).unwrap();
2057		let recorder = catalog.enroll_test("video0").unwrap();
2058
2059		let mut old_media = moq_net::broadcast::Info::new().produce();
2060		let _old_track = write_routed_media(&mut old_media, OLD, recorder, 0);
2061		let old_server = origin.dynamic("media", sibling_route(10)).unwrap();
2062		settle().await;
2063
2064		let source = moq_mux::Source::new(origin.consume(), "live");
2065		let upstream = Upstream {
2066			broadcast: source.broadcast().await.unwrap(),
2067			source,
2068		};
2069		let mut config = video_config();
2070		config.broadcast = Some(moq_net::path::Relative::new("media").to_owned());
2071
2072		let (rendition, watcher) = export(&upstream, &config);
2073		accept_sibling(&old_server, &old_media).await;
2074		tokio::time::timeout(Duration::from_secs(5), rendition.playable())
2075			.await
2076			.expect("the timeline reaches the rendition");
2077		let served = rendition
2078			.segment(0)
2079			.await
2080			.unwrap()
2081			.expect("the original sibling is servable");
2082		assert!(contains(&served, OLD), "the first hop serves the original publisher");
2083		assert!(!rendition.snapshot().segments.is_empty());
2084
2085		// The replacement is already announced before the incumbent is dropped, matching a
2086		// rival publisher that appears while the current first hop is still serving.
2087		let new_server = origin.dynamic("media", sibling_route(11)).unwrap();
2088		drop((old_server, old_media, _old_track));
2089		until_empty(&rendition).await;
2090		assert!(
2091			rendition.segment(0).await.unwrap().is_none(),
2092			"a row listed for the old publisher answers 404 after replacement"
2093		);
2094
2095		let mut new_media = moq_net::broadcast::Info::new().produce();
2096		accept_sibling(&new_server, &new_media).await;
2097		// The rendition skips timeline rows until this bind resolves. Joining the
2098		// same front waits for attach, so writes below land after that.
2099		tokio::time::timeout(Duration::from_secs(5), origin.consume().request_broadcast("media"))
2100			.await
2101			.expect("the rebound sibling resolves")
2102			.expect("the replacement is routable");
2103		let recorder = catalog.enroll_test("video0").unwrap();
2104		let _new_track = write_routed_media(&mut new_media, NEW, recorder, 6_000_000);
2105		let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
2106		loop {
2107			if !rendition.snapshot().segments.is_empty() {
2108				break;
2109			}
2110			assert!(
2111				tokio::time::Instant::now() < deadline,
2112				"rows produced after the new bind were not listed"
2113			);
2114			tokio::task::yield_now().await;
2115		}
2116		let listed = rendition
2117			.snapshot()
2118			.segments
2119			.into_iter()
2120			.find(|segment| !segment.gap)
2121			.expect("a non-gap row after the new bind")
2122			.segment;
2123		let served = rendition
2124			.segment(listed)
2125			.await
2126			.unwrap()
2127			.expect("the rebound sibling is servable");
2128		assert!(
2129			contains(&served, NEW),
2130			"rows after the new bind come from the replacement"
2131		);
2132		assert!(!contains(&served, OLD));
2133		assert!(
2134			rendition.segment(0).await.unwrap().is_none(),
2135			"a row listed for the old publisher is not served from the replacement"
2136		);
2137		watcher.abort();
2138		drop((new_media, _new_track, new_server, catalog, live));
2139	}
2140
2141	// A restart can land while a segment request waits for its media broadcast. The init
2142	// decoded from the old run's groups must not be cached for the new run.
2143	#[tokio::test]
2144	async fn a_segment_straddling_a_restart_keeps_its_init_out_of_the_new_run() {
2145		tokio::time::pause();
2146		// A VP8 keyframe whose 320x240 geometry is only known inline.
2147		const KEYFRAME: &[u8] = &[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00];
2148
2149		let origin = produce_origin();
2150		let mut live = origin.create_broadcast("live").expect("publish allowed");
2151		live.announce(Default::default()).expect("publish allowed");
2152		let catalog = moq_mux::catalog::Producer::new(&mut live, moq_mux::catalog::Config::default()).unwrap();
2153		let recorder = catalog.enroll_test("video0").unwrap();
2154
2155		let mut media = moq_net::broadcast::Info::new().produce();
2156		let _track = write_routed_media(&mut media, KEYFRAME, recorder, 0);
2157		let server = origin.dynamic("media", sibling_route(10)).unwrap();
2158		settle().await;
2159
2160		let source = moq_mux::Source::new(origin.consume(), "live");
2161		let upstream = Upstream {
2162			broadcast: source.broadcast().await.unwrap(),
2163			source,
2164		};
2165		let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8);
2166		config.broadcast = Some(moq_net::path::Relative::new("media").to_owned());
2167
2168		let mut snapshot = moq_mux::catalog::hang::Catalog::default();
2169		snapshot.video.renditions.insert("video0".to_string(), config);
2170		let archive = hang::catalog::Archive::new(hang::timeline::DEFAULT_NAME);
2171		snapshot.archive = Some(archive.clone());
2172		let renditions = renditions::Producer::new(Config::default().window);
2173		renditions.sync(&upstream, &snapshot);
2174		let fanout = renditions.fanout();
2175		let watcher = tokio::spawn(watch_timeline(upstream.broadcast.clone(), archive, fanout.clone()));
2176		let rendition = renditions.get(Kind::Video, "video0").unwrap();
2177		tokio::time::timeout(Duration::from_secs(5), rendition.playable())
2178			.await
2179			.expect("the timeline reaches the rendition");
2180
2181		// The sibling request is unanswered, so the segment waits for its media broadcast.
2182		let mut segment = std::pin::pin!(rendition.segment(0));
2183		assert!(
2184			tokio::time::timeout(Duration::from_millis(10), segment.as_mut())
2185				.await
2186				.is_err()
2187		);
2188		fanout.set_generation(Some("run-1".into()));
2189		fanout.set_generation(Some("run-2".into()));
2190
2191		accept_sibling(&server, &media).await;
2192		segment
2193			.await
2194			.unwrap()
2195			.expect("the old run's rows were captured before the restart");
2196		assert!(
2197			rendition.init().await.unwrap().is_none(),
2198			"the new run has no media of its own yet"
2199		);
2200		watcher.abort();
2201	}
2202
2203	// A replacement already present when the export starts is the sibling the request
2204	// resolves to: it is served, not treated as a stale epoch.
2205	#[tokio::test]
2206	async fn a_sibling_present_at_export_start_is_served() {
2207		const NEW: &[u8] = b"NEWNEWNEWNEWNEWN";
2208
2209		let origin = produce_origin();
2210		let mut live = origin.create_broadcast("live").expect("publish allowed");
2211		live.announce(Default::default()).expect("publish allowed");
2212		let catalog = moq_mux::catalog::Producer::new(&mut live, moq_mux::catalog::Config::default()).unwrap();
2213		let recorder = catalog.enroll_test("video0").unwrap();
2214
2215		let mut media = moq_net::broadcast::Info::new().produce();
2216		let _track = write_routed_media(&mut media, NEW, recorder, 0);
2217		let server = origin.dynamic("media", sibling_route(11)).unwrap();
2218		settle().await;
2219
2220		let source = moq_mux::Source::new(origin.consume(), "live");
2221		let upstream = Upstream {
2222			broadcast: source.broadcast().await.unwrap(),
2223			source,
2224		};
2225		let mut config = video_config();
2226		config.broadcast = Some(moq_net::path::Relative::new("media").to_owned());
2227
2228		let (rendition, watcher) = export(&upstream, &config);
2229		accept_sibling(&server, &media).await;
2230		tokio::time::timeout(Duration::from_secs(5), rendition.playable())
2231			.await
2232			.expect("the timeline reaches the rendition");
2233		let served = rendition
2234			.segment(0)
2235			.await
2236			.unwrap()
2237			.expect("the sibling present at export start is servable");
2238		assert!(contains(&served, NEW));
2239		watcher.abort();
2240		drop((media, _track, server, catalog, live));
2241	}
2242
2243	// The replacement is often announced after the incumbent is already gone. The rebind
2244	// then resolves Unroutable; Binding never retries, so the next poll must issue a new
2245	// request or the rendition stays empty forever.
2246	#[tokio::test]
2247	async fn a_replaced_sibling_recovers_after_an_unroutable_gap() {
2248		const OLD: &[u8] = b"OLDOLDOLDOLDOLDO";
2249		const NEW: &[u8] = b"NEWNEWNEWNEWNEWN";
2250
2251		let origin = produce_origin();
2252		let mut live = origin.create_broadcast("live").expect("publish allowed");
2253		live.announce(Default::default()).expect("publish allowed");
2254		let catalog = moq_mux::catalog::Producer::new(&mut live, moq_mux::catalog::Config::default()).unwrap();
2255		let recorder = catalog.enroll_test("video0").unwrap();
2256
2257		let mut old_media = moq_net::broadcast::Info::new().produce();
2258		let _old_track = write_routed_media(&mut old_media, OLD, recorder, 0);
2259		let old_server = origin.dynamic("media", sibling_route(10)).unwrap();
2260		settle().await;
2261
2262		let source = moq_mux::Source::new(origin.consume(), "live");
2263		let upstream = Upstream {
2264			broadcast: source.broadcast().await.unwrap(),
2265			source,
2266		};
2267		let mut config = video_config();
2268		config.broadcast = Some(moq_net::path::Relative::new("media").to_owned());
2269
2270		let (rendition, watcher) = export(&upstream, &config);
2271		accept_sibling(&old_server, &old_media).await;
2272		tokio::time::timeout(Duration::from_secs(5), rendition.playable())
2273			.await
2274			.expect("the timeline reaches the rendition");
2275		assert!(rendition.segment(0).await.unwrap().is_some());
2276
2277		drop((old_server, old_media, _old_track));
2278		until_empty(&rendition).await;
2279		for _ in 0..4 {
2280			assert!(rendition.snapshot().segments.is_empty());
2281			assert!(rendition.segment(0).await.unwrap().is_none());
2282		}
2283
2284		let new_server = origin.dynamic("media", sibling_route(11)).unwrap();
2285		let mut new_media = moq_net::broadcast::Info::new().produce();
2286		let _ = rendition.snapshot();
2287		accept_sibling(&new_server, &new_media).await;
2288		tokio::time::timeout(Duration::from_secs(5), origin.consume().request_broadcast("media"))
2289			.await
2290			.expect("the rebound sibling resolves")
2291			.expect("the replacement is routable");
2292		let recorder = catalog.enroll_test("video0").unwrap();
2293		let _new_track = write_routed_media(&mut new_media, NEW, recorder, 6_000_000);
2294		let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
2295		loop {
2296			if !rendition.snapshot().segments.is_empty() {
2297				break;
2298			}
2299			assert!(
2300				tokio::time::Instant::now() < deadline,
2301				"rows produced after the new bind were not listed"
2302			);
2303			tokio::task::yield_now().await;
2304		}
2305		let listed = rendition
2306			.snapshot()
2307			.segments
2308			.into_iter()
2309			.find(|segment| !segment.gap)
2310			.expect("a non-gap row after the new bind")
2311			.segment;
2312		let served = rendition
2313			.segment(listed)
2314			.await
2315			.unwrap()
2316			.expect("the rebound sibling is servable after an unroutable gap");
2317		assert!(contains(&served, NEW));
2318		assert!(rendition.segment(0).await.unwrap().is_none());
2319		watcher.abort();
2320		drop((new_media, _new_track, new_server, catalog, live));
2321	}
2322
2323	// A rendition the catalog drops must end its segment cursors. The cursor holds an
2324	// `Arc<Rendition>`, so without an explicit close the rendition (and its timeline
2325	// subscription) would stay alive and the cursor would park at the live edge forever.
2326	#[tokio::test]
2327	async fn removing_a_rendition_ends_its_segment_cursor() {
2328		let origin = produce_origin();
2329		let broadcast = origin.create_broadcast("live").expect("publish allowed");
2330		broadcast.announce(Default::default()).expect("publish allowed");
2331		let source = moq_mux::Source::new(origin.consume(), "live");
2332		settle().await;
2333		let upstream = Upstream {
2334			broadcast: source.broadcast().await.unwrap(),
2335			source,
2336		};
2337
2338		// Drive the producer directly so the catalog can be reconciled synchronously.
2339		let renditions = renditions::Producer::new(Config::default().window);
2340		let media = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8);
2341		let mut catalog = moq_mux::catalog::hang::Catalog::default();
2342		catalog.video.renditions.insert("video".to_string(), media);
2343		catalog.archive = Some(hang::catalog::Archive::new(hang::timeline::DEFAULT_NAME));
2344		renditions.sync(&upstream, &catalog);
2345
2346		let rendition = renditions.get(Kind::Video, "video").expect("rendition synced");
2347		let mut segments = rendition.segments();
2348
2349		// The catalog drops the rendition: its cursor must run dry rather than park.
2350		let mut empty = moq_mux::catalog::hang::Catalog::default();
2351		empty.archive = Some(hang::catalog::Archive::new(hang::timeline::DEFAULT_NAME));
2352		renditions.sync(&upstream, &empty);
2353		let ended = tokio::time::timeout(Duration::from_secs(5), segments.next())
2354			.await
2355			.expect("a removed rendition's cursor ends instead of parking")
2356			.unwrap();
2357		assert!(ended.is_none(), "no segments remained to drain");
2358
2359		drop((broadcast, rendition));
2360	}
2361
2362	// Dropping the broadcaster must release its renditions even when a cursor (and an
2363	// `Arc<Rendition>` it was handed) outlives it. The map lives in shared state, so without an
2364	// explicit teardown those would pin every rendition's timeline watcher -- and the standing
2365	// source subscription it holds -- for as long as the consumer lived.
2366	#[tokio::test]
2367	async fn dropping_the_broadcaster_releases_its_renditions() {
2368		let origin = produce_origin();
2369		let mut broadcast = origin.create_broadcast("live").expect("publish allowed");
2370		broadcast.announce(Default::default()).expect("publish allowed");
2371		settle().await;
2372		let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
2373
2374		let reserved = catalog.reserve();
2375		let mut registration = catalog.test_video("video0").unwrap();
2376		let config = video_config();
2377		registration.set(config).unwrap();
2378		drop(reserved);
2379
2380		let track = broadcast.create_track("video0", None).unwrap();
2381		let mut media = catalog
2382			.media_producer(
2383				track,
2384				moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data),
2385			)
2386			.unwrap();
2387		media.write(frame(0, true)).unwrap();
2388		media.write(frame(2_000_000, true)).unwrap();
2389
2390		let source = moq_mux::Source::new(origin.consume(), "live");
2391		let broadcaster = Broadcaster::new(source, Config::default()).await.unwrap();
2392
2393		let mut renditions = broadcaster.renditions();
2394		let added = tokio::time::timeout(Duration::from_secs(5), renditions.next())
2395			.await
2396			.expect("a rendition is discovered");
2397		let Some(renditions::Event::Added(_rendition)) = added else {
2398			panic!("expected an Added event");
2399		};
2400
2401		// The cursor and the handed-out rendition both outlive the broadcaster.
2402		drop(broadcaster);
2403
2404		let next = tokio::time::timeout(Duration::from_secs(5), renditions.next())
2405			.await
2406			.expect("the cursor resolves after the broadcaster drops");
2407		assert!(
2408			matches!(next, Some(renditions::Event::Removed { .. })),
2409			"dropping the broadcaster releases its renditions to a live cursor"
2410		);
2411
2412		// ...and the cursor then reaches a terminal state rather than parking. Releasing the
2413		// renditions is only safe if consumers actually finish.
2414		let ended = tokio::time::timeout(Duration::from_secs(5), renditions.next())
2415			.await
2416			.expect("the cursor terminates after the broadcaster drops");
2417		assert!(ended.is_none(), "the cursor ends once the broadcaster is gone");
2418
2419		drop((catalog, media, registration, broadcast));
2420	}
2421
2422	// The record path: the renditions cursor yields the video rendition, its segment cursor
2423	// yields each finalized segment WITH its media, and closing the publisher drains the final
2424	// segment and then ends both cursors.
2425	#[tokio::test]
2426	async fn record_cursors_yield_renditions_and_segments() {
2427		let origin = produce_origin();
2428		let mut broadcast = origin.create_broadcast("live").expect("publish allowed");
2429		broadcast.announce(Default::default()).expect("publish allowed");
2430		settle().await;
2431		let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
2432
2433		let reserved = catalog.reserve();
2434		let mut registration = catalog.test_video("video0").unwrap();
2435		let config = video_config();
2436		registration.set(config).unwrap();
2437		drop(reserved);
2438
2439		// Groups 0 and 1 are complete; group 2 is the live edge until the publisher drops.
2440		let track = broadcast.create_track("video0", None).unwrap();
2441		let mut media = catalog
2442			.media_producer(
2443				track,
2444				moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data),
2445			)
2446			.unwrap();
2447		media.write(frame(0, true)).unwrap();
2448		media.write(frame(2_000_000, true)).unwrap();
2449		media.write(frame(4_000_000, true)).unwrap();
2450
2451		let source = moq_mux::Source::new(origin.consume(), "live");
2452		let broadcaster = Broadcaster::new(source, Config::default()).await.unwrap();
2453
2454		let mut renditions = broadcaster.renditions();
2455		let rendition = match tokio::time::timeout(Duration::from_secs(5), renditions.next())
2456			.await
2457			.expect("a rendition is discovered")
2458		{
2459			Some(renditions::Event::Added(rendition)) => rendition,
2460			other => panic!("expected an Added event, got {:?}", other.is_some()),
2461		};
2462		assert_eq!(rendition.kind, Kind::Video);
2463		assert_eq!(rendition.name, "video0");
2464
2465		let mut segments = rendition.segments();
2466		assert!(segments.init().await.unwrap().is_some(), "init is buildable");
2467
2468		let first = tokio::time::timeout(Duration::from_secs(5), segments.next())
2469			.await
2470			.expect("first segment finalizes")
2471			.unwrap()
2472			.expect("a segment, not end");
2473		assert_eq!(first.segment, 0);
2474		assert_eq!(&first.media[4..8], b"moof", "the segment carries its transmuxed media");
2475		assert_eq!(first.duration, Duration::from_secs(2));
2476		assert!(!first.discontinuity, "a clean start is not a discontinuity");
2477
2478		let second = segments.next().await.unwrap().expect("second segment");
2479		assert_eq!(second.segment, 1);
2480		assert!(!second.discontinuity, "consecutive segments are continuous");
2481
2482		// Tear down the publisher mid-group. The track ends abruptly (the cursor drains the
2483		// segments it already saw and ends; the still-open live-edge group is NOT finalized,
2484		// since a reset can't vouch that its media is complete), and the broadcast closes.
2485		// (Clean-end finalization of the
2486		// live edge is covered by segments::tests::next_after_walks_finalized_segments.)
2487		drop((catalog, media, registration));
2488		broadcast.close();
2489
2490		let end = tokio::time::timeout(Duration::from_secs(5), segments.next())
2491			.await
2492			.expect("segment cursor resolves after the source is lost")
2493			.unwrap();
2494		assert!(
2495			end.is_none(),
2496			"an abrupt end does not finalize the open live-edge group"
2497		);
2498
2499		// The renditions cursor also ends once the source closes.
2500		let ended = tokio::time::timeout(Duration::from_secs(5), renditions.next())
2501			.await
2502			.expect("renditions cursor resolves");
2503		assert!(ended.is_none(), "renditions cursor ends when the broadcast closes");
2504	}
2505}