Skip to main content

moq_hls/server/
routes.rs

1//! axum handlers for the HLS endpoints.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use axum::Router;
7use axum::extract::{RawQuery, State};
8use axum::http::{StatusCode, Uri, header};
9use axum::response::{IntoResponse, Response};
10use axum::routing::get;
11use bytes::Bytes;
12use percent_encoding::percent_decode_str;
13
14use super::Server;
15use crate::export::{Kind, Rendition};
16
17const M3U8: &str = "application/vnd.apple.mpegurl";
18const MPD: &str = "application/dash+xml";
19const MP4: &str = "video/mp4";
20
21/// How long a rendition lookup waits for the catalog (and its first timeline records) to
22/// populate.
23const READY_TIMEOUT: Duration = Duration::from_secs(5);
24
25pub fn router(server: Server) -> Router {
26	Router::new().route("/{*path}", get(request)).with_state(server)
27}
28
29/// A parsed request path: the broadcast, and the resource under it.
30#[derive(Clone, Debug, PartialEq, Eq)]
31#[non_exhaustive]
32pub struct Route {
33	/// The broadcast path, percent-decoded. An embedder that scopes its origin rewrites
34	/// this relative to that scope before calling [`Server::respond`].
35	pub broadcast: String,
36	/// The resource requested under the broadcast.
37	pub resource: Resource,
38}
39
40/// A resource under a broadcast.
41#[derive(Clone, Debug, PartialEq, Eq)]
42#[non_exhaustive]
43pub enum Resource {
44	/// `master.m3u8`: the HLS multivariant playlist.
45	Master,
46	/// `manifest.mpd`: the DASH manifest.
47	Manifest,
48	/// `{kind}/{rendition}/media.m3u8`: a rendition's HLS media playlist.
49	#[non_exhaustive]
50	Media {
51		/// The rendition's kind.
52		kind: Kind,
53		/// The rendition's name, percent-decoded.
54		rendition: String,
55	},
56	/// `{kind}/{rendition}/init.{hash}.mp4`: a rendition's CMAF init segment.
57	#[non_exhaustive]
58	Init {
59		/// The rendition's kind.
60		kind: Kind,
61		/// The rendition's name, percent-decoded.
62		rendition: String,
63		/// The hash of the init bytes the URL names.
64		hash: String,
65	},
66	/// `{kind}/{rendition}/seg/[{generation}.]{sequence}.m4s`: a segment by its HLS number.
67	#[non_exhaustive]
68	Segment {
69		/// The rendition's kind.
70		kind: Kind,
71		/// The rendition's name, percent-decoded.
72		rendition: String,
73		/// The publisher run the URL names, if the broadcaster carries one.
74		generation: Option<String>,
75		/// The segment's aligned number.
76		sequence: u64,
77	},
78	/// `{kind}/{rendition}/seg/[{generation}.]t{pts}.m4s`: the same bytes, addressed by the
79	/// DASH timeline pts (`$Time$`).
80	#[non_exhaustive]
81	SegmentAt {
82		/// The rendition's kind.
83		kind: Kind,
84		/// The rendition's name, percent-decoded.
85		rendition: String,
86		/// The publisher run the URL names, if the broadcaster carries one.
87		generation: Option<String>,
88		/// The segment's timeline pts.
89		pts: u64,
90	},
91}
92
93impl Route {
94	/// Parse a request path such as `/project/live/video/hd/media.m3u8`, or `None` when it
95	/// addresses nothing.
96	///
97	/// The broadcast occupies every segment before the resource suffix. Each segment is
98	/// percent-decoded, and one that decodes to a `/` is refused, so an encoded separator
99	/// cannot smuggle a different broadcast past a policy check on [`broadcast`](Self::broadcast).
100	pub fn parse(path: &str) -> Option<Self> {
101		let parts = path
102			.strip_prefix('/')?
103			.split('/')
104			.map(|part| {
105				percent_decode_str(part)
106					.decode_utf8()
107					.ok()
108					.map(|part| part.into_owned())
109			})
110			.collect::<Option<Vec<_>>>()?;
111		if parts.iter().any(String::is_empty) {
112			return None;
113		}
114
115		let (broadcast, resource) = match parts.as_slice() {
116			[broadcast @ .., file] if file == "master.m3u8" => (broadcast, Resource::Master),
117			[broadcast @ .., file] if file == "manifest.mpd" => (broadcast, Resource::Manifest),
118			[broadcast @ .., kind, rendition, file] if file == "media.m3u8" => (
119				broadcast,
120				Resource::Media {
121					kind: Kind::parse(kind)?,
122					rendition: rendition.clone(),
123				},
124			),
125			[broadcast @ .., kind, rendition, file] if init_hash(file).is_some() => (
126				broadcast,
127				Resource::Init {
128					kind: Kind::parse(kind)?,
129					rendition: rendition.clone(),
130					hash: init_hash(file)?.to_string(),
131				},
132			),
133			[broadcast @ .., kind, rendition, directory, file] if directory == "seg" => {
134				let kind = Kind::parse(kind)?;
135				let rendition = rendition.clone();
136				let stem = file.strip_suffix(".m4s")?;
137				// `{generation}.{segment}` when the export carries a generation, `{segment}` otherwise.
138				let (generation, stem) = match stem.split_once('.') {
139					Some((generation, stem)) => (Some(generation.to_string()), stem),
140					None => (None, stem),
141				};
142				let resource = match stem.strip_prefix('t') {
143					Some(pts) => Resource::SegmentAt {
144						kind,
145						rendition,
146						generation,
147						pts: pts.parse().ok()?,
148					},
149					None => Resource::Segment {
150						kind,
151						rendition,
152						generation,
153						sequence: stem.parse().ok()?,
154					},
155				};
156				(broadcast, resource)
157			}
158			_ => return None,
159		};
160
161		if broadcast.is_empty() || broadcast.iter().any(|part| part.contains('/')) {
162			return None;
163		}
164		Some(Self {
165			broadcast: broadcast.join("/"),
166			resource,
167		})
168	}
169}
170
171/// The content hash in an `init.{hash}.mp4` file name.
172fn init_hash(file: &str) -> Option<&str> {
173	file.strip_prefix("init.")?
174		.strip_suffix(".mp4")
175		.filter(|hash| !hash.is_empty())
176}
177
178async fn request(State(server): State<Server>, uri: Uri, RawQuery(query): RawQuery) -> Response {
179	match Route::parse(uri.path()) {
180		Some(route) => server.respond(&route, query.as_deref()).await,
181		None => not_found(),
182	}
183}
184
185impl Server {
186	/// Answer a parsed [`Route`] from this server's origin: the handler behind
187	/// [`router`](Self::router), for an embedder that parses and authorizes the request
188	/// itself.
189	///
190	/// `query` is the raw request query (without the leading `?`), propagated to every
191	/// child URL a playlist or manifest lists, so a credential carried there reaches the
192	/// player's follow-up requests.
193	pub async fn respond(&self, route: &Route, query: Option<&str>) -> Response {
194		let broadcast = route.broadcast.as_str();
195		match &route.resource {
196			Resource::Master => master(self, broadcast, query).await,
197			Resource::Manifest => manifest(self, broadcast, query).await,
198			Resource::Media { kind, rendition } => media(self, broadcast, *kind, rendition, query).await,
199			Resource::Init { kind, rendition, hash } => init(self, broadcast, *kind, rendition, hash).await,
200			Resource::Segment {
201				kind,
202				rendition,
203				generation,
204				sequence,
205			} => {
206				let at = SegmentAt::Sequence(*sequence);
207				segment(self, broadcast, *kind, rendition, generation.as_deref(), at).await
208			}
209			Resource::SegmentAt {
210				kind,
211				rendition,
212				generation,
213				pts,
214			} => {
215				let at = SegmentAt::Pts(*pts);
216				segment(self, broadcast, *kind, rendition, generation.as_deref(), at).await
217			}
218		}
219	}
220}
221
222async fn master(server: &Server, broadcast: &str, query: Option<&str>) -> Response {
223	let Some(broadcaster) = server.broadcaster(broadcast).await else {
224		return not_found();
225	};
226	let _ = tokio::time::timeout(READY_TIMEOUT, broadcaster.ready()).await;
227	if broadcaster.is_empty() {
228		return not_found();
229	}
230	m3u8(broadcaster.master_playlist(query))
231}
232
233async fn manifest(server: &Server, broadcast: &str, query: Option<&str>) -> Response {
234	let Some(broadcaster) = server.broadcaster(broadcast).await else {
235		return not_found();
236	};
237	let _ = tokio::time::timeout(READY_TIMEOUT, broadcaster.ready()).await;
238	if broadcaster.is_empty() {
239		return not_found();
240	}
241	// A manifest whose timelines are all empty confuses players; give the broadcast a moment
242	// to index its first complete segment before answering.
243	let _ = tokio::time::timeout(READY_TIMEOUT, broadcaster.playable()).await;
244	// Each representation names its init by hash, which an inline codec only learns from media.
245	broadcaster.build_inits().await;
246	match broadcaster.manifest(query) {
247		Some(manifest) => mpd(manifest),
248		None => not_found(),
249	}
250}
251
252async fn media(server: &Server, broadcast: &str, kind: Kind, rendition: &str, query: Option<&str>) -> Response {
253	let Some(rendition) = rendition_for(server, broadcast, kind, rendition).await else {
254		return not_found();
255	};
256	match tokio::time::timeout(READY_TIMEOUT, rendition.playlist(query)).await {
257		Ok(Ok(Some(playlist))) => m3u8(playlist),
258		Ok(Ok(None)) | Err(_) => not_found(),
259		Ok(Err(err)) => server_error(err),
260	}
261}
262
263async fn init(server: &Server, broadcast: &str, kind: Kind, rendition: &str, hash: &str) -> Response {
264	let Some(rendition) = rendition_for(server, broadcast, kind, rendition).await else {
265		return not_found();
266	};
267	media_result(rendition.init_versioned(hash).await, server)
268}
269
270/// How a segment URL addresses its bytes: HLS by aligned number (`seg/0.m4s`), DASH by
271/// timeline pts (`seg/t2000.m4s`, the SegmentTemplate's `$Time$`).
272enum SegmentAt {
273	Sequence(u64),
274	Pts(u64),
275}
276
277async fn segment(
278	server: &Server,
279	broadcast: &str,
280	kind: Kind,
281	rendition: &str,
282	generation: Option<&str>,
283	at: SegmentAt,
284) -> Response {
285	let Some(rendition) = rendition_for(server, broadcast, kind, rendition).await else {
286		return not_found();
287	};
288	// Only the current run's URLs are served: a restart reuses segment numbers, so another
289	// generation's URL would name different bytes than the ones it once served.
290	let current = || rendition.generation().as_deref() == generation;
291	if !current() {
292		return not_found();
293	}
294	let result = match at {
295		SegmentAt::Sequence(sequence) => rendition.segment(sequence).await,
296		SegmentAt::Pts(pts) => rendition.segment_at(pts).await,
297	};
298	// A generation change while fetching may have swapped the rows under the lookup.
299	if !current() {
300		return not_found();
301	}
302	media_result(result, server)
303}
304
305/// Resolve a rendition, waiting for the catalog to populate.
306async fn rendition_for(server: &Server, broadcast: &str, kind: Kind, rendition: &str) -> Option<Arc<Rendition>> {
307	let broadcaster = server.broadcaster(broadcast).await?;
308	let _ = tokio::time::timeout(READY_TIMEOUT, broadcaster.ready()).await;
309	broadcaster.rendition(kind, rendition)
310}
311
312fn m3u8(body: String) -> Response {
313	// Playlists mutate as the live edge advances, so they must not be cached.
314	(
315		[(header::CONTENT_TYPE, M3U8), (header::CACHE_CONTROL, "no-cache")],
316		body,
317	)
318		.into_response()
319}
320
321fn mpd(body: String) -> Response {
322	// Like the playlists: the manifest mutates as the live edge advances.
323	([(header::CONTENT_TYPE, MPD), (header::CACHE_CONTROL, "no-cache")], body).into_response()
324}
325
326fn media_result(result: crate::Result<Option<Bytes>>, server: &Server) -> Response {
327	match result {
328		Ok(Some(bytes)) => media_bytes(bytes, server),
329		Ok(None) => not_found(),
330		Err(err) => server_error(err),
331	}
332}
333
334fn media_bytes(body: Bytes, server: &Server) -> Response {
335	// Init/segment bytes never change while their URL is listed, but a segment URL without a
336	// generation is not globally unique: a restarted publisher starts a new timeline whose
337	// segment numbers and pts can repeat the old ones with different media. Cap shared caching
338	// at the playlist window - every concurrent viewer of the live window still hits the cache,
339	// while a stale run's bytes age out as fast as the window that stopped listing them.
340	let max_age = server.inner.config.window.as_secs().max(1);
341	(
342		[
343			(header::CONTENT_TYPE, MP4.to_string()),
344			(header::CACHE_CONTROL, format!("public, max-age={max_age}")),
345		],
346		body,
347	)
348		.into_response()
349}
350
351fn not_found() -> Response {
352	// The resource may appear later (a segment not yet produced), so don't let a
353	// CDN pin the 404.
354	(StatusCode::NOT_FOUND, [(header::CACHE_CONTROL, "no-store")]).into_response()
355}
356
357fn server_error(err: crate::Error) -> Response {
358	tracing::warn!(%err, "hls request failed");
359	(StatusCode::INTERNAL_SERVER_ERROR, [(header::CACHE_CONTROL, "no-store")]).into_response()
360}
361
362#[cfg(test)]
363mod tests {
364	use super::*;
365
366	#[test]
367	fn parses_multisegment_broadcast_and_encoded_rendition() {
368		assert_eq!(
369			Route::parse("/project/live/video/cam%231%2Fmain%3Falt/media.m3u8"),
370			Some(Route {
371				broadcast: "project/live".to_string(),
372				resource: Resource::Media {
373					kind: Kind::Video,
374					rendition: "cam#1/main?alt".to_string(),
375				},
376			})
377		);
378	}
379
380	#[test]
381	fn parses_all_resource_routes() {
382		let resource = |path| Route::parse(path).map(|route| route.resource);
383		assert_eq!(resource("/project/live/master.m3u8"), Some(Resource::Master));
384		assert_eq!(resource("/project/live/manifest.mpd"), Some(Resource::Manifest));
385		assert_eq!(
386			resource("/project/live/audio/main/init.0123abcd.mp4"),
387			Some(Resource::Init {
388				kind: Kind::Audio,
389				rendition: "main".to_string(),
390				hash: "0123abcd".to_string(),
391			})
392		);
393		assert_eq!(
394			resource("/project/live/video/main/seg/42.m4s"),
395			Some(Resource::Segment {
396				kind: Kind::Video,
397				rendition: "main".to_string(),
398				generation: None,
399				sequence: 42,
400			})
401		);
402		assert_eq!(
403			resource("/project/live/video/main/seg/run-1.42.m4s"),
404			Some(Resource::Segment {
405				kind: Kind::Video,
406				rendition: "main".to_string(),
407				generation: Some("run-1".to_string()),
408				sequence: 42,
409			})
410		);
411		assert_eq!(
412			resource("/project/live/video/main/seg/t2000.m4s"),
413			Some(Resource::SegmentAt {
414				kind: Kind::Video,
415				rendition: "main".to_string(),
416				generation: None,
417				pts: 2000,
418			})
419		);
420		assert_eq!(
421			resource("/project/live/video/main/seg/init.t2000.m4s"),
422			Some(Resource::SegmentAt {
423				kind: Kind::Video,
424				rendition: "main".to_string(),
425				generation: Some("init".to_string()),
426				pts: 2000,
427			})
428		);
429		assert!(Route::parse("/master.m3u8").is_none());
430		assert!(Route::parse("/project/live/video/main/unknown").is_none());
431		assert!(Route::parse("/project/live/data/main/media.m3u8").is_none());
432		assert!(Route::parse("/project/live/video/main/init.mp4").is_none());
433		assert!(Route::parse("/project/live/video/main/init..mp4").is_none());
434		assert!(Route::parse("/project/live/video/main/seg/tx.m4s").is_none());
435		assert!(Route::parse("/project/live/video/main/seg/1.ts").is_none());
436	}
437
438	#[test]
439	fn rejects_empty_path_segments() {
440		assert!(Route::parse("/project//live/master.m3u8").is_none());
441	}
442
443	#[test]
444	fn rejects_encoded_broadcast_separators() {
445		assert!(Route::parse("/project/private%2F/master.m3u8").is_none());
446		assert!(Route::parse("/project%2Fprivate/master.m3u8").is_none());
447	}
448
449	const TIMEOUT: Duration = Duration::from_secs(10);
450
451	fn vp8_frame(micros: u64, keyframe: bool) -> moq_mux::container::Frame {
452		let payload = if keyframe {
453			&[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00][..]
454		} else {
455			&[0x31, 0x00, 0x00][..]
456		};
457		moq_mux::container::Frame {
458			timestamp: moq_net::Timestamp::from_micros(micros).unwrap(),
459			payload: bytes::Bytes::copy_from_slice(payload),
460			keyframe,
461			duration: None,
462		}
463	}
464
465	fn video_config() -> hang::catalog::VideoConfig {
466		let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8);
467		config.coded_width = Some(320);
468		config.coded_height = Some(240);
469		config.framerate = Some(30.0);
470		config
471	}
472
473	/// A moq-lite publisher↔subscriber pair over loopback TCP, so a FETCH actually
474	/// crosses a session.
475	struct LitePair {
476		pub_origin: moq_net::origin::Producer,
477		sub_origin: moq_net::origin::Producer,
478		_connection: moq_tokio::Connection,
479		accept: tokio::task::JoinHandle<()>,
480	}
481
482	async fn lite_pair() -> LitePair {
483		lite_pair_pub(moq_net::Hop::random()).await
484	}
485
486	async fn lite_pair_pub(config: impl Into<moq_net::origin::Config>) -> LitePair {
487		lite_pair_on("moq-lite-05", config).await
488	}
489
490	async fn lite_pair_on(version: &str, config: impl Into<moq_net::origin::Config>) -> LitePair {
491		let lite: moq_net::Version = version.parse().expect("lite version");
492		let pub_origin = moq_tokio::origin::spawn_config(config.into());
493		let sub_origin = moq_tokio::origin::spawn();
494
495		let mut listen = moq_tokio::listen::Config::default();
496		listen.tcp.bind = Some("127.0.0.1:0".parse().expect("addr"));
497		listen.version = vec![lite];
498		let mut server = listen
499			.init(Default::default())
500			.expect("server")
501			.listen()
502			.await
503			.expect("listen");
504		let addr = server.tcp_local_addr().expect("tcp listener bound");
505
506		let pub_for_accept = pub_origin.clone();
507		let accept = tokio::spawn(async move {
508			let request = server.accept().await.expect("accept");
509			let session = request
510				.with_publisher(&pub_for_accept)
511				.ok()
512				.await
513				.expect("publisher session");
514			let _ = session.closed().await;
515		});
516
517		let mut connect = moq_tokio::connect::Config::default();
518		connect.version = vec![lite];
519		let client = connect
520			.init(Default::default())
521			.expect("client")
522			.with_subscriber(sub_origin.clone())
523			.with_reconnect(false);
524		let url: url::Url = format!("tcp://{addr}/").parse().expect("url");
525		let connection = tokio::time::timeout(TIMEOUT, client.connect(url).established())
526			.await
527			.expect("connect timed out")
528			.expect("connect");
529
530		LitePair {
531			pub_origin,
532			sub_origin,
533			_connection: connection,
534			accept,
535		}
536	}
537
538	async fn oneshot(app: axum::Router, uri: &str) -> axum::http::Response<axum::body::Body> {
539		use tower::ServiceExt;
540		app.oneshot(
541			axum::extract::Request::builder()
542				.uri(uri)
543				.body(axum::body::Body::empty())
544				.unwrap(),
545		)
546		.await
547		.unwrap()
548	}
549
550	async fn wait_listed(app: &axum::Router, playlist: &str, segment: &str) {
551		let deadline = tokio::time::Instant::now() + TIMEOUT;
552		loop {
553			let response = oneshot(app.clone(), playlist).await;
554			if response.status() == StatusCode::OK {
555				let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
556				if String::from_utf8_lossy(&body).contains(segment) {
557					return;
558				}
559			}
560			assert!(
561				tokio::time::Instant::now() < deadline,
562				"{playlist} never listed {segment}"
563			);
564			tokio::time::sleep(Duration::from_millis(50)).await;
565		}
566	}
567
568	fn publish_video(
569		broadcast: &mut moq_net::broadcast::Producer,
570		config: hang::catalog::VideoConfig,
571		track: impl Into<Option<moq_net::track::Info>>,
572	) -> (
573		moq_mux::catalog::Producer,
574		(),
575		moq_net::track::Producer,
576		moq_mux::container::Producer<moq_mux::catalog::hang::Container, hang::catalog::VideoConfig>,
577	) {
578		let catalog = moq_mux::catalog::Producer::new(broadcast, moq_mux::catalog::Config::default()).unwrap();
579		let reserved = catalog.reserve();
580		let track = broadcast.create_track("video0", track).unwrap();
581		let media = reserved
582			.video(
583				track.clone(),
584				moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Video),
585				config,
586			)
587			.unwrap();
588		drop(reserved);
589		(catalog, (), track, media)
590	}
591
592	fn write_three_gops(
593		media: &mut moq_mux::container::Producer<moq_mux::catalog::hang::Container, hang::catalog::VideoConfig>,
594	) {
595		media.write(vp8_frame(0, true)).unwrap();
596		media.write(vp8_frame(1_000_000, false)).unwrap();
597		media.write(vp8_frame(2_000_000, true)).unwrap();
598		media.write(vp8_frame(3_000_000, false)).unwrap();
599		media.write(vp8_frame(4_000_000, true)).unwrap();
600	}
601
602	/// An embedder parses the path, rewrites the broadcast into its own scope, and answers
603	/// through `respond` with the query propagated to every child URL.
604	#[tokio::test]
605	async fn respond_serves_a_route_rewritten_into_scope() {
606		let pair = lite_pair().await;
607		let mut broadcast = pair.pub_origin.create_broadcast("live").expect("publish");
608		broadcast.announce(Default::default()).expect("announce");
609		let (_catalog, _registration, _track, mut media) = publish_video(&mut broadcast, video_config(), None);
610		write_three_gops(&mut media);
611
612		let server = Server::new(pair.sub_origin.consume(), crate::export::Config::default());
613		let mut route = Route::parse("/tenant/live/video/video0/media.m3u8").expect("media route");
614		route.broadcast = route.broadcast.strip_prefix("tenant/").expect("scoped").to_string();
615
616		let deadline = tokio::time::Instant::now() + TIMEOUT;
617		let body = loop {
618			let response = server.respond(&route, Some("jwt=abc")).await;
619			if response.status() == StatusCode::OK {
620				let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
621				let body = String::from_utf8(body.to_vec()).unwrap();
622				if body.contains("seg/0.m4s") {
623					break body;
624				}
625			}
626			assert!(
627				tokio::time::Instant::now() < deadline,
628				"media playlist never listed a segment"
629			);
630			tokio::time::sleep(Duration::from_millis(50)).await;
631		};
632		assert!(body.contains(".mp4?jwt=abc\""), "{body}");
633		assert!(body.contains("seg/0.m4s?jwt=abc"), "{body}");
634
635		pair.accept.abort();
636	}
637
638	/// A recorder's cursor gets the media of a publisher whose moq-lite has no FETCH: the
639	/// cursor's own subscription carries every group to the subscriber's cache.
640	#[tokio::test]
641	async fn a_cursor_records_a_publisher_without_fetch() {
642		for version in [
643			"moq-lite-01",
644			"moq-lite-02",
645			"moq-lite-03",
646			"moq-lite-04",
647			"moq-lite-05",
648		] {
649			let pair = lite_pair_on(version, moq_net::Hop::random()).await;
650			let mut broadcast = pair.pub_origin.create_broadcast("live").expect("publish");
651			broadcast.announce(Default::default()).expect("announce");
652			let (_catalog, _registration, track, mut media) = publish_video(&mut broadcast, video_config(), None);
653
654			let origin = pair.sub_origin.consume();
655			tokio::time::timeout(TIMEOUT, origin.routed("live"))
656				.await
657				.expect("announce timed out")
658				.expect("routed");
659			let source = moq_mux::Source::new(origin, "live");
660			let broadcaster = tokio::time::timeout(
661				TIMEOUT,
662				crate::export::Broadcaster::new(source, crate::export::Config::default()),
663			)
664			.await
665			.expect("broadcaster timed out")
666			.expect("broadcaster");
667			// Recorded as soon as the catalog lists it, before any media, as a recorder does.
668			let mut renditions = broadcaster.renditions();
669			let rendition = match tokio::time::timeout(TIMEOUT, renditions.next()).await {
670				Ok(Some(crate::export::renditions::Event::Added(rendition))) => rendition,
671				_ => panic!("{version}: the catalog never listed the rendition"),
672			};
673			let mut cursor = rendition.segments();
674			let mut next = std::pin::pin!(cursor.next());
675			// Waiting for its first segment, the cursor subscribes before any media exists.
676			let demand = track.demand();
677			tokio::time::timeout(TIMEOUT, async {
678				tokio::select! {
679					_ = next.as_mut() => panic!("{version}: a segment before any media"),
680					used = demand.used() => used.expect("track open"),
681				}
682			})
683			.await
684			.unwrap_or_else(|_| panic!("{version}: the cursor never subscribed"));
685
686			write_three_gops(&mut media);
687			let segment = tokio::time::timeout(TIMEOUT, next)
688				.await
689				.unwrap_or_else(|_| panic!("{version}: no segment recorded"))
690				.unwrap_or_else(|err| panic!("{version}: {err}"))
691				.expect("a segment");
692			assert_eq!(segment.segment, 0, "{version}");
693			assert!(!segment.media.is_empty(), "{version}");
694
695			pair.accept.abort();
696		}
697	}
698
699	async fn status(app: &axum::Router, uri: &str) -> StatusCode {
700		oneshot(app.clone(), uri).await.status()
701	}
702
703	/// Only the URLs the renderers emit are served: the init under its hash, segments under the
704	/// current generation.
705	#[tokio::test]
706	async fn serves_only_versioned_media_urls() {
707		let origin = moq_tokio::origin::spawn();
708		let mut broadcast = origin.create_broadcast("live").expect("publish");
709		broadcast.announce(Default::default()).expect("announce");
710		let (_catalog, _registration, _track, mut media) = publish_video(&mut broadcast, video_config(), None);
711		write_three_gops(&mut media);
712
713		let server = Server::new(origin.consume(), crate::export::Config::default());
714		let app = server.router();
715		wait_listed(&app, "/live/video/video0/media.m3u8", "seg/0.m4s").await;
716		let broadcaster = server.broadcaster("live").await.expect("broadcaster");
717		broadcaster.set_generation(Some("run-1")).unwrap();
718
719		let response = oneshot(app.clone(), "/live/video/video0/media.m3u8").await;
720		let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
721		let playlist = String::from_utf8(body.to_vec()).unwrap();
722		assert!(playlist.contains("\nseg/run-1.0.m4s\n"), "{playlist}");
723		let map = playlist.lines().find(|line| line.starts_with("#EXT-X-MAP:")).unwrap();
724		let init = map
725			.strip_prefix("#EXT-X-MAP:URI=\"")
726			.unwrap()
727			.strip_suffix('"')
728			.unwrap();
729
730		assert_eq!(
731			status(&app, &format!("/live/video/video0/{init}")).await,
732			StatusCode::OK
733		);
734		assert_eq!(status(&app, "/live/video/video0/init.mp4").await, StatusCode::NOT_FOUND);
735		assert_eq!(
736			status(&app, "/live/video/video0/init.0000000000000000.mp4").await,
737			StatusCode::NOT_FOUND
738		);
739
740		assert_eq!(status(&app, "/live/video/video0/seg/run-1.0.m4s").await, StatusCode::OK);
741		assert_eq!(
742			status(&app, "/live/video/video0/seg/run-1.t0.m4s").await,
743			StatusCode::OK
744		);
745		assert_eq!(
746			status(&app, "/live/video/video0/seg/0.m4s").await,
747			StatusCode::NOT_FOUND
748		);
749		assert_eq!(
750			status(&app, "/live/video/video0/seg/run-0.0.m4s").await,
751			StatusCode::NOT_FOUND
752		);
753
754		// A new run: the previous generation's URLs are refused even for numbers it reuses.
755		broadcaster.set_generation(Some("run-2")).unwrap();
756		assert_eq!(
757			status(&app, "/live/video/video0/seg/run-1.0.m4s").await,
758			StatusCode::NOT_FOUND
759		);
760	}
761
762	/// A miss that crossed a moq-lite session answers 404, not 500.
763	#[tokio::test]
764	async fn a_session_crossed_cache_miss_answers_404() {
765		let pool = moq_net::cache::Pool::new(moq_net::cache::Config::default().with_capacity(1));
766		let pair = lite_pair_pub({
767			let mut config = moq_net::origin::Config::default();
768			config.pool = pool;
769			config
770		})
771		.await;
772		let mut broadcast = pair.pub_origin.create_broadcast("live").expect("publish");
773		broadcast.announce(Default::default()).expect("announce");
774		let (_catalog, _registration, _track, mut media) = publish_video(&mut broadcast, video_config(), None);
775		write_three_gops(&mut media);
776
777		let app = Server::new(pair.sub_origin.consume(), crate::export::Config::default()).router();
778		wait_listed(&app, "/live/video/video0/media.m3u8", "seg/0.m4s").await;
779
780		let response = oneshot(app, "/live/video/video0/seg/0.m4s").await;
781		assert_eq!(response.status(), StatusCode::NOT_FOUND);
782		assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
783
784		pair.accept.abort();
785	}
786
787	#[tokio::test]
788	async fn a_disconnected_publisher_on_the_catalog_broadcast_answers_404() {
789		let pair = lite_pair().await;
790		let mut broadcast = pair.pub_origin.create_broadcast("live").expect("publish");
791		broadcast.announce(Default::default()).expect("announce");
792		let (catalog, registration, track, mut media) = publish_video(&mut broadcast, video_config(), None);
793		write_three_gops(&mut media);
794
795		let app = Server::new(pair.sub_origin.consume(), crate::export::Config::default()).router();
796		wait_listed(&app, "/live/video/video0/media.m3u8", "seg/0.m4s").await;
797
798		let remote = tokio::time::timeout(TIMEOUT, pair.sub_origin.consume().request_broadcast("live"))
799			.await
800			.expect("remote resolve timed out")
801			.expect("remote broadcast");
802		drop((catalog, registration, track, media, broadcast));
803		tokio::time::timeout(TIMEOUT, remote.closed())
804			.await
805			.expect("publisher close timed out");
806
807		let response = oneshot(app, "/live/video/video0/seg/0.m4s").await;
808		assert_eq!(response.status(), StatusCode::NOT_FOUND);
809		assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
810
811		pair.accept.abort();
812	}
813
814	#[tokio::test]
815	async fn a_disconnected_sibling_publisher_answers_404() {
816		let pair = lite_pair().await;
817		let mut catalog_broadcast = pair.pub_origin.create_broadcast("room/live").expect("catalog");
818		catalog_broadcast
819			.announce(Default::default())
820			.expect("announce catalog");
821		let media_broadcast = pair.pub_origin.create_broadcast("room/source").expect("media");
822		media_broadcast.announce(Default::default()).expect("announce media");
823
824		let catalog =
825			moq_mux::catalog::Producer::new(&mut catalog_broadcast, moq_mux::catalog::Config::default()).unwrap();
826		let reserved = catalog.reserve();
827		let mut config = video_config();
828		config.broadcast = Some(moq_net::path::Relative::new("../source").to_owned());
829		let track = media_broadcast.create_track("video0", None).unwrap();
830		let mut media = reserved
831			.video(
832				track,
833				moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Video),
834				config,
835			)
836			.unwrap();
837		drop(reserved);
838		write_three_gops(&mut media);
839
840		let app = Server::new(pair.sub_origin.consume(), crate::export::Config::default()).router();
841		wait_listed(&app, "/room/live/video/video0/media.m3u8", "seg/0.m4s").await;
842
843		let remote_media = tokio::time::timeout(TIMEOUT, pair.sub_origin.consume().request_broadcast("room/source"))
844			.await
845			.expect("sibling resolve timed out")
846			.expect("sibling broadcast");
847		drop(media);
848		drop(media_broadcast);
849		tokio::time::timeout(TIMEOUT, remote_media.closed())
850			.await
851			.expect("sibling close timed out");
852
853		let response = oneshot(app, "/room/live/video/video0/seg/0.m4s").await;
854		assert_eq!(response.status(), StatusCode::NOT_FOUND);
855		assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
856
857		drop((catalog, catalog_broadcast));
858		pair.accept.abort();
859	}
860}