mod master;
mod playlist;
mod rendition;
mod upstream;
pub mod renditions;
pub mod segments;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use moq_mux::catalog::{self, CatalogFormat, Stream};
use rand::RngExt;
pub(crate) use playlist::render_media;
pub use rendition::{Kind, Rendition};
pub(crate) use upstream::Upstream;
const CATALOG_RETRY_MIN: Duration = Duration::from_millis(250);
const CATALOG_RETRY_MAX: Duration = Duration::from_secs(5);
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct Config {
pub window: Duration,
}
impl Default for Config {
fn default() -> Self {
Self {
window: Duration::from_secs(16),
}
}
}
pub struct Broadcaster {
#[cfg_attr(not(feature = "server"), allow(dead_code))]
broadcast: moq_net::broadcast::Consumer,
renditions: renditions::Producer,
watcher: Mutex<Option<tokio::task::JoinHandle<()>>>,
}
impl Broadcaster {
pub async fn new(source: moq_mux::Source, config: Config) -> crate::Result<Arc<Self>> {
let broadcast = source.broadcast().await?;
let upstream = Upstream {
source,
broadcast: broadcast.clone(),
};
let renditions = renditions::Producer::new();
let broadcaster = Arc::new(Self {
broadcast,
renditions: renditions.clone(),
watcher: Mutex::new(None),
});
let watcher = tokio::spawn(watch_catalog(upstream, config, renditions));
*broadcaster.watcher.lock().unwrap() = Some(watcher);
Ok(broadcaster)
}
#[cfg(feature = "server")]
pub(crate) fn is_closed(&self) -> bool {
self.broadcast.is_closed()
}
#[cfg(feature = "server")]
pub(crate) async fn closed(&self) {
self.broadcast.closed().await;
}
pub fn rendition(&self, kind: Kind, name: &str) -> Option<Arc<Rendition>> {
self.renditions.get(kind, name)
}
pub fn renditions(&self) -> renditions::Consumer {
self.renditions.subscribe()
}
pub async fn ready(&self) {
self.renditions.ready().await;
}
pub fn master_playlist(&self, query: Option<&str>) -> String {
let mut video = Vec::new();
let mut audio = Vec::new();
for rendition in self.renditions.snapshot() {
match rendition.kind {
Kind::Video => video.push(master::VideoVariant {
name: rendition.name.clone(),
bandwidth: rendition.bandwidth,
width: rendition.width,
height: rendition.height,
codec: rendition.codec.clone(),
}),
Kind::Audio => audio.push(master::AudioVariant {
name: rendition.name.clone(),
bandwidth: rendition.bandwidth,
codec: rendition.codec.clone(),
}),
}
}
master::render_master(&video, &audio, query)
}
#[cfg_attr(not(feature = "server"), allow(dead_code))]
pub(crate) fn is_empty(&self) -> bool {
self.renditions.is_empty()
}
}
impl Drop for Broadcaster {
fn drop(&mut self) {
if let Some(watcher) = self.watcher.lock().unwrap().take() {
watcher.abort();
}
self.renditions.clear();
}
}
async fn watch_catalog(upstream: Upstream, config: Config, renditions: renditions::Producer) {
let broadcast = &upstream.broadcast;
let mut delay = CATALOG_RETRY_MIN;
let mut consumer = loop {
match catalog::Consumer::<()>::new(broadcast, CatalogFormat::Hang).await {
Ok(consumer) => break consumer,
Err(err) => {
tracing::warn!(%err, "failed to subscribe to broadcast catalog, retrying");
let wait = delay.mul_f64(0.5 + rand::rng().random::<f64>() / 2.0);
delay = (delay * 2).min(CATALOG_RETRY_MAX);
tokio::select! {
_ = tokio::time::sleep(wait) => {}
_ = kio::wait(|waiter| broadcast.poll_closed(waiter)) => {
renditions.close();
return;
}
}
}
}
};
loop {
match kio::wait(|waiter| consumer.poll_next(waiter)).await {
Ok(Some(catalog)) => renditions.sync(&upstream, &config, &catalog),
Ok(None) => break,
Err(err) => {
tracing::warn!(%err, "broadcast catalog stream ended with error");
break;
}
}
}
renditions.close();
}
#[cfg(test)]
mod tests {
use super::*;
fn frame(micros: u64, keyframe: bool) -> moq_mux::container::Frame {
payload_frame(micros, keyframe, &[0xDE, 0xAD, 0xBE, 0xEF])
}
fn payload_frame(micros: u64, keyframe: bool, payload: &'static [u8]) -> moq_mux::container::Frame {
moq_mux::container::Frame {
timestamp: moq_net::Timestamp::from_micros(micros).unwrap(),
payload: bytes::Bytes::from_static(payload),
keyframe,
duration: None,
}
}
fn vp8_frame(micros: u64, keyframe: bool) -> moq_mux::container::Frame {
let payload = if keyframe {
&[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00][..]
} else {
&[0x31, 0x00, 0x00][..]
};
moq_mux::container::Frame {
timestamp: moq_net::Timestamp::from_micros(micros).unwrap(),
payload: bytes::Bytes::copy_from_slice(payload),
keyframe,
duration: None,
}
}
fn video_config(catalog: &moq_mux::catalog::Producer) -> hang::catalog::VideoConfig {
let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8);
config.coded_width = Some(320);
config.coded_height = Some(240);
config.framerate = Some(30.0);
config.timeline = Some(catalog.timeline("video0").unwrap().section());
config
}
async fn settle() {
for _ in 0..10 {
tokio::task::yield_now().await;
}
}
#[tokio::test]
async fn escaping_broadcast_reference_is_not_advertised() {
let origin = moq_net::Origin::random().produce();
let _broadcast = origin
.create_broadcast("a/pub", moq_net::broadcast::Route::new().with_announce(true))
.expect("publish allowed");
settle().await;
let source = moq_mux::Source::new(origin.consume(), "a/pub");
let upstream = Upstream {
broadcast: source.broadcast().await.unwrap(),
source,
};
let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8);
config.broadcast = Some(moq_net::PathRelative::new("../../source").to_owned());
config.timeline = Some(hang::catalog::Timeline::new("video.timeline"));
let mut catalog = moq_mux::catalog::hang::Catalog::default();
catalog.video.renditions.insert("video".to_string(), config);
let renditions = renditions::Producer::new();
renditions.sync(&upstream, &Config::default(), &catalog);
assert!(renditions.get(Kind::Video, "video").is_none());
}
#[tokio::test]
async fn serves_playlist_and_segments_from_the_timeline() {
let origin = moq_net::Origin::random().produce();
let mut broadcast = origin
.create_broadcast("live", moq_net::broadcast::Route::new().with_announce(true))
.expect("publish allowed");
settle().await;
let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
let reserved = catalog.reserve();
let mut registration = reserved.video("video0");
let mut config = video_config(&catalog);
config.coded_width = None;
config.coded_height = None;
registration.set(config);
drop(reserved);
let track = broadcast.create_track("video0", None).unwrap();
let mut media = catalog
.media_producer(track, moq_mux::catalog::hang::Container::Legacy)
.unwrap();
media.write(vp8_frame(0, true)).unwrap();
media.write(vp8_frame(1_000_000, false)).unwrap();
media.write(vp8_frame(2_000_000, true)).unwrap();
media.write(vp8_frame(3_000_000, false)).unwrap();
media.write(vp8_frame(4_000_000, true)).unwrap();
let source = moq_mux::Source::new(origin.consume(), "live");
let broadcaster = Broadcaster::new(source, Config::default()).await.unwrap();
let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await;
let rendition = broadcaster
.rendition(Kind::Video, "video0")
.expect("rendition discovered from the catalog");
let _ = tokio::time::timeout(Duration::from_secs(5), rendition.playable()).await;
let master = broadcaster.master_playlist(None);
assert!(master.contains("video/video0/media.m3u8"), "master lists the rendition");
let playlist = rendition.playlist();
assert_eq!(playlist.segments.len(), 2, "the live-edge group is not listed");
assert_eq!(playlist.segments[0].group, 0);
assert_eq!(playlist.segments[0].duration, 2.0);
assert_eq!(playlist.segments[1].group, 1);
assert_eq!(playlist.target_duration, 2, "ceil of the longest timeline gap");
assert!(!playlist.finished);
let rendered = rendition.media_playlist(None).expect("playable");
assert!(rendered.contains("#EXT-X-MAP:URI=\"init.mp4\"\n"));
assert!(rendered.contains("seg/0.m4s\n"));
assert!(rendered.contains("seg/1.m4s\n"));
let signed = rendition.media_playlist(Some("jwt=abc.def")).expect("playable");
assert!(signed.contains("#EXT-X-MAP:URI=\"init.mp4?jwt=abc.def\"\n"));
assert!(signed.contains("seg/0.m4s?jwt=abc.def\n"));
let init = rendition.init().await.unwrap().expect("init segment");
assert_eq!(&init[4..8], b"ftyp");
let segment = rendition.segment(0).await.unwrap().expect("segment fetched on demand");
assert_eq!(&segment[4..8], b"moof", "a fetched group transmuxes to moof+mdat");
assert!(rendition.segment(2).await.unwrap().is_none());
assert!(rendition.segment(99).await.unwrap().is_none());
drop((media, registration, broadcast));
}
#[tokio::test]
async fn dropping_the_broadcaster_keeps_a_cursor_drainable() {
let origin = moq_net::Origin::random().produce();
let mut broadcast = origin
.create_broadcast("live", moq_net::broadcast::Route::new().with_announce(true))
.expect("publish allowed");
settle().await;
let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
let reserved = catalog.reserve();
let mut registration = reserved.video("video0");
let config = video_config(&catalog);
registration.set(config);
drop(reserved);
let track = broadcast.create_track("video0", None).unwrap();
let mut media = catalog
.media_producer(track, moq_mux::catalog::hang::Container::Legacy)
.unwrap();
media.write(frame(0, true)).unwrap();
media.write(frame(2_000_000, true)).unwrap();
let source = moq_mux::Source::new(origin.consume(), "live");
let broadcaster = Broadcaster::new(source, Config::default()).await.unwrap();
let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await;
let rendition = broadcaster
.rendition(Kind::Video, "video0")
.expect("rendition discovered");
let mut segments = rendition.segments();
drop(broadcaster);
media.finish().unwrap();
catalog.finish().unwrap();
let mut groups = Vec::new();
while let Some(segment) = tokio::time::timeout(Duration::from_secs(5), segments.next())
.await
.expect("the cursor drains without parking")
.unwrap()
{
groups.push(segment.group);
}
assert!(
groups.contains(&1),
"the final segment survives dropping the broadcaster, got {groups:?}"
);
drop((catalog, media, registration, broadcast, rendition));
}
#[tokio::test]
async fn a_replacement_publisher_is_not_served_under_the_replaced_catalog() {
const OLD: &[u8] = b"OLDOLDOLDOLDOLDO";
const NEW: &[u8] = b"NEWNEWNEWNEWNEWN";
fn publish(
origin: &moq_net::origin::Producer,
payload: &'static [u8],
) -> (Box<dyn std::any::Any>, hang::catalog::VideoConfig) {
let mut broadcast = origin
.create_broadcast("live", moq_net::broadcast::Route::new().with_announce(true))
.expect("publish allowed");
let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
let reserved = catalog.reserve();
let mut registration = reserved.video("video0");
let config = video_config(&catalog);
registration.set(config.clone());
drop(reserved);
let track = broadcast.create_track("video0", None).unwrap();
let mut media = catalog
.media_producer(track, moq_mux::catalog::hang::Container::Legacy)
.unwrap();
media.write(payload_frame(0, true, payload)).unwrap();
media.write(payload_frame(2_000_000, true, payload)).unwrap();
media.write(payload_frame(4_000_000, true, payload)).unwrap();
(Box::new((broadcast, catalog, registration, media)), config)
}
fn contains(haystack: &bytes::Bytes, needle: &[u8]) -> bool {
haystack.windows(needle.len()).any(|window| window == needle)
}
let origin = moq_net::Origin::random().produce();
let (old, config) = publish(&origin, OLD);
settle().await;
let source = moq_mux::Source::new(origin.consume(), "live");
let upstream = Upstream {
broadcast: source.broadcast().await.unwrap(),
source,
};
let mut catalog = moq_mux::catalog::hang::Catalog::default();
catalog.video.renditions.insert("video0".to_string(), config);
drop(old);
let (new, _) = publish(&origin, NEW);
settle().await;
let renditions = renditions::Producer::new();
renditions.sync(&upstream, &Config::default(), &catalog);
let rendition = renditions.get(Kind::Video, "video0").expect("rendition synced");
let _ = tokio::time::timeout(Duration::from_secs(1), rendition.playable()).await;
let served = rendition.segment(0).await.unwrap();
if let Some(served) = &served {
assert!(
!contains(served, NEW),
"the replacement publisher's media was served under the replaced broadcast's catalog"
);
}
assert!(
served.is_none(),
"the replaced broadcast is closed, so it serves nothing"
);
let source = moq_mux::Source::new(origin.consume(), "live");
let fresh = Upstream {
broadcast: source.broadcast().await.unwrap(),
source,
};
let renditions = renditions::Producer::new();
renditions.sync(&fresh, &Config::default(), &catalog);
let rendition = renditions.get(Kind::Video, "video0").expect("rendition synced");
tokio::time::timeout(Duration::from_secs(5), rendition.playable())
.await
.expect("the new publisher's timeline arrives");
let served = rendition
.segment(0)
.await
.unwrap()
.expect("the new publisher is servable on its own catalog");
assert!(
contains(&served, NEW),
"the new export serves the new publisher's media"
);
drop(new);
}
#[tokio::test]
async fn removing_a_rendition_ends_its_segment_cursor() {
let origin = moq_net::Origin::random().produce();
let broadcast = origin
.create_broadcast("live", moq_net::broadcast::Route::new().with_announce(true))
.expect("publish allowed");
let source = moq_mux::Source::new(origin.consume(), "live");
settle().await;
let upstream = Upstream {
broadcast: source.broadcast().await.unwrap(),
source,
};
let renditions = renditions::Producer::new();
let mut media = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8);
media.timeline = Some(hang::catalog::Timeline::new("video.timeline"));
let mut catalog = moq_mux::catalog::hang::Catalog::default();
catalog.video.renditions.insert("video".to_string(), media);
renditions.sync(&upstream, &Config::default(), &catalog);
let rendition = renditions.get(Kind::Video, "video").expect("rendition synced");
let mut segments = rendition.segments();
renditions.sync(
&upstream,
&Config::default(),
&moq_mux::catalog::hang::Catalog::default(),
);
let ended = tokio::time::timeout(Duration::from_secs(5), segments.next())
.await
.expect("a removed rendition's cursor ends instead of parking")
.unwrap();
assert!(ended.is_none(), "no segments remained to drain");
drop((broadcast, rendition));
}
#[tokio::test]
async fn dropping_the_broadcaster_releases_its_renditions() {
let origin = moq_net::Origin::random().produce();
let mut broadcast = origin
.create_broadcast("live", moq_net::broadcast::Route::new().with_announce(true))
.expect("publish allowed");
settle().await;
let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
let reserved = catalog.reserve();
let mut registration = reserved.video("video0");
let config = video_config(&catalog);
registration.set(config);
drop(reserved);
let track = broadcast.create_track("video0", None).unwrap();
let mut media = catalog
.media_producer(track, moq_mux::catalog::hang::Container::Legacy)
.unwrap();
media.write(frame(0, true)).unwrap();
media.write(frame(2_000_000, true)).unwrap();
let source = moq_mux::Source::new(origin.consume(), "live");
let broadcaster = Broadcaster::new(source, Config::default()).await.unwrap();
let mut renditions = broadcaster.renditions();
let added = tokio::time::timeout(Duration::from_secs(5), renditions.next())
.await
.expect("a rendition is discovered");
let Some(renditions::Event::Added(_rendition)) = added else {
panic!("expected an Added event");
};
drop(broadcaster);
let next = tokio::time::timeout(Duration::from_secs(5), renditions.next())
.await
.expect("the cursor resolves after the broadcaster drops");
assert!(
matches!(next, Some(renditions::Event::Removed { .. })),
"dropping the broadcaster releases its renditions to a live cursor"
);
let ended = tokio::time::timeout(Duration::from_secs(5), renditions.next())
.await
.expect("the cursor terminates after the broadcaster drops");
assert!(ended.is_none(), "the cursor ends once the broadcaster is gone");
drop((catalog, media, registration, broadcast));
}
#[tokio::test]
async fn record_cursors_yield_renditions_and_segments() {
let origin = moq_net::Origin::random().produce();
let mut broadcast = origin
.create_broadcast("live", moq_net::broadcast::Route::new().with_announce(true))
.expect("publish allowed");
settle().await;
let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
let reserved = catalog.reserve();
let mut registration = reserved.video("video0");
let config = video_config(&catalog);
registration.set(config);
drop(reserved);
let track = broadcast.create_track("video0", None).unwrap();
let mut media = catalog
.media_producer(track, moq_mux::catalog::hang::Container::Legacy)
.unwrap();
media.write(frame(0, true)).unwrap();
media.write(frame(2_000_000, true)).unwrap();
media.write(frame(4_000_000, true)).unwrap();
let source = moq_mux::Source::new(origin.consume(), "live");
let broadcaster = Broadcaster::new(source, Config::default()).await.unwrap();
let mut renditions = broadcaster.renditions();
let rendition = match tokio::time::timeout(Duration::from_secs(5), renditions.next())
.await
.expect("a rendition is discovered")
{
Some(renditions::Event::Added(rendition)) => rendition,
other => panic!("expected an Added event, got {:?}", other.is_some()),
};
assert_eq!(rendition.kind, Kind::Video);
assert_eq!(rendition.name, "video0");
let mut segments = rendition.segments();
assert!(segments.init().await.unwrap().is_some(), "init is buildable");
let first = tokio::time::timeout(Duration::from_secs(5), segments.next())
.await
.expect("first segment finalizes")
.unwrap()
.expect("a segment, not end");
assert_eq!(first.group, 0);
assert_eq!(&first.media[4..8], b"moof", "the segment carries its transmuxed media");
assert_eq!(first.duration, 2.0);
assert!(!first.discontinuity, "a clean start is not a discontinuity");
let second = segments.next().await.unwrap().expect("second segment");
assert_eq!(second.group, 1);
assert!(!second.discontinuity, "consecutive segments are continuous");
drop((catalog, media, registration));
broadcast.finish();
let end = tokio::time::timeout(Duration::from_secs(5), segments.next())
.await
.expect("segment cursor resolves after the source is lost")
.unwrap();
assert!(
end.is_none(),
"an abrupt end does not finalize the open live-edge group"
);
let ended = tokio::time::timeout(Duration::from_secs(5), renditions.next())
.await
.expect("renditions cursor resolves");
assert!(ended.is_none(), "renditions cursor ends when the broadcast closes");
}
}