use std::collections::HashMap;
use std::collections::hash_map::Entry;
use std::io::SeekFrom;
use std::time::Duration;
use bytes::Bytes;
use m3u8_rs::{
AlternativeMedia, AlternativeMediaType, ByteRange, Map, MasterPlaylist, MediaPlaylist, MediaSegment, Resolution,
VariantStream,
};
use moq_mux::catalog::Producer as CatalogProducer;
use moq_mux::container::fmp4::Import as Fmp4;
use moq_mux::select;
use rand::RngExt;
use reqwest::Client;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tracing::{debug, info, warn};
use url::Url;
use crate::{Error, Result, SequenceKind};
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const STEP_RETRY_MIN: Duration = Duration::from_secs(1);
const STEP_RETRY_MAX: Duration = Duration::from_secs(5);
const STEP_RETRY_BUDGET: Duration = Duration::from_secs(10);
const ANCHOR_SEGMENTS: usize = 3;
const MAX_RENDITION_FAILURES: usize = 3;
#[derive(Clone)]
#[non_exhaustive]
pub struct Config {
pub playlist: Url,
pub client: Option<Client>,
}
impl Config {
pub fn new(playlist: Url) -> Self {
Self { playlist, client: None }
}
}
struct StepOutcome {
wrote_segments: usize,
target_duration: Option<u64>,
ok: usize,
failed: Option<Error>,
}
#[derive(Clone, Copy)]
enum OnError {
Fail,
Warn,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct Resource {
url: Url,
range: Option<ResolvedRange>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct ResolvedRange {
start: u64,
length: u64,
}
#[derive(Clone, Debug, Default)]
struct RangeCursor {
previous: Option<(Url, u64)>,
}
impl RangeCursor {
fn resolve(&mut self, url: Url, range: Option<&ByteRange>) -> Result<Resource> {
let Some(range) = range else {
self.previous = None;
return Ok(Resource { url, range: None });
};
let start = match range.offset {
Some(offset) => offset,
None => self
.previous
.as_ref()
.filter(|(previous_url, _)| previous_url == &url)
.map(|(_, end)| *end)
.ok_or_else(|| Error::MissingByteRangeOffset { url: url.clone() })?,
};
let end = start
.checked_add(range.length)
.filter(|_| range.length > 0)
.ok_or_else(|| Error::InvalidByteRange {
url: url.clone(),
start,
length: range.length,
})?;
self.previous = Some((url.clone(), end));
Ok(Resource {
url,
range: Some(ResolvedRange {
start,
length: range.length,
}),
})
}
}
fn resolve_map(url: Url, range: Option<&ByteRange>) -> Result<Resource> {
RangeCursor::default().resolve(url, range)
}
fn same_origin(entry: &Url, url: &Url) -> bool {
entry.scheme() == url.scheme()
&& entry.host() == url.host()
&& entry.port_or_known_default() == url.port_or_known_default()
}
struct Fetcher {
client: Client,
entry: Url,
}
impl Fetcher {
fn new(config: &Config) -> Result<Self> {
let entry = config.playlist.clone();
if !matches!(entry.scheme(), "file" | "http" | "https") {
return Err(Error::ResourceRefused);
}
let client = match &config.client {
Some(client) => client.clone(),
None => {
let origin = entry.clone();
let follow = reqwest::redirect::Policy::default();
let redirect = reqwest::redirect::Policy::custom(move |attempt| {
if same_origin(&origin, attempt.url()) {
follow.redirect(attempt)
} else {
attempt.stop()
}
});
Client::builder()
.user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")))
.timeout(REQUEST_TIMEOUT)
.redirect(redirect)
.build()?
}
};
Ok(Self { client, entry })
}
async fn media_playlist(&self, url: Url) -> Result<MediaPlaylist> {
let body = self.fetch_url(url).await?;
let playlist = m3u8_rs::parse_media_playlist_res(&body).map_err(|e| Error::ParsePlaylist(e.to_string()))?;
Ok(playlist)
}
async fn fetch_url(&self, url: Url) -> Result<Bytes> {
self.fetch(&Resource { url, range: None }).await
}
async fn fetch(&self, resource: &Resource) -> Result<Bytes> {
let url = &resource.url;
if !same_origin(&self.entry, url) {
return Err(Error::ResourceRefused);
}
if url.scheme() == "file" {
let path = url.to_file_path().map_err(|_| Error::InvalidFileUrl)?;
let Some(range) = resource.range else {
return tokio::fs::read(&path).await.map(Bytes::from).map_err(Error::from);
};
let mut file = tokio::fs::File::open(&path).await.map_err(Error::from)?;
file.seek(SeekFrom::Start(range.start)).await.map_err(Error::from)?;
let mut bytes = Vec::new();
file.take(range.length)
.read_to_end(&mut bytes)
.await
.map_err(Error::from)?;
self.validate_range_length(resource, Bytes::from(bytes))
} else {
let response = self.request(resource).send().await.map_err(Error::from)?;
if response.status().is_redirection() || !same_origin(&self.entry, response.url()) {
return Err(Error::ResourceRefused);
}
let response = response.error_for_status().map_err(Error::from)?;
let partial = response.status() == reqwest::StatusCode::PARTIAL_CONTENT;
if partial {
self.validate_content_range(resource, response.headers())?;
}
let bytes = response.bytes().await.map_err(Error::from)?;
if resource.range.is_some() && !partial {
self.slice_full_response(resource, bytes)
} else {
self.validate_range_length(resource, bytes)
}
}
}
fn request(&self, resource: &Resource) -> reqwest::RequestBuilder {
let mut request = self.client.get(resource.url.clone());
if let Some(range) = resource.range {
let end = range.start + range.length - 1;
request = request.header(reqwest::header::RANGE, format!("bytes={}-{}", range.start, end));
}
request
}
fn slice_full_response(&self, resource: &Resource, bytes: Bytes) -> Result<Bytes> {
let Some(range) = resource.range else {
return Ok(bytes);
};
let start = usize::try_from(range.start).ok();
let end = range
.start
.checked_add(range.length)
.and_then(|end| usize::try_from(end).ok());
match start
.zip(end)
.and_then(|(start, end)| bytes.get(start..end).map(|_| (start, end)))
{
Some((start, end)) => Ok(bytes.slice(start..end)),
None => Err(Error::ByteRangeLengthMismatch {
url: resource.url.clone(),
expected: range.length,
actual: bytes.len().saturating_sub(start.unwrap_or(bytes.len())),
}),
}
}
fn validate_range_length(&self, resource: &Resource, bytes: Bytes) -> Result<Bytes> {
let Some(range) = resource.range else {
return Ok(bytes);
};
if u64::try_from(bytes.len()).ok() != Some(range.length) {
return Err(Error::ByteRangeLengthMismatch {
url: resource.url.clone(),
expected: range.length,
actual: bytes.len(),
});
}
Ok(bytes)
}
fn validate_content_range(&self, resource: &Resource, headers: &reqwest::header::HeaderMap) -> Result<()> {
let Some(range) = resource.range else {
return Ok(());
};
let end = range.start + range.length - 1;
let actual = headers
.get(reqwest::header::CONTENT_RANGE)
.and_then(|value| value.to_str().ok())
.unwrap_or("<missing>");
let bounds = actual
.split_once(' ')
.filter(|(unit, _)| unit.eq_ignore_ascii_case("bytes"))
.and_then(|(_, value)| value.split_once('/'))
.and_then(|(bounds, _)| bounds.split_once('-'))
.and_then(|(start, end)| Some((start.parse().ok()?, end.parse().ok()?)));
if bounds != Some((range.start, end)) {
return Err(Error::ByteRangeResponseMismatch {
url: resource.url.clone(),
start: range.start,
end,
});
}
Ok(())
}
}
#[derive(Clone)]
struct Sink {
broadcast: moq_net::broadcast::Producer,
catalog: CatalogProducer,
}
impl Sink {
fn importer(&self, select: &select::Broadcast) -> Fmp4 {
Fmp4::new(self.broadcast.clone(), self.catalog.reserve()).with_select(select.clone())
}
}
struct TrackState {
label: String,
playlist: Url,
select: select::Broadcast,
sink: Sink,
importer: Option<Fmp4>,
next_sequence: Option<u64>,
next_discontinuity: Option<u64>,
map: Option<Resource>,
media_range: RangeCursor,
failures: usize,
}
impl TrackState {
fn new(label: String, playlist: Url, select: select::Broadcast, sink: Sink) -> Self {
Self {
label,
playlist,
select,
sink,
importer: None,
next_sequence: None,
next_discontinuity: None,
map: None,
media_range: RangeCursor::default(),
failures: 0,
}
}
fn evict(&mut self) {
self.importer = None;
self.map = None;
self.reanchor();
}
async fn ingest(&mut self, fetcher: &Fetcher, target_duration: &mut Option<u64>) -> Result<usize> {
let playlist = fetcher.media_playlist(self.playlist.clone()).await?;
if target_duration.is_none() {
*target_duration = Some(playlist.target_duration);
}
self.consume_segments(fetcher, &playlist).await
}
fn anchor(&mut self, playlist_seq: u64, last_playlist_seq: u64, total_segments: usize) -> usize {
let live_edge = total_segments.saturating_sub(ANCHOR_SEGMENTS);
let Some(next) = self.next_sequence else {
return live_edge;
};
if next > last_playlist_seq {
warn!(
label = %self.label,
next_sequence = next,
playlist_sequence = playlist_seq,
last_playlist_sequence = last_playlist_seq,
"imported ahead of playlist (upstream sequence reset?), re-anchoring near the live edge"
);
self.reanchor();
return live_edge;
}
if next < playlist_seq {
warn!(
label = %self.label,
next_sequence = next,
playlist_sequence = playlist_seq,
"fell behind the playlist window, resuming from its oldest segment"
);
self.reanchor();
return 0;
}
(next - playlist_seq) as usize
}
fn reanchor(&mut self) {
self.next_sequence = None;
}
async fn consume_segments(&mut self, fetcher: &Fetcher, playlist: &MediaPlaylist) -> Result<usize> {
let playlist_seq = playlist.media_sequence;
let total_segments = playlist.segments.len();
let last_playlist_seq = playlist_seq + total_segments as u64;
let skip = self.anchor(playlist_seq, last_playlist_seq, total_segments);
let to_process = total_segments.saturating_sub(skip);
debug!(
label = %self.label,
playlist_sequence = playlist_seq,
next_sequence = ?self.next_sequence,
skip,
total_segments,
to_process,
"consuming HLS segments"
);
if to_process == 0 {
return Ok(0);
}
let mut discontinuity_seq = playlist.discontinuity_sequence;
let mut map = None;
self.media_range = RangeCursor::default();
for segment in &playlist.segments[..skip] {
if segment.discontinuity {
discontinuity_seq = bump_discontinuity(discontinuity_seq)?;
}
if segment.map.is_some() {
map = segment.map.as_ref();
}
if !segment.uri.is_empty() {
let url = resolve_uri(&self.playlist, &segment.uri)?;
self.media_range.resolve(url, segment.byte_range.as_ref())?;
}
}
let base_seq = playlist_seq + skip as u64;
for (i, segment) in playlist.segments[skip..].iter().enumerate() {
let sequence = base_seq + i as u64;
if segment.discontinuity {
discontinuity_seq = bump_discontinuity(discontinuity_seq)?;
}
if segment.map.is_some() {
map = segment.map.as_ref();
}
if let Some(map) = map {
self.ensure_map(fetcher, map).await?;
}
self.push_segment(fetcher, segment, sequence, discontinuity_seq).await?;
}
info!(label = %self.label, consumed = to_process, "consumed HLS segments");
Ok(to_process)
}
async fn ensure_map(&mut self, fetcher: &Fetcher, map: &Map) -> Result<()> {
let url = resolve_uri(&self.playlist, &map.uri)?;
let resource = resolve_map(url, map.byte_range.as_ref())?;
if self.map.as_ref() == Some(&resource) {
return Ok(());
}
let bytes = fetcher.fetch(&resource).await?;
let mut importer = self.sink.importer(&self.select);
importer.decode(&bytes)?;
self.importer = Some(importer);
self.map = Some(resource);
self.next_discontinuity = None;
info!(label = %self.label, "loaded HLS init segment generation");
Ok(())
}
async fn push_segment(
&mut self,
fetcher: &Fetcher,
segment: &MediaSegment,
sequence: u64,
discontinuity_sequence: u64,
) -> Result<()> {
if segment.uri.is_empty() {
return Err(Error::EmptySegmentUri);
}
let url = resolve_uri(&self.playlist, &segment.uri)?;
let mut range = self.media_range.clone();
let resource = range.resolve(url, segment.byte_range.as_ref())?;
let bytes = fetcher.fetch(&resource).await?;
let group_sequence = moq_sequence(discontinuity_sequence, sequence)?;
let reanchored =
self.next_sequence != Some(sequence) || self.next_discontinuity != Some(discontinuity_sequence);
let importer = self.importer.as_mut().ok_or(Error::MissingMap)?;
if reanchored {
importer.seek(group_sequence)?;
}
importer.cut();
importer.decode(&bytes)?;
self.media_range = range;
self.next_sequence = Some(sequence + 1);
self.next_discontinuity = Some(discontinuity_sequence);
Ok(())
}
}
pub struct Import {
sink: Sink,
fetcher: Fetcher,
base_url: Url,
video: Vec<TrackState>,
audio: Option<TrackState>,
}
impl Import {
pub fn new(broadcast: moq_net::broadcast::Producer, catalog: CatalogProducer, cfg: Config) -> Result<Self> {
let fetcher = Fetcher::new(&cfg)?;
let base_url = cfg.playlist;
Ok(Self {
sink: Sink { broadcast, catalog },
fetcher,
base_url,
video: Vec::new(),
audio: None,
})
}
pub async fn init(&mut self) -> Result<()> {
let wrote = self.step(OnError::Fail).await?.wrote_segments;
if wrote == 0 {
warn!("HLS playlist had no new segments during init step");
} else {
info!(count = wrote, "buffered initial HLS segments");
}
Ok(())
}
pub async fn run(&mut self) -> Result<()> {
let mut delay = STEP_RETRY_MIN;
let mut deadline = tokio::time::Instant::now() + STEP_RETRY_BUDGET;
loop {
let stepped = match self.step(OnError::Warn).await {
Ok(StepOutcome {
ok: 0,
failed: Some(err),
..
}) => Err(err),
stepped => stepped,
};
let outcome = match stepped {
Ok(outcome) => {
delay = STEP_RETRY_MIN;
deadline = tokio::time::Instant::now() + STEP_RETRY_BUDGET;
outcome
}
Err(err @ Error::ResourceRefused) => return Err(err),
Err(err) if err.status().is_some_and(|status| !crate::status_retryable(status)) => {
return Err(err);
}
Err(err) => {
let now = tokio::time::Instant::now();
if now >= deadline {
return Err(err);
}
warn!(%err, "HLS import step failed, retrying");
let wait = delay
.mul_f64(0.5 + rand::rng().random::<f64>() / 2.0)
.min(deadline - now);
delay = (delay * 2).min(STEP_RETRY_MAX);
tokio::time::sleep(wait).await;
continue;
}
};
let delay = refresh_delay(outcome.target_duration, outcome.wrote_segments);
debug!(
wrote_segments = outcome.wrote_segments,
target_duration = ?outcome.target_duration,
delay_secs = delay.as_secs_f32(),
"HLS import step complete"
);
tokio::time::sleep(delay).await;
}
}
pub fn abort(mut self, err: moq_net::Error) {
for track in &mut self.video {
if let Some(importer) = track.importer.take() {
importer.abort(err.clone());
}
}
if let Some(track) = &mut self.audio
&& let Some(importer) = track.importer.take()
{
importer.abort(err.clone());
}
}
async fn step(&mut self, on_error: OnError) -> Result<StepOutcome> {
self.ensure_tracks().await?;
let _reserved = self.sink.catalog.timeline().reserve();
let mut wrote_segments = 0;
let mut target_duration = None;
let mut failed = None;
let mut ok = 0;
for track in self.video.iter_mut().chain(self.audio.iter_mut()) {
match track.ingest(&self.fetcher, &mut target_duration).await {
Ok(count) => {
track.failures = 0;
wrote_segments += count;
ok += 1;
}
Err(err @ Error::ResourceRefused) => return Err(err),
Err(err) => match on_error {
OnError::Fail => return Err(err),
OnError::Warn => {
track.failures += 1;
if track.failures >= MAX_RENDITION_FAILURES && track.importer.is_some() {
warn!(label = %track.label, %err, failures = track.failures, "rendition import keeps failing, dropping it until it recovers");
track.evict();
} else {
warn!(label = %track.label, %err, "rendition import step failed, will retry");
}
let recoverable = err.status().is_none_or(crate::status_retryable);
if recoverable || failed.is_none() {
failed = Some(err);
}
}
},
}
}
Ok(StepOutcome {
wrote_segments,
target_duration,
failed,
ok,
})
}
fn track(&self, label: impl Into<String>, playlist: Url, select: select::Broadcast) -> TrackState {
TrackState::new(label.into(), playlist, select, self.sink.clone())
}
async fn ensure_tracks(&mut self) -> Result<()> {
if !self.video.is_empty() {
return Ok(());
}
let body = self.fetcher.fetch_url(self.base_url.clone()).await?;
if !m3u8_rs::is_master_playlist(&body) {
let track = self.track("video[0]", self.base_url.clone(), select_muxed());
self.video.push(track);
return Ok(());
}
let master = m3u8_rs::parse_master_playlist_res(&body).map_err(|e| Error::ParsePlaylist(e.to_string()))?;
let variants = select_variants(&master);
if variants.is_empty() {
return Err(Error::NoVariants);
}
if let Some(group_id) = variants.iter().find_map(|v| v.audio.as_deref()) {
if let Some(audio_tag) = select_audio(&master, group_id) {
if let Some(uri) = &audio_tag.uri {
let audio_url = resolve_uri(&self.base_url, uri)?;
self.audio = Some(self.track("audio", audio_url, select_audio_only()));
} else {
warn!(%group_id, "audio rendition missing URI");
}
} else {
warn!(%group_id, "audio group not found in master playlist");
}
}
let variant_select = if self.audio.is_some() {
select_video_only()
} else {
select_muxed()
};
for (index, variant) in variants.iter().enumerate() {
let video_url = resolve_uri(&self.base_url, &variant.uri)?;
let track = self.track(format!("video[{index}]"), video_url, variant_select.clone());
self.video.push(track);
}
let audio_url = self.audio.as_ref().map(|a| a.playlist.to_string());
info!(
video_variants = variants.len(),
audio = audio_url.as_deref().unwrap_or("none"),
"selected master playlist renditions"
);
Ok(())
}
}
fn refresh_delay(target_duration: Option<u64>, wrote_segments: usize) -> Duration {
let base = target_duration
.map(|dur| Duration::from_secs(dur.max(1)))
.unwrap_or_else(|| Duration::from_millis(500));
if wrote_segments == 0 {
return base / 2;
}
base
}
fn select_muxed() -> select::Broadcast {
select::Broadcast::default()
.video(select::Video::default())
.audio(select::Audio::default())
}
fn select_video_only() -> select::Broadcast {
select::Broadcast::default().video(select::Video::default())
}
fn select_audio_only() -> select::Broadcast {
select::Broadcast::default().audio(select::Audio::default())
}
fn select_audio<'a>(master: &'a MasterPlaylist, group_id: &str) -> Option<&'a AlternativeMedia> {
let mut first = None;
let mut default = None;
for alternative in master
.alternatives
.iter()
.filter(|alt| alt.media_type == AlternativeMediaType::Audio && alt.group_id == group_id)
{
if first.is_none() {
first = Some(alternative);
}
if alternative.default {
default = Some(alternative);
break;
}
}
default.or(first)
}
fn select_variants(master: &MasterPlaylist) -> Vec<&VariantStream> {
fn codec_family(codec: &str) -> Option<&'static str> {
if codec.starts_with("avc1.") || codec.starts_with("avc3.") {
Some("h264")
} else {
None
}
}
fn first_video_codec(variant: &VariantStream) -> Option<&str> {
let codecs = variant.codecs.as_deref()?;
codecs
.split(',')
.map(|s| s.trim())
.find(|codec| codec_family(codec).is_some())
}
fn bandwidth(variant: &VariantStream) -> u64 {
variant.average_bandwidth.unwrap_or(variant.bandwidth)
}
let candidates: Vec<(&VariantStream, &str, &str)> = master
.variants
.iter()
.filter(|variant| !variant.is_i_frame && !variant.uri.is_empty())
.filter_map(|variant| {
let codec = first_video_codec(variant)?;
let family = codec_family(codec)?;
Some((variant, codec, family))
})
.collect();
if candidates.is_empty() {
return Vec::new();
}
const FAMILY_PREFERENCE: &[&str] = &["h264"];
let families_present: Vec<&str> = candidates.iter().map(|(_, _, fam)| *fam).collect();
let target_family = FAMILY_PREFERENCE
.iter()
.find(|fav| families_present.iter().any(|fam| fam == *fav))
.copied()
.unwrap_or(families_present[0]);
let family_variants: Vec<&VariantStream> = candidates
.into_iter()
.filter(|(_, _, fam)| *fam == target_family)
.map(|(variant, _, _)| variant)
.collect();
let mut by_resolution: HashMap<Option<Resolution>, &VariantStream> = HashMap::new();
for variant in family_variants {
match by_resolution.entry(variant.resolution) {
Entry::Vacant(entry) => {
entry.insert(variant);
}
Entry::Occupied(mut entry) => {
if bandwidth(variant) > bandwidth(entry.get()) {
entry.insert(variant);
}
}
}
}
let mut selected: Vec<&VariantStream> = by_resolution.into_values().collect();
selected.sort_by_key(|variant| {
let pixels = variant.resolution.map(|r| r.width * r.height).unwrap_or(0);
std::cmp::Reverse((pixels, bandwidth(variant)))
});
selected
}
fn resolve_uri(base: &Url, value: &str) -> std::result::Result<Url, url::ParseError> {
if let Ok(url) = Url::parse(value) {
return Ok(url);
}
base.join(value)
}
fn bump_discontinuity(sequence: u64) -> Result<u64> {
sequence.checked_add(1).ok_or(Error::SequenceOverflow {
kind: SequenceKind::Discontinuity,
value: sequence,
})
}
fn moq_sequence(discontinuity_sequence: u64, media_sequence: u64) -> Result<u64> {
const MEDIA_BITS: u32 = 48;
const MEDIA_MASK: u64 = (1u64 << MEDIA_BITS) - 1;
const DISCONTINUITY_MASK: u64 = u64::MAX >> MEDIA_BITS;
if media_sequence > MEDIA_MASK {
return Err(Error::SequenceOverflow {
kind: SequenceKind::Media,
value: media_sequence,
});
}
if discontinuity_sequence > DISCONTINUITY_MASK {
return Err(Error::SequenceOverflow {
kind: SequenceKind::Discontinuity,
value: discontinuity_sequence,
});
}
Ok((discontinuity_sequence << MEDIA_BITS) | media_sequence)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::AsyncWriteExt as _;
use tokio::net::TcpListener;
static COUNTER: AtomicUsize = AtomicUsize::new(0);
fn temp_dir() -> PathBuf {
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("moq-hls-test-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn write_import(dir: &Path, resource: &[u8], playlist: &str) -> (Import, CatalogProducer) {
std::fs::write(dir.join("media.mp4"), resource).unwrap();
let playlist_path = dir.join("media.m3u8");
std::fs::write(&playlist_path, playlist).unwrap();
let mut broadcast = moq_net::broadcast::Info::new().produce();
let catalog = CatalogProducer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
let cfg = Config::new(Url::from_file_path(&playlist_path).unwrap());
let import = Import::new(broadcast, catalog.clone(), cfg).unwrap();
(import, catalog)
}
fn fmp4_parts(count: usize) -> (Vec<u8>, Vec<Vec<u8>>) {
let data = include_bytes!("../../moq-mux/src/container/fmp4/test_data/bbb.mp4");
let mut moofs = Vec::new();
let mut position = 0usize;
while position + 8 <= data.len() {
let size = u32::from_be_bytes(data[position..position + 4].try_into().unwrap()) as usize;
if size < 8 || position + size > data.len() {
break;
}
if &data[position + 4..position + 8] == b"moof" {
moofs.push(position);
}
position += size;
}
assert!(moofs.len() > count, "fixture has too few fragments for {count}");
let init = data[..moofs[0]].to_vec();
let fragments = moofs
.windows(2)
.take(count)
.map(|window| data[window[0]..window[1]].to_vec())
.collect();
(init, fragments)
}
fn http_response(status: &str, headers: &[(&str, &str)], body: &[u8]) -> Vec<u8> {
let mut response = format!(
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n",
body.len()
);
for (name, value) in headers {
response.push_str(&format!("{name}: {value}\r\n"));
}
response.push_str("\r\n");
let mut response = response.into_bytes();
response.extend_from_slice(body);
response
}
fn serve(listener: TcpListener, responses: Vec<Vec<u8>>) -> tokio::task::JoinHandle<Vec<Vec<u8>>> {
tokio::spawn(async move {
let mut requests = Vec::new();
for response in responses {
let (mut stream, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
loop {
let mut chunk = [0; 1024];
let read = stream.read(&mut chunk).await.unwrap();
if read == 0 {
break;
}
request.extend_from_slice(&chunk[..read]);
if request.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
stream.write_all(&response).await.unwrap();
requests.push(request);
}
requests
})
}
async fn serve_response(
status: &str,
headers: &[(&str, &str)],
body: &[u8],
) -> (Url, tokio::task::JoinHandle<Vec<Vec<u8>>>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = serve(listener, vec![http_response(status, headers, body)]);
(Url::parse(&format!("http://{address}/media.mp4")).unwrap(), server)
}
fn sink() -> Sink {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let catalog = CatalogProducer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
Sink { broadcast, catalog }
}
fn track_state() -> TrackState {
TrackState::new(
"video[0]".to_string(),
Url::parse("https://example.com/media.m3u8").unwrap(),
select_muxed(),
sink(),
)
}
fn fetcher(entry: &Url) -> Fetcher {
Fetcher::new(&Config::new(entry.clone())).unwrap()
}
#[test]
fn same_origin_references_are_admitted() {
let entry = Url::parse("https://example.com/live/master.m3u8").unwrap();
for reference in [
"video.m3u8",
"../a.mp4",
"/a.mp4",
"//example.com/a.mp4",
"https://example.com:443/a.mp4",
"HTTPS://EXAMPLE.COM/a.mp4",
] {
let url = resolve_uri(&entry, reference).unwrap();
assert!(same_origin(&entry, &url), "{reference}");
}
}
#[tokio::test]
async fn cross_origin_references_are_refused() {
let dir = temp_dir();
let path = dir.join("resource");
std::fs::write(&path, b"sentinel").unwrap();
let local = Url::from_file_path(dir.join("media.m3u8")).unwrap();
let remote = Url::parse("https://example.com/live/media.m3u8").unwrap();
let file = Url::from_file_path(&path).unwrap();
let mut downgraded = remote.clone();
downgraded.set_scheme("http").unwrap();
for (entry, reference) in [
(&remote, "https://other.example/live/a.mp4"),
(&remote, "//other.example/live/a.mp4"),
(&remote, downgraded.as_str()),
(&remote, "https://example.com:8443/live/a.mp4"),
(&remote, file.as_str()),
(&local, "https://example.com/live/a.mp4"),
(&local, "//example.com/live/a.mp4"),
] {
let url = resolve_uri(entry, reference).unwrap();
let fetcher = fetcher(entry);
assert!(
matches!(fetcher.fetch_url(url.clone()).await, Err(Error::ResourceRefused)),
"{reference} from {entry}"
);
assert!(matches!(
fetcher.fetch(&ranged_resource(url)).await,
Err(Error::ResourceRefused)
));
}
}
#[tokio::test]
async fn redirect_hops_stay_on_the_playlist_origin() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let entry = Url::parse(&format!("http://{}/media.m3u8", listener.local_addr().unwrap())).unwrap();
let server = serve(
listener,
vec![
http_response("302 Found", &[("Location", "/moved.m3u8")], b""),
http_response("200 OK", &[], b"moved"),
],
);
assert_eq!(fetcher(&entry).fetch_url(entry.clone()).await.unwrap(), b"moved"[..]);
server.await.unwrap();
let other = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
other.set_nonblocking(true).unwrap();
let location = format!("http://{}/media.m3u8", other.local_addr().unwrap());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let entry = Url::parse(&format!("http://{}/media.m3u8", listener.local_addr().unwrap())).unwrap();
let server = serve(
listener,
vec![
http_response("302 Found", &[("Location", "/hop.m3u8")], b""),
http_response("302 Found", &[("Location", &location)], b""),
],
);
assert!(matches!(
fetcher(&entry).fetch_url(entry.clone()).await,
Err(Error::ResourceRefused)
));
server.await.unwrap();
assert_eq!(other.accept().unwrap_err().kind(), std::io::ErrorKind::WouldBlock);
}
#[tokio::test]
async fn caller_client_stays_on_the_playlist_origin() {
let other = TcpListener::bind("127.0.0.1:0").await.unwrap();
let location = format!("http://{}/media.m3u8", other.local_addr().unwrap());
let other = serve(other, vec![http_response("200 OK", &[], b"other")]);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let entry = Url::parse(&format!("http://{}/media.m3u8", listener.local_addr().unwrap())).unwrap();
let server = serve(
listener,
vec![http_response("302 Found", &[("Location", &location)], b"")],
);
let mut config = Config::new(entry.clone());
config.client = Some(Client::new());
let fetcher = Fetcher::new(&config).unwrap();
assert!(matches!(
fetcher.fetch_url(entry.clone()).await,
Err(Error::ResourceRefused)
));
server.await.unwrap();
other.await.unwrap();
let reference = resolve_uri(&entry, &location).unwrap();
assert!(matches!(
fetcher.fetch_url(reference).await,
Err(Error::ResourceRefused)
));
}
#[tokio::test]
async fn admission_failure_ends_import_without_retrying() {
tokio::time::pause();
let sink = sink();
let mut import = Import::new(
sink.broadcast.clone(),
sink.catalog.clone(),
Config::new(Url::parse("https://example.com/master.m3u8").unwrap()),
)
.unwrap();
import.video.push(TrackState::new(
"video[0]".to_string(),
Url::parse("file:///refused.m3u8").unwrap(),
select_muxed(),
sink,
));
assert!(matches!(
futures::FutureExt::now_or_never(import.run()),
Some(Err(Error::ResourceRefused))
));
}
#[tokio::test]
async fn local_import_allows_local_resources() {
let dir = temp_dir();
let path = dir.join("resource");
std::fs::write(&path, b"sentinel").unwrap();
let url = Url::from_file_path(&path).unwrap();
let fetcher = Fetcher::new(&Config::new(url.clone())).unwrap();
assert_eq!(fetcher.fetch_url(url.clone()).await.unwrap(), b"sentinel"[..]);
assert_eq!(fetcher.fetch(&ranged_resource(url)).await.unwrap(), b"nti"[..]);
}
#[tokio::test]
async fn unsupported_resources_are_refused() {
let entry = Url::parse("https://example.com/master.m3u8").unwrap();
for scheme in ["ftp", "data", "custom"] {
let url = Url::parse(&format!("{scheme}://example.com/resource?token=secret")).unwrap();
assert!(matches!(
Fetcher::new(&Config::new(url.clone())),
Err(Error::ResourceRefused)
));
let error = fetcher(&entry).fetch_url(url).await.unwrap_err();
assert!(matches!(error, Error::ResourceRefused));
assert!(!format!("{error} {error:?}").contains("secret"));
}
}
fn ranged_resource(url: Url) -> Resource {
Resource {
url,
range: Some(ResolvedRange { start: 2, length: 3 }),
}
}
#[test]
fn hls_config_new_sets_fields() {
let url = Url::parse("https://example.com/stream.m3u8").unwrap();
let cfg = Config::new(url.clone());
assert_eq!(cfg.playlist, url);
}
#[test]
fn select_variants_handles_audio_first_codecs() {
let master = b"#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS=\"mp4a.40.2,avc1.4d401f\"\nvideo.m3u8\n";
let (_, master) = m3u8_rs::parse_master_playlist(master).unwrap();
let variants = select_variants(&master);
assert_eq!(variants.len(), 1);
}
#[test]
fn select_variants_prefers_highest_bandwidth_per_resolution() {
let master = b"#EXTM3U\n\
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=1280x720,CODECS=\"avc1.4d401f\"\nlow.m3u8\n\
#EXT-X-STREAM-INF:BANDWIDTH=3000000,RESOLUTION=1280x720,CODECS=\"avc1.4d401f\"\nhigh.m3u8\n";
let (_, master) = m3u8_rs::parse_master_playlist(master).unwrap();
let variants = select_variants(&master);
assert_eq!(variants.len(), 1);
assert_eq!(variants[0].uri, "high.m3u8");
}
#[test]
fn select_variants_orders_by_descending_quality() {
let master = b"#EXTM3U\n\
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=640x360,CODECS=\"avc1.4d401f\"\nsmall.m3u8\n\
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=1920x1080,CODECS=\"avc1.4d401f\"\nlarge.m3u8\n\
#EXT-X-STREAM-INF:BANDWIDTH=3000000,RESOLUTION=1280x720,CODECS=\"avc1.4d401f\"\nmedium.m3u8\n";
let (_, master) = m3u8_rs::parse_master_playlist(master).unwrap();
let uris: Vec<&str> = select_variants(&master).iter().map(|v| v.uri.as_str()).collect();
assert_eq!(uris, ["large.m3u8", "medium.m3u8", "small.m3u8"]);
}
#[test]
fn hls_import_starts_without_tracks() {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let catalog = CatalogProducer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
let url = Url::parse("https://example.com/master.m3u8").unwrap();
let cfg = Config::new(url);
let hls = Import::new(broadcast, catalog, cfg).unwrap();
assert!(hls.video.is_empty());
assert!(hls.audio.is_none());
}
#[test]
fn first_anchor_joins_near_the_live_edge() {
let mut track = track_state();
assert_eq!(track.anchor(100, 110, 10), 10 - ANCHOR_SEGMENTS);
}
#[test]
fn first_anchor_takes_a_short_window_whole() {
let mut track = track_state();
assert_eq!(track.anchor(0, 2, 2), 0);
}
#[test]
fn contiguous_anchor_resumes_where_it_left_off() {
let mut track = track_state();
track.next_sequence = Some(105);
assert_eq!(track.anchor(100, 110, 10), 5);
assert_eq!(track.next_sequence, Some(105));
}
#[test]
fn anchor_ahead_of_playlist_rejoins_near_the_live_edge() {
let mut track = track_state();
track.next_sequence = Some(500);
assert_eq!(track.anchor(100, 110, 10), 10 - ANCHOR_SEGMENTS);
assert!(track.next_sequence.is_none());
}
#[test]
fn anchor_behind_playlist_resumes_from_the_oldest_segment() {
let mut track = track_state();
track.next_sequence = Some(5);
assert_eq!(track.anchor(100, 110, 10), 0);
assert!(track.next_sequence.is_none());
}
#[tokio::test]
async fn variantless_master_is_not_treated_as_media() {
let dir = temp_dir();
let path = dir.join("master.m3u8");
std::fs::write(
&path,
"#EXTM3U\n#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"en\",URI=\"audio.m3u8\"\n",
)
.unwrap();
let mut broadcast = moq_net::broadcast::Info::new().produce();
let catalog = CatalogProducer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
let cfg = Config::new(Url::from_file_path(&path).unwrap());
let mut import = Import::new(broadcast, catalog, cfg).unwrap();
assert!(matches!(import.ensure_tracks().await, Err(Error::NoVariants)));
}
#[test]
fn byte_ranges_advance_implicit_offsets_for_the_same_resource() {
let url = Url::parse("https://example.com/media.mp4").unwrap();
let mut cursor = RangeCursor::default();
let first = cursor
.resolve(
url.clone(),
Some(&ByteRange {
length: 20,
offset: Some(10),
}),
)
.unwrap();
let second = cursor
.resolve(
url,
Some(&ByteRange {
length: 5,
offset: None,
}),
)
.unwrap();
assert_eq!(first.range.unwrap().start, 10);
assert_eq!(second.range.unwrap().start, 30);
}
#[test]
fn implicit_byte_range_rejects_a_different_resource() {
let mut cursor = RangeCursor::default();
cursor
.resolve(
Url::parse("https://example.com/a.mp4").unwrap(),
Some(&ByteRange {
length: 20,
offset: Some(10),
}),
)
.unwrap();
let err = cursor
.resolve(
Url::parse("https://example.com/b.mp4").unwrap(),
Some(&ByteRange {
length: 5,
offset: None,
}),
)
.unwrap_err();
assert!(matches!(err, Error::MissingByteRangeOffset { .. }));
}
#[test]
fn map_byte_range_requires_an_explicit_offset() {
let err = resolve_map(
Url::parse("https://example.com/init.mp4").unwrap(),
Some(&ByteRange {
length: 20,
offset: None,
}),
)
.unwrap_err();
assert!(matches!(err, Error::MissingByteRangeOffset { .. }));
}
#[test]
fn ranged_resource_builds_http_range_request() {
let url = Url::parse("https://example.com/media.mp4").unwrap();
let request = fetcher(&url)
.request(&Resource {
url: url.clone(),
range: Some(ResolvedRange { start: 2, length: 3 }),
})
.build()
.unwrap();
assert_eq!(request.headers()[reqwest::header::RANGE], "bytes=2-4");
}
#[tokio::test]
async fn ranged_http_resource_accepts_matching_partial_response() {
let (url, server) = serve_response("206 Partial Content", &[("Content-Range", "bytes 2-4/6")], b"cde").await;
let bytes = fetcher(&url).fetch(&ranged_resource(url.clone())).await.unwrap();
let request = String::from_utf8(server.await.unwrap().remove(0)).unwrap();
assert_eq!(bytes, b"cde".as_slice());
assert!(
request
.lines()
.any(|line| line.eq_ignore_ascii_case("range: bytes=2-4"))
);
}
#[tokio::test]
async fn ranged_http_resource_slices_full_response() {
let (url, server) = serve_response("200 OK", &[], b"abcdef").await;
let bytes = fetcher(&url).fetch(&ranged_resource(url.clone())).await.unwrap();
server.await.unwrap();
assert_eq!(bytes, b"cde".as_slice());
}
#[tokio::test]
async fn ranged_http_resource_rejects_wrong_response_length() {
let (url, server) = serve_response("206 Partial Content", &[("Content-Range", "bytes 2-4/6")], b"cd").await;
let err = fetcher(&url).fetch(&ranged_resource(url.clone())).await.unwrap_err();
server.await.unwrap();
assert!(matches!(
err,
Error::ByteRangeLengthMismatch {
expected: 3,
actual: 2,
..
}
));
}
#[tokio::test]
async fn ranged_http_resource_rejects_mismatched_content_range() {
let (url, server) = serve_response("206 Partial Content", &[("Content-Range", "bytes 3-5/6")], b"def").await;
let err = fetcher(&url).fetch(&ranged_resource(url.clone())).await.unwrap_err();
server.await.unwrap();
assert!(matches!(err, Error::ByteRangeResponseMismatch { start: 2, end: 4, .. }));
}
#[tokio::test]
async fn imports_single_file_with_implicit_segment_ranges() {
let (init, fragments) = fmp4_parts(2);
let mut resource = init.clone();
resource.extend_from_slice(&fragments[0]);
resource.extend_from_slice(&fragments[1]);
let playlist = format!(
"#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-TARGETDURATION:2\n#EXT-X-MAP:URI=\"media.mp4\",BYTERANGE=\"{}@0\"\n#EXTINF:1,\n#EXT-X-BYTERANGE:{}@{}\nmedia.mp4\n#EXTINF:1,\n#EXT-X-BYTERANGE:{}\nmedia.mp4\n",
init.len(),
fragments[0].len(),
init.len(),
fragments[1].len()
);
let (mut import, catalog) = write_import(&temp_dir(), &resource, &playlist);
import.init().await.unwrap();
let snapshot = catalog.snapshot();
assert_eq!(snapshot.video.renditions.len(), 1);
assert_eq!(snapshot.audio.renditions.len(), 1);
assert_eq!(import.video[0].next_sequence, Some(2));
}
#[tokio::test]
async fn a_persistently_failing_rendition_is_evicted() {
let (init, fragments) = fmp4_parts(2);
let mut resource = init.clone();
resource.extend_from_slice(&fragments[0]);
resource.extend_from_slice(&fragments[1]);
let playlist = format!(
"#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-TARGETDURATION:2\n#EXT-X-MAP:URI=\"media.mp4\",BYTERANGE=\"{}@0\"\n#EXTINF:1,\n#EXT-X-BYTERANGE:{}@{}\nmedia.mp4\n#EXTINF:1,\n#EXT-X-BYTERANGE:{}\nmedia.mp4\n",
init.len(),
fragments[0].len(),
init.len(),
fragments[1].len()
);
let dir = temp_dir();
let (mut import, _catalog) = write_import(&dir, &resource, &playlist);
import.init().await.unwrap();
assert!(import.video[0].importer.is_some(), "the rendition imported once");
std::fs::remove_file(dir.join("media.m3u8")).unwrap();
for _ in 0..MAX_RENDITION_FAILURES - 1 {
import.step(OnError::Warn).await.unwrap();
assert!(
import.video[0].importer.is_some(),
"a transient failure keeps the rendition"
);
}
import.step(OnError::Warn).await.unwrap();
assert!(
import.video[0].importer.is_none(),
"the dead rendition stops gating the broadcast's timeline"
);
}
#[tokio::test]
async fn joins_mid_window_using_prefix_map_and_byte_ranges() {
let (init, fragments) = fmp4_parts(5);
let segments: Vec<&Vec<u8>> = [0, 1, 2, 4].iter().map(|&k| &fragments[k]).collect();
let mut resource = init.clone();
for segment in &segments {
resource.extend_from_slice(segment);
}
let mut playlist = format!(
"#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-TARGETDURATION:2\n#EXT-X-MAP:URI=\"media.mp4\",BYTERANGE=\"{}@0\"\n#EXTINF:1,\n#EXT-X-BYTERANGE:{}@{}\nmedia.mp4\n",
init.len(),
segments[0].len(),
init.len()
);
for segment in &segments[1..] {
playlist.push_str(&format!("#EXTINF:1,\n#EXT-X-BYTERANGE:{}\nmedia.mp4\n", segment.len()));
}
let (mut import, catalog) = write_import(&temp_dir(), &resource, &playlist);
import.init().await.unwrap();
let track = &import.video[0];
assert!(
track.importer.is_some(),
"the EXT-X-MAP from the skipped prefix must initialize the importer"
);
assert_eq!(track.next_sequence, Some(4));
assert_eq!(catalog.snapshot().video.renditions.len(), 1);
}
#[tokio::test]
async fn map_change_replaces_importer_and_catalog_generation() {
let (init, fragments) = fmp4_parts(2);
let second_init = init.len() + fragments[0].len();
let second_fragment = second_init + init.len();
let mut resource = init.clone();
resource.extend_from_slice(&fragments[0]);
resource.extend_from_slice(&init);
resource.extend_from_slice(&fragments[1]);
let playlist = format!(
"#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-TARGETDURATION:2\n#EXT-X-MAP:URI=\"media.mp4\",BYTERANGE=\"{}@0\"\n#EXTINF:1,\n#EXT-X-BYTERANGE:{}@{}\nmedia.mp4\n#EXT-X-DISCONTINUITY\n#EXT-X-MAP:URI=\"media.mp4\",BYTERANGE=\"{}@{}\"\n#EXTINF:1,\n#EXT-X-BYTERANGE:{}@{}\nmedia.mp4\n",
init.len(),
fragments[0].len(),
init.len(),
init.len(),
second_init,
fragments[1].len(),
second_fragment
);
let (mut import, catalog) = write_import(&temp_dir(), &resource, &playlist);
import.init().await.unwrap();
let snapshot = catalog.snapshot();
assert_eq!(snapshot.video.renditions.len(), 1);
assert_eq!(snapshot.audio.renditions.len(), 1);
assert_eq!(
import.video[0].map.as_ref().unwrap().range.unwrap().start,
second_init as u64
);
}
async fn discover(master_body: &str) -> Import {
let dir = temp_dir();
let path = dir.join("master.m3u8");
std::fs::write(&path, master_body).unwrap();
let mut broadcast = moq_net::broadcast::Info::new().produce();
let catalog = CatalogProducer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
let cfg = Config::new(Url::from_file_path(&path).unwrap());
let mut hls = Import::new(broadcast, catalog, cfg).unwrap();
hls.ensure_tracks().await.unwrap();
hls
}
#[tokio::test]
async fn discover_splits_separate_audio() {
let master = "#EXTM3U\n\
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"en\",URI=\"audio.m3u8\"\n\
#EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS=\"avc1.4d401f,mp4a.40.2\",AUDIO=\"aud\"\n\
video.m3u8\n";
let hls = discover(master).await;
assert_eq!(hls.video.len(), 1);
assert!(hls.video[0].select.has_video() && !hls.video[0].select.has_audio());
let audio = hls.audio.as_ref().expect("separate audio rendition");
assert!(audio.select.has_audio() && !audio.select.has_video());
}
#[tokio::test]
async fn discover_muxed_variant_keeps_both() {
let master = "#EXTM3U\n\
#EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS=\"avc1.4d401f\"\n\
video.m3u8\n";
let hls = discover(master).await;
assert_eq!(hls.video.len(), 1);
assert!(hls.video[0].select.has_video() && hls.video[0].select.has_audio());
assert!(hls.audio.is_none());
}
#[test]
fn moq_sequence_orders_discontinuities_after_media_sequence() {
let last_of_epoch_0 = moq_sequence(0, u64::from(u32::MAX)).unwrap();
let first_of_epoch_1 = moq_sequence(1, 0).unwrap();
assert!(first_of_epoch_1 > last_of_epoch_0);
}
#[test]
fn moq_sequence_preserves_media_order_within_epoch() {
assert!(moq_sequence(3, 10).unwrap() > moq_sequence(3, 9).unwrap());
}
#[test]
fn moq_sequence_rejects_unrepresentable_media_sequence() {
let err = moq_sequence(0, 1u64 << 48).unwrap_err();
assert!(matches!(
err,
Error::SequenceOverflow {
kind: SequenceKind::Media,
..
}
));
}
#[test]
fn moq_sequence_rejects_unrepresentable_discontinuity_sequence() {
let err = moq_sequence(1u64 << 16, 0).unwrap_err();
assert!(matches!(
err,
Error::SequenceOverflow {
kind: SequenceKind::Discontinuity,
..
}
));
}
}