mod master;
mod playlist;
mod rendition;
pub mod store;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::task::Poll;
use std::time::Duration;
use hang::catalog::{AudioConfig, VideoConfig};
use moq_mux::catalog::hang::Catalog;
use moq_mux::catalog::{CatalogFormat, Consumer, Select, Stream};
use moq_mux::container::fmp4::Export;
use moq_mux::select;
pub use playlist::render_media;
pub use rendition::{Kind, Rendition};
use crate::Result;
type RenditionExport = Export<Select<Consumer<()>>>;
#[derive(Clone, Debug)]
pub struct Config {
pub part_target: Duration,
pub window: Duration,
pub latency: Duration,
pub audio_segment_target: Duration,
}
impl Default for Config {
fn default() -> Self {
Self {
part_target: Duration::from_millis(500),
window: Duration::from_secs(16),
latency: Duration::from_secs(10),
audio_segment_target: Duration::from_secs(2),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Generation(u64);
impl Generation {
pub fn get(self) -> u64 {
self.0
}
fn next(self) -> Self {
Self(self.0.checked_add(1).expect("rendition generation overflow"))
}
}
#[derive(Default)]
struct State {
generation: Generation,
renditions: BTreeMap<String, Arc<Rendition>>,
}
impl State {
fn snapshot(&self) -> Snapshot {
Snapshot {
generation: self.generation,
renditions: self.renditions.clone(),
}
}
}
#[derive(Clone)]
pub struct Snapshot {
generation: Generation,
renditions: BTreeMap<String, Arc<Rendition>>,
}
impl Snapshot {
pub fn generation(&self) -> Generation {
self.generation
}
pub fn rendition(&self, name: &str) -> Option<Arc<Rendition>> {
self.renditions.get(name).cloned()
}
pub fn is_empty(&self) -> bool {
self.renditions.is_empty()
}
pub fn renditions(&self) -> Vec<Arc<Rendition>> {
self.renditions.values().cloned().collect()
}
pub fn master_playlist(&self) -> String {
let mut video = Vec::new();
let mut audio = Vec::new();
for rendition in self.renditions.values() {
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)
}
}
#[derive(Clone, PartialEq)]
enum MediaConfig {
Video(VideoConfig),
Audio(AudioConfig),
}
impl MediaConfig {
fn kind(&self) -> Kind {
match self {
Self::Video(_) => Kind::Video,
Self::Audio(_) => Kind::Audio,
}
}
fn rendition(&self, name: String, config: &Config) -> Rendition {
match self {
Self::Video(media) => Rendition::video(name, media, config),
Self::Audio(media) => Rendition::audio(name, media, config),
}
}
}
struct Driver {
config: MediaConfig,
info: Arc<Rendition>,
export: Option<RenditionExport>,
}
impl Driver {
fn done(&self) -> bool {
self.export.is_none()
}
fn finish(&mut self) {
if self.export.take().is_some() {
self.info.store.finish();
}
}
}
pub struct Broadcaster {
broadcast: moq_net::BroadcastConsumer,
config: Config,
catalog: Consumer<()>,
catalog_done: bool,
renditions: BTreeMap<String, Driver>,
state: kio::Producer<State>,
paused: bool,
paused_observed: bool,
}
impl Broadcaster {
pub fn new(broadcast: moq_net::BroadcastConsumer, config: Config) -> Result<Self> {
let catalog = Consumer::<()>::new(&broadcast, CatalogFormat::Hang)?;
Ok(Self {
broadcast,
config,
catalog,
catalog_done: false,
renditions: BTreeMap::new(),
state: kio::Producer::new(State::default()),
paused: false,
paused_observed: false,
})
}
pub fn handle(&self) -> Handle {
Handle {
state: self.state.consume(),
}
}
pub fn set_paused(&mut self, paused: bool) {
self.paused = paused;
}
pub fn is_paused(&self) -> bool {
self.paused
}
pub fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> {
while !self.catalog_done {
match self.catalog.poll_next(waiter) {
Poll::Ready(Ok(Some(catalog))) => self.sync(&catalog),
Poll::Ready(Ok(None)) => self.catalog_done = true,
Poll::Ready(Err(err)) => {
tracing::warn!(%err, "broadcast catalog stream ended");
self.catalog_done = true;
}
Poll::Pending => break,
}
}
if self.paused {
self.paused_observed = true;
if self.broadcast.poll_closed(waiter).is_ready() {
self.finish_all();
}
} else {
if self.paused_observed {
for driver in self.renditions.values() {
driver.info.store.mark_discontinuity();
}
self.paused_observed = false;
}
for driver in self.renditions.values_mut() {
if driver.export.is_none() {
continue;
}
loop {
let outcome = driver.export.as_mut().unwrap().poll_next_fragment(waiter);
match outcome {
Poll::Ready(Ok(Some(fragment))) => driver.info.store.push(fragment),
Poll::Ready(Ok(None)) => {
driver.finish();
break;
}
Poll::Ready(Err(err)) => {
tracing::warn!(name = %driver.info.name, ?driver.info.kind, %err, "hls rendition exporter ended");
driver.finish();
break;
}
Poll::Pending => break,
}
}
}
}
if self.catalog_done && self.renditions.values().all(Driver::done) {
return Poll::Ready(());
}
Poll::Pending
}
pub async fn run(&mut self) {
kio::wait(|waiter| self.poll(waiter)).await
}
fn finish_all(&mut self) {
self.catalog_done = true;
for driver in self.renditions.values_mut() {
driver.finish();
}
}
fn sync(&mut self, catalog: &Catalog) {
let mut desired = BTreeMap::new();
for (name, video) in &catalog.video.renditions {
desired.insert(name.clone(), MediaConfig::Video(video.clone()));
}
for (name, audio) in &catalog.audio.renditions {
desired
.entry(name.clone())
.or_insert_with(|| MediaConfig::Audio(audio.clone()));
}
let stale: Vec<_> = self
.renditions
.iter()
.filter(|(name, driver)| desired.get(*name) != Some(&driver.config))
.map(|(name, _)| name.clone())
.collect();
let mut changed = !stale.is_empty();
for name in stale {
let mut driver = self.renditions.remove(&name).expect("stale rendition exists");
driver.finish();
}
for (name, media) in desired {
if self.renditions.contains_key(&name) {
continue;
}
changed |= self.insert_rendition(name, media);
}
if changed {
let Ok(mut state) = self.state.write() else {
unreachable!("broadcaster owns rendition state");
};
state.generation = state.generation.next();
state.renditions = self
.renditions
.iter()
.map(|(name, driver)| (name.clone(), driver.info.clone()))
.collect();
}
}
fn insert_rendition(&mut self, name: String, media: MediaConfig) -> bool {
let kind = media.kind();
let export = match build_export(&self.broadcast, &name, kind, &self.config) {
Ok(export) => export,
Err(err) => {
tracing::warn!(%name, ?kind, %err, "failed to build rendition exporter; skipping");
return false;
}
};
let info = Arc::new(media.rendition(name.clone(), &self.config));
self.renditions.insert(
name,
Driver {
config: media,
info,
export: Some(export),
},
);
true
}
}
fn build_export(
broadcast: &moq_net::BroadcastConsumer,
name: &str,
kind: Kind,
cfg: &Config,
) -> Result<RenditionExport> {
let consumer = Consumer::<()>::new(broadcast, CatalogFormat::Hang)?;
let selection = match kind {
Kind::Video => select::Broadcast::default().video(select::Video::default().name(name)),
Kind::Audio => select::Broadcast::default().audio(select::Audio::default().name(name)),
};
let filtered = consumer.select(selection);
Ok(Export::new(broadcast.clone(), filtered)
.with_fragment_duration(cfg.part_target)
.with_latency(cfg.latency))
}
#[derive(Clone)]
pub struct Handle {
state: kio::Consumer<State>,
}
impl Handle {
pub fn snapshot(&self) -> Snapshot {
self.state.read().snapshot()
}
pub fn rendition(&self, name: &str) -> Option<Arc<Rendition>> {
self.state.read().renditions.get(name).cloned()
}
pub fn renditions(&self) -> Vec<Arc<Rendition>> {
self.state.read().renditions.values().cloned().collect()
}
pub async fn wait_ready(&self, timeout: Duration) {
let ready = kio::wait(|waiter| {
match self.state.poll(waiter, |state| {
if state.renditions.is_empty() {
Poll::Pending
} else {
Poll::Ready(())
}
}) {
Poll::Ready(_) => Poll::Ready(()),
Poll::Pending => Poll::Pending,
}
});
let _ = tokio::time::timeout(timeout, ready).await;
}
pub fn subscribe(&self) -> Changes {
Changes {
state: self.state.clone(),
observed: None,
}
}
pub fn master_playlist(&self) -> String {
self.snapshot().master_playlist()
}
}
pub struct Changes {
state: kio::Consumer<State>,
observed: Option<Generation>,
}
impl Changes {
pub async fn changed(&mut self) -> Option<Snapshot> {
let observed = self.observed;
let snapshot = kio::wait(|waiter| {
match self.state.poll(waiter, |state| {
if Some(state.generation) == observed {
Poll::Pending
} else {
Poll::Ready(state.snapshot())
}
}) {
Poll::Ready(Ok(snapshot)) => Poll::Ready(Some(snapshot)),
Poll::Ready(Err(_)) => Poll::Ready(None),
Poll::Pending => Poll::Pending,
}
})
.await?;
self.observed = Some(snapshot.generation());
Some(snapshot)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn video(level: u8) -> VideoConfig {
let mut video = VideoConfig::new(hang::catalog::H264 {
profile: 0x42,
constraints: 0xc0,
level,
inline: true,
});
video.coded_width = Some(1280);
video.coded_height = Some(720);
video.bitrate = Some(2_000_000);
video
}
fn test_broadcaster() -> (moq_net::BroadcastProducer, moq_net::TrackProducer, Broadcaster) {
let mut producer = moq_net::Broadcast::new().produce();
let catalog = producer.create_track(moq_net::Track::new("catalog.json")).unwrap();
let broadcaster = Broadcaster::new(producer.consume(), Config::default()).unwrap();
(producer, catalog, broadcaster)
}
#[tokio::test]
async fn removes_absent_rendition() {
use moq_mux::catalog::Producer as CatalogProducer;
let mut producer = moq_net::Broadcast::new().produce();
let mut catalog = CatalogProducer::new(&mut producer).unwrap();
let video_track = producer.create_track(moq_net::Track::new("video")).unwrap();
let mut broadcaster = Broadcaster::new(producer.consume(), Config::default()).unwrap();
let handle = broadcaster.handle();
let mut changes = handle.subscribe();
assert_eq!(changes.changed().await.unwrap().generation().get(), 0);
let driver = tokio::spawn(async move { broadcaster.run().await });
catalog.lock().video.renditions.insert("video".to_string(), video(0x1f));
let first = tokio::time::timeout(Duration::from_secs(5), changes.changed())
.await
.expect("catalog addition should publish a generation")
.unwrap();
assert_eq!(first.generation().get(), 1);
let removed = first.rendition("video").unwrap();
tokio::time::timeout(Duration::from_secs(5), video_track.used())
.await
.expect("exporter should subscribe to the added track")
.unwrap();
catalog.lock().video.renditions.clear();
let empty = tokio::time::timeout(Duration::from_secs(5), changes.changed())
.await
.expect("catalog removal should publish a generation")
.unwrap();
assert_eq!(empty.generation().get(), 2);
assert!(empty.renditions().is_empty());
assert!(removed.store.version().finished);
assert!(handle.rendition("video").is_none());
tokio::time::timeout(Duration::from_secs(5), video_track.unused())
.await
.expect("removing a rendition should release its media subscription")
.unwrap();
driver.abort();
}
#[test]
fn replaces_same_name_when_media_config_changes() {
let (_producer, _catalog_track, mut broadcaster) = test_broadcaster();
let handle = broadcaster.handle();
let mut catalog = Catalog::default();
catalog.video.renditions.insert("video".to_string(), video(0x1f));
broadcaster.sync(&catalog);
let first = handle.rendition("video").unwrap();
catalog.video.renditions.insert("video".to_string(), video(0x28));
broadcaster.sync(&catalog);
let replacement = handle.rendition("video").unwrap();
assert!(!Arc::ptr_eq(&first, &replacement));
assert_ne!(first.codec, replacement.codec);
assert!(first.store.version().finished);
assert_eq!(handle.snapshot().generation().get(), 2);
}
#[test]
fn readds_rendition_with_a_fresh_store() {
let (_producer, _catalog_track, mut broadcaster) = test_broadcaster();
let handle = broadcaster.handle();
let mut catalog = Catalog::default();
catalog.video.renditions.insert("video".to_string(), video(0x1f));
broadcaster.sync(&catalog);
let first = handle.rendition("video").unwrap();
catalog.video.renditions.clear();
broadcaster.sync(&catalog);
catalog.video.renditions.insert("video".to_string(), video(0x1f));
broadcaster.sync(&catalog);
let readded = handle.rendition("video").unwrap();
assert!(!Arc::ptr_eq(&first, &readded));
assert!(first.store.version().finished);
assert!(!readded.store.version().finished);
assert_eq!(handle.snapshot().generation().get(), 3);
}
#[tokio::test]
async fn dropping_broadcaster_releases_subscription() {
let mut producer = moq_net::Broadcast::new().produce();
let catalog = producer
.create_track(moq_net::Track {
name: "catalog.json".to_string(),
priority: 0,
})
.unwrap();
let mut broadcaster = Broadcaster::new(producer.consume(), Config::default()).unwrap();
let driver = tokio::spawn(async move { broadcaster.run().await });
tokio::time::timeout(Duration::from_secs(5), catalog.used())
.await
.expect("export should subscribe to the catalog track")
.unwrap();
driver.abort();
tokio::time::timeout(Duration::from_secs(5), catalog.unused())
.await
.expect("dropping the Broadcaster must release the catalog subscription")
.unwrap();
}
#[tokio::test]
async fn dropping_broadcaster_releases_media_subscription() {
use moq_mux::catalog::Producer as CatalogProducer;
let mut producer = moq_net::Broadcast::new().produce();
let mut catalog = CatalogProducer::new(&mut producer).unwrap();
let video = producer.create_track(moq_net::Track::new("video")).unwrap();
catalog.lock().video.renditions.insert(
"video".to_string(),
hang::catalog::VideoConfig::new(hang::catalog::H264 {
profile: 0x42,
constraints: 0xc0,
level: 0x1f,
inline: true,
}),
);
let mut broadcaster = Broadcaster::new(producer.consume(), Config::default()).unwrap();
let driver = tokio::spawn(async move { broadcaster.run().await });
tokio::time::timeout(Duration::from_secs(5), video.used())
.await
.expect("exporter should subscribe to the video track")
.unwrap();
driver.abort();
tokio::time::timeout(Duration::from_secs(5), video.unused())
.await
.expect("dropping the Broadcaster must release the media subscription")
.unwrap();
}
#[tokio::test]
async fn broadcast_gone_completes() {
let mut producer = moq_net::Broadcast::new().produce();
let catalog = producer
.create_track(moq_net::Track {
name: "catalog.json".to_string(),
priority: 0,
})
.unwrap();
let mut broadcaster = Broadcaster::new(producer.consume(), Config::default()).unwrap();
drop(catalog);
drop(producer);
tokio::time::timeout(Duration::from_secs(5), broadcaster.run())
.await
.expect("broadcaster should complete when the broadcast goes away");
}
}