1use 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
21const 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#[derive(Clone, Debug, PartialEq, Eq)]
31#[non_exhaustive]
32pub struct Route {
33 pub broadcast: String,
36 pub resource: Resource,
38}
39
40#[derive(Clone, Debug, PartialEq, Eq)]
42#[non_exhaustive]
43pub enum Resource {
44 Master,
46 Manifest,
48 #[non_exhaustive]
50 Media {
51 kind: Kind,
53 rendition: String,
55 },
56 #[non_exhaustive]
58 Init {
59 kind: Kind,
61 rendition: String,
63 hash: String,
65 },
66 #[non_exhaustive]
68 Segment {
69 kind: Kind,
71 rendition: String,
73 generation: Option<String>,
75 sequence: u64,
77 },
78 #[non_exhaustive]
81 SegmentAt {
82 kind: Kind,
84 rendition: String,
86 generation: Option<String>,
88 pts: u64,
90 },
91}
92
93impl Route {
94 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 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
171fn 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 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 let _ = tokio::time::timeout(READY_TIMEOUT, broadcaster.playable()).await;
244 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
270enum 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 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 if !current() {
300 return not_found();
301 }
302 media_result(result, server)
303}
304
305async 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 (
315 [(header::CONTENT_TYPE, M3U8), (header::CACHE_CONTROL, "no-cache")],
316 body,
317 )
318 .into_response()
319}
320
321fn mpd(body: String) -> Response {
322 ([(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 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 (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 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 #[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 #[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 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 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 #[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 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 #[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}