mod master;
mod playlist;
mod rendition;
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};
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 renditions = renditions::Producer::new();
let broadcaster = Arc::new(Self {
broadcast: broadcast.clone(),
renditions: renditions.clone(),
watcher: Mutex::new(None),
});
let watcher = tokio::spawn(watch_catalog(source, broadcast, 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(
source: moq_mux::Source,
broadcast: moq_net::broadcast::Consumer,
config: Config,
renditions: renditions::Producer,
) {
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(&source, &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 {
moq_mux::container::Frame {
timestamp: moq_net::Timestamp::from_micros(micros).unwrap(),
payload: bytes::Bytes::from_static(&[0xDE, 0xAD, 0xBE, 0xEF]),
keyframe,
duration: None,
}
}
async fn settle() {
for _ in 0..10 {
tokio::task::yield_now().await;
}
}
#[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 = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8);
config.framerate = Some(30.0);
config.timeline = Some(catalog.timeline("video0").unwrap().section());
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(1_000_000, false)).unwrap();
media.write(frame(2_000_000, true)).unwrap();
media.write(frame(3_000_000, false)).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 _ = 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 mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8);
config.framerate = Some(30.0);
config.timeline = Some(catalog.timeline("video0").unwrap().section());
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 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");
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(&source, &Config::default(), &catalog);
let rendition = renditions.get(Kind::Video, "video").expect("rendition synced");
let mut segments = rendition.segments();
renditions.sync(&source, &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 mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8);
config.framerate = Some(30.0);
config.timeline = Some(catalog.timeline("video0").unwrap().section());
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 mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8);
config.framerate = Some(30.0);
config.timeline = Some(catalog.timeline("video0").unwrap().section());
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");
}
}