use alloc::collections::{BTreeMap, BTreeSet, VecDeque};
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use transmux::TrackId;
use broadcast_common::Unpackage;
use broadcast_hls::{ByteRange, MapTag, MediaPlaylist, MediaSegment, OpenSegment, PreloadHintType};
use transmux::{DemuxEvent, Fmp4Demux, Sample, StreamingTsDemux, TrackSpec};
use super::action::{Action, BlockingReload, ResourceId};
use super::error::{Error, Result};
use super::output::Output;
use super::url;
const TS_SYNC_BYTE: u8 = 0x47;
const TS_MOVIE_TIMESCALE: u32 = 90_000;
const LIVE_EDGE_TARGET_DURATIONS: f64 = 3.0;
struct TsDemuxState(StreamingTsDemux);
impl core::fmt::Debug for TsDemuxState {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str("TsDemuxState(StreamingTsDemux)")
}
}
fn index_u64(i: usize) -> u64 {
u64::try_from(i).unwrap_or(u64::MAX)
}
fn resource_msn(id: &ResourceId) -> Option<u64> {
match id {
ResourceId::Part { msn, .. } | ResourceId::Segment { msn } => Some(*msn),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Continuity {
Continues,
Lagging,
OlderWindow,
Restart,
}
fn program_date_time(seg: &MediaSegment) -> Option<&str> {
seg.pre_tags
.iter()
.find_map(|t| t.strip_prefix("#EXT-X-PROGRAM-DATE-TIME:"))
}
fn program_date_time_ms(seg: &MediaSegment) -> Option<i64> {
parse_rfc3339_ms(program_date_time(seg)?)
}
fn parse_rfc3339_ms(text: &str) -> Option<i64> {
text.parse::<jiff::Timestamp>()
.ok()
.map(|t| t.as_millisecond())
}
#[derive(Debug)]
pub struct HlsClient {
playlist_url: String,
pending_actions: VecDeque<Action>,
#[cfg(feature = "std")]
wait_anchor: Option<std::time::Instant>,
pending_outputs: VecDeque<Output>,
init_uri: Option<String>,
init_bytes: Option<Vec<u8>>,
init_emitted: bool,
pending_demux: VecDeque<(ResourceId, Vec<u8>)>,
requested: BTreeSet<ResourceId>,
fulfilled: BTreeSet<ResourceId>,
joined: bool,
ts_demux: Option<TsDemuxState>,
ts_specs: BTreeMap<TrackId, TrackSpec>,
last_ts_init: Option<Vec<u8>>,
delivered_parts: BTreeSet<(u64, u64)>,
delivered_segments: BTreeSet<u64>,
discontinuous_msns: BTreeSet<u64>,
discontinuity_emitted: BTreeSet<u64>,
byte_range_cursor: BTreeMap<String, u64>,
outstanding_fetches: u64,
saw_endlist: bool,
end_emitted: bool,
last_full_playlist: Option<MediaPlaylist>,
last_media_sequence: Option<u64>,
}
impl HlsClient {
pub fn new(playlist_url: impl Into<String>) -> Self {
let playlist_url = playlist_url.into();
let mut pending_actions = VecDeque::new();
pending_actions.push_back(Action::FetchPlaylist {
url: playlist_url.clone(),
blocking: None,
skip: false,
});
Self {
playlist_url,
pending_actions,
#[cfg(feature = "std")]
wait_anchor: None,
pending_outputs: VecDeque::new(),
init_uri: None,
init_bytes: None,
init_emitted: false,
pending_demux: VecDeque::new(),
requested: BTreeSet::new(),
fulfilled: BTreeSet::new(),
joined: false,
ts_demux: None,
ts_specs: BTreeMap::new(),
last_ts_init: None,
delivered_parts: BTreeSet::new(),
delivered_segments: BTreeSet::new(),
discontinuous_msns: BTreeSet::new(),
discontinuity_emitted: BTreeSet::new(),
byte_range_cursor: BTreeMap::new(),
outstanding_fetches: 0,
saw_endlist: false,
end_emitted: false,
last_full_playlist: None,
last_media_sequence: None,
}
}
pub fn playlist_url(&self) -> &str {
&self.playlist_url
}
pub fn poll(&mut self) -> Option<Action> {
#[cfg(feature = "std")]
{
self.wait_anchor = None;
}
self.pending_actions.pop_front()
}
pub fn next_wait(&self) -> Option<core::time::Duration> {
match self.pending_actions.front() {
Some(Action::WaitMs(ms)) => Some(core::time::Duration::from_millis(*ms)),
_ => None,
}
}
#[cfg(feature = "std")]
pub fn poll_timeout(&mut self, now: std::time::Instant) -> Option<std::time::Instant> {
let wait = self.next_wait()?;
let anchor = *self.wait_anchor.get_or_insert(now);
Some(anchor + wait)
}
pub fn next_output(&mut self) -> Option<Output> {
self.pending_outputs.pop_front()
}
pub fn on_playlist(&mut self, bytes: &[u8]) -> Result<()> {
let text = core::str::from_utf8(bytes)?;
let playlist = MediaPlaylist::parse(text)?;
let playlist = self.merge_delta(playlist);
let mut lag_floor: Option<u64> = None;
if self.last_media_sequence.is_some() {
match self.continuity(&playlist) {
Continuity::Restart => self.restart_after_regression()?,
Continuity::OlderWindow => {
lag_floor = self.last_full_playlist.as_ref().map(|p| p.media_sequence);
}
Continuity::Lagging | Continuity::Continues => {}
}
}
self.last_media_sequence = Some(
self.last_media_sequence
.map_or(playlist.media_sequence, |p| p.max(playlist.media_sequence)),
);
let next_msn = playlist
.media_sequence
.checked_add(index_u64(playlist.segments.len()))
.ok_or(Error::MediaSequenceOverflow {
media_sequence: playlist.media_sequence,
segments: playlist.segments.len(),
})?;
if !self.joined {
self.joined = true;
self.join_start(&playlist)?;
}
for (i, seg) in playlist.segments.iter().enumerate() {
let msn = playlist.media_sequence.saturating_add(index_u64(i));
if lag_floor.is_some_and(|floor| msn < floor) {
self.skip_segment(msn, seg)?;
continue;
}
self.process_closed_segment(msn, seg)?;
}
if let Some(open) = &playlist.open_segment {
self.process_open_segment(next_msn, open)?;
}
let map = playlist
.open_segment
.as_ref()
.and_then(|o| o.map.as_ref())
.or_else(|| playlist.segments.last().and_then(|s| s.map.as_ref()));
if let Some(map) = map {
self.ensure_init_requested(map)?;
}
if let Some(ll) = &playlist.low_latency
&& let Some(hint_uri) = &ll.preload_hint_part
{
match ll.preload_hint_type {
PreloadHintType::Part => {
let part_idx = playlist
.open_segment
.as_ref()
.map(|o| index_u64(o.parts.len()))
.unwrap_or(0);
let id = ResourceId::Part {
msn: next_msn,
part: part_idx,
};
let url = url::resolve(&self.playlist_url, hint_uri);
let byte_range = self.resolve_hint_byte_range(&url, ll)?;
self.request_resource(id, url, byte_range);
}
PreloadHintType::Map => {
let map = MapTag {
uri: hint_uri.clone(),
byte_range: ll.preload_hint_byte_range_length.map(|length| ByteRange {
length,
offset: ll.preload_hint_byte_range_start,
}),
extra_attrs: Vec::new(),
};
self.ensure_init_requested(&map)?;
}
_ => {
}
}
}
if playlist.endlist {
self.saw_endlist = true;
} else {
let blocking = playlist
.low_latency
.as_ref()
.filter(|ll| ll.can_block_reload)
.map(|_| {
let part = playlist
.open_segment
.as_ref()
.map(|o| index_u64(o.parts.len()))
.unwrap_or(0);
BlockingReload {
msn: next_msn,
part: Some(part),
}
});
let can_skip = playlist
.low_latency
.as_ref()
.and_then(|ll| ll.can_skip_until)
.is_some();
let skip = can_skip && self.last_full_playlist.is_some();
self.pending_actions.push_back(Action::FetchPlaylist {
url: self.playlist_url.clone(),
blocking,
skip,
});
if blocking.is_none() {
let wait_ms = (u64::from(playlist.target_duration.max(1)) * 1000) / 2;
self.pending_actions.push_back(Action::WaitMs(wait_ms));
}
}
self.prune(&playlist);
if playlist.skip.is_none() {
self.last_full_playlist = Some(playlist);
}
self.maybe_emit_end_of_stream()?;
Ok(())
}
pub fn on_resource(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
let was_requested = match id {
ResourceId::Init => self.init_uri.is_some(),
ResourceId::Part { .. } | ResourceId::Segment { .. } => self.requested.contains(&id),
};
if !was_requested {
return Err(Error::UnrequestedResource { id });
}
let duplicate = match id {
ResourceId::Init => self.init_bytes.is_some(),
ResourceId::Part { .. } | ResourceId::Segment { .. } => self.fulfilled.contains(&id),
};
if duplicate {
return Err(Error::DuplicateResource { id });
}
self.outstanding_fetches = self.outstanding_fetches.saturating_sub(1);
if id != ResourceId::Init {
self.fulfilled.insert(id);
}
match id {
ResourceId::Init => {
self.init_bytes = Some(bytes.to_vec());
if !self.init_emitted {
self.pending_outputs.push_back(Output::Init(bytes.to_vec()));
self.init_emitted = true;
}
let buffered: Vec<_> = self.pending_demux.drain(..).collect();
for (bid, bbytes) in buffered {
if let Err(e) = self.finish_media_resource(bid, &bbytes) {
self.fulfilled.remove(&bid);
return Err(e);
}
}
}
ResourceId::Part { .. } | ResourceId::Segment { .. } => {
let processed = if self.is_ts_segment(bytes) {
self.finish_ts_resource(id, bytes)
} else if self.init_bytes.is_none() {
self.pending_demux.push_back((id, bytes.to_vec()));
Ok(())
} else {
self.finish_media_resource(id, bytes)
};
if let Err(e) = processed {
self.fulfilled.remove(&id);
return Err(e);
}
}
}
self.maybe_emit_end_of_stream()?;
Ok(())
}
fn finish_media_resource(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
match id {
ResourceId::Part { msn, part } => {
self.emit_discontinuity_if_needed(msn);
self.demux_and_emit(id, bytes)?;
self.delivered_parts.insert((msn, part));
}
ResourceId::Segment { msn } => {
self.emit_discontinuity_if_needed(msn);
self.demux_and_emit(id, bytes)?;
self.delivered_segments.insert(msn);
}
ResourceId::Init => {}
}
Ok(())
}
fn finish_ts_resource(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
match id {
ResourceId::Part { msn, part } => {
self.begin_ts_resource(id, msn)?;
self.demux_and_emit_ts(id, bytes)?;
self.delivered_parts.insert((msn, part));
}
ResourceId::Segment { msn } => {
self.begin_ts_resource(id, msn)?;
self.demux_and_emit_ts(id, bytes)?;
self.delivered_segments.insert(msn);
}
ResourceId::Init => {}
}
Ok(())
}
fn is_ts_segment(&self, bytes: &[u8]) -> bool {
self.init_uri.is_none() && bytes.first() == Some(&TS_SYNC_BYTE)
}
pub fn on_error(&mut self, id: Option<ResourceId>) {
if let Some(id) = id {
self.outstanding_fetches = self.outstanding_fetches.saturating_sub(1);
match id {
ResourceId::Init => self.init_uri = None,
other => {
self.requested.remove(&other);
}
}
}
let _ = self.maybe_emit_end_of_stream();
}
fn merge_delta(&self, playlist: MediaPlaylist) -> MediaPlaylist {
let Some(skip) = &playlist.skip else {
return playlist;
};
if skip.skipped_segments == 0 {
return playlist;
}
let Some(prev) = &self.last_full_playlist else {
return playlist;
};
if playlist.media_sequence < prev.media_sequence {
return playlist;
}
let Ok(prefix_start) = usize::try_from(playlist.media_sequence - prev.media_sequence)
else {
return playlist;
};
let prefix_end = usize::try_from(skip.skipped_segments)
.ok()
.and_then(|skipped| prefix_start.checked_add(skipped));
let Some(prefix) = prefix_end.and_then(|end| prev.segments.get(prefix_start..end)) else {
return playlist;
};
let mut merged = playlist;
let mut segments = prefix.to_vec();
segments.extend(merged.segments);
merged.segments = segments;
merged
}
fn process_closed_segment(&mut self, msn: u64, seg: &MediaSegment) -> Result<()> {
if seg.discontinuous {
self.discontinuous_msns.insert(msn);
}
if self.delivered_segments.contains(&msn) {
return Ok(());
}
if seg.parts.is_empty() {
let already_have_parts = self
.delivered_parts
.range((msn, 0)..=(msn, u64::MAX))
.next()
.is_some();
if already_have_parts {
self.delivered_segments.insert(msn);
return Ok(());
}
let id = ResourceId::Segment { msn };
if !self.requested.contains(&id) {
let url = url::resolve(&self.playlist_url, &seg.uri);
let byte_range = self.resolve_byte_range(&url, &seg.byte_range)?;
self.request_resource(id, url, byte_range);
}
return Ok(());
}
let mut fully_accounted = true;
for (i, part) in seg.parts.iter().enumerate() {
let i = index_u64(i);
if part.gap || self.delivered_parts.contains(&(msn, i)) {
continue;
}
fully_accounted = false;
let id = ResourceId::Part { msn, part: i };
if !self.requested.contains(&id) {
let url = url::resolve(&self.playlist_url, &part.uri);
let byte_range = self.resolve_byte_range(&url, &part.byte_range)?;
self.request_resource(id, url, byte_range);
}
}
if fully_accounted {
self.delivered_segments.insert(msn);
}
Ok(())
}
fn process_open_segment(&mut self, msn: u64, open: &OpenSegment) -> Result<()> {
for (i, part) in open.parts.iter().enumerate() {
let i = index_u64(i);
if part.gap || self.delivered_parts.contains(&(msn, i)) {
continue;
}
let id = ResourceId::Part { msn, part: i };
if !self.requested.contains(&id) {
let url = url::resolve(&self.playlist_url, &part.uri);
let byte_range = self.resolve_byte_range(&url, &part.byte_range)?;
self.request_resource(id, url, byte_range);
}
}
Ok(())
}
fn ensure_init_requested(&mut self, map: &MapTag) -> Result<()> {
let url = url::resolve(&self.playlist_url, &map.uri);
if self.init_uri.as_deref() == Some(url.as_str()) {
return Ok(());
}
self.init_uri = Some(url.clone());
self.init_bytes = None;
self.init_emitted = false;
let byte_range = self.resolve_byte_range(&url, &map.byte_range)?;
self.pending_actions.push_back(Action::FetchResource {
id: ResourceId::Init,
url,
byte_range,
});
self.outstanding_fetches += 1;
Ok(())
}
fn request_resource(&mut self, id: ResourceId, url: String, byte_range: Option<(u64, u64)>) {
self.requested.insert(id);
self.outstanding_fetches += 1;
self.pending_actions.push_back(Action::FetchResource {
id,
url,
byte_range,
});
}
fn resolve_byte_range(
&mut self,
url: &str,
br: &Option<ByteRange>,
) -> Result<Option<(u64, u64)>> {
let Some(br) = br.as_ref() else {
return Ok(None);
};
let offset = br
.offset
.unwrap_or_else(|| *self.byte_range_cursor.get(url).unwrap_or(&0));
let next_cursor =
offset
.checked_add(br.length)
.ok_or_else(|| Error::ByteRangeOverflow {
url: url.to_string(),
offset,
length: br.length,
})?;
self.byte_range_cursor.insert(url.to_string(), next_cursor);
Ok(Some((offset, br.length)))
}
fn resolve_hint_byte_range(
&mut self,
url: &str,
ll: &broadcast_hls::LowLatencyConfig,
) -> Result<Option<(u64, u64)>> {
let Some(length) = ll.preload_hint_byte_range_length else {
return Ok(None);
};
let br = ByteRange {
length,
offset: ll.preload_hint_byte_range_start,
};
self.resolve_byte_range(url, &Some(br))
}
fn demux_and_emit(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
let init = self
.init_bytes
.as_ref()
.ok_or(Error::InitNotYetAvailable { id })?;
let mut combined = Vec::with_capacity(init.len() + bytes.len());
combined.extend_from_slice(init);
combined.extend_from_slice(bytes);
let mut demux = Fmp4Demux::new();
let media = demux
.unpackage(combined.as_slice())
.map_err(|source| Error::Demux { id, source })?;
for track in media.tracks {
if !track.samples.is_empty() {
self.pending_outputs.push_back(Output::Samples {
track_id: track.spec.track_id,
samples: track.samples,
});
}
}
Ok(())
}
fn begin_ts_resource(&mut self, id: ResourceId, msn: u64) -> Result<()> {
if self.discontinuous_msns.contains(&msn) && !self.discontinuity_emitted.contains(&msn) {
self.flush_ts_demux(id)?;
self.emit_discontinuity_if_needed(msn);
self.ts_demux = None;
self.ts_specs.clear();
}
Ok(())
}
fn demux_and_emit_ts(&mut self, id: ResourceId, bytes: &[u8]) -> Result<()> {
let TsDemuxState(demux) = self
.ts_demux
.get_or_insert_with(|| TsDemuxState(StreamingTsDemux::new()));
demux.feed(bytes);
self.drain_ts_demux(id)
}
fn flush_ts_demux(&mut self, id: ResourceId) -> Result<()> {
let Some(TsDemuxState(demux)) = self.ts_demux.as_mut() else {
return Ok(());
};
demux.finish();
self.drain_ts_demux(id)
}
fn drain_ts_demux(&mut self, id: ResourceId) -> Result<()> {
let Some(TsDemuxState(demux)) = self.ts_demux.as_mut() else {
return Ok(());
};
let mut per_track: BTreeMap<TrackId, Vec<Sample>> = BTreeMap::new();
while let Some(event) = demux.poll_event() {
match event {
DemuxEvent::TrackAdded(spec) | DemuxEvent::TrackUpdated(spec) => {
self.ts_specs.insert(spec.track_id, spec);
}
DemuxEvent::TrackRemoved { track_id, .. } => {
self.ts_specs.remove(&track_id);
}
DemuxEvent::Sample {
track_id, sample, ..
} => {
per_track.entry(track_id).or_default().push(sample);
}
_ => {}
}
}
if !self.ts_specs.is_empty() {
let specs: Vec<TrackSpec> = self.ts_specs.values().cloned().collect();
let init_bytes = transmux::build_init_segment(&specs, TS_MOVIE_TIMESCALE)
.map_err(|source| Error::Demux { id, source })?;
if self.last_ts_init.as_deref() != Some(init_bytes.as_slice()) {
self.pending_outputs
.push_back(Output::Init(init_bytes.clone()));
self.last_ts_init = Some(init_bytes);
self.init_emitted = true;
}
}
for (track_id, samples) in per_track {
if !samples.is_empty() {
self.pending_outputs
.push_back(Output::Samples { track_id, samples });
}
}
Ok(())
}
fn emit_discontinuity_if_needed(&mut self, msn: u64) -> bool {
if self.discontinuous_msns.contains(&msn) && !self.discontinuity_emitted.contains(&msn) {
self.pending_outputs.push_back(Output::Discontinuity);
self.discontinuity_emitted.insert(msn);
return true;
}
false
}
fn join_start(&mut self, playlist: &MediaPlaylist) -> Result<()> {
let segments = &playlist.segments;
let target = f64::from(playlist.target_duration.max(1));
let duration_of = |seg: &MediaSegment| {
let d = seg.duration.get();
if d > 0.0 { d } else { target }
};
let from_start_tag = playlist.start.as_ref().map(|start| {
let total: f64 = segments.iter().map(duration_of).sum();
let offset = start.time_offset.get();
let position = if offset >= 0.0 {
offset
} else {
total + offset
};
let position = position.clamp(0.0, total);
let mut elapsed = 0.0_f64;
for (i, seg) in segments.iter().enumerate() {
elapsed += duration_of(seg);
if elapsed > position {
return i;
}
}
segments.len().saturating_sub(1)
});
let from_hold_back = (!playlist.endlist).then(|| {
let ll = playlist.low_latency.as_ref();
let wanted = match ll.filter(|ll| ll.can_block_reload) {
Some(ll) if ll.part_hold_back.is_some() => {
ll.part_hold_back.map_or(0.0, |p| p.get())
}
_ => {
let hold_back = ll.and_then(|ll| ll.hold_back).map_or(0.0, |h| h.get());
(LIVE_EDGE_TARGET_DURATIONS * target).max(hold_back)
}
};
let mut behind: f64 = playlist
.open_segment
.as_ref()
.map_or(0.0, |o| o.parts.iter().map(|p| p.duration.get()).sum());
if behind >= wanted {
return segments.len();
}
for (i, seg) in segments.iter().enumerate().rev() {
behind += duration_of(seg);
if behind >= wanted {
return i;
}
}
0
});
let start = match (from_start_tag, from_hold_back) {
(Some(s), Some(h)) => s.min(h),
(Some(s), None) => s,
(None, Some(h)) => h,
(None, None) => 0,
};
for (i, seg) in segments.iter().enumerate().take(start) {
let msn = playlist.media_sequence.saturating_add(index_u64(i));
self.skip_segment(msn, seg)?;
}
Ok(())
}
fn skip_segment(&mut self, msn: u64, seg: &MediaSegment) -> Result<()> {
if seg.parts.is_empty() {
let url = url::resolve(&self.playlist_url, &seg.uri);
self.resolve_byte_range(&url, &seg.byte_range)?;
} else {
for part in seg.parts.iter().filter(|p| !p.gap) {
let url = url::resolve(&self.playlist_url, &part.uri);
self.resolve_byte_range(&url, &part.byte_range)?;
}
}
self.delivered_segments.insert(msn);
Ok(())
}
fn prune(&mut self, playlist: &MediaPlaylist) {
let first = playlist.media_sequence;
let below = |id: &ResourceId| resource_msn(id).is_some_and(|m| m < first);
let fulfilled = &self.fulfilled;
self.requested
.retain(|id| !(below(id) && fulfilled.contains(id)));
self.fulfilled.retain(|id| !below(id));
self.delivered_parts.retain(|&(m, _)| m >= first);
self.delivered_segments.retain(|&m| m >= first);
self.discontinuous_msns.retain(|&m| m >= first);
self.discontinuity_emitted.retain(|&m| m >= first);
if playlist.skip.is_none() {
let base = &self.playlist_url;
let mut live: BTreeSet<String> = BTreeSet::new();
for seg in &playlist.segments {
live.insert(url::resolve(base, &seg.uri));
for part in &seg.parts {
live.insert(url::resolve(base, &part.uri));
}
if let Some(map) = &seg.map {
live.insert(url::resolve(base, &map.uri));
}
}
if let Some(open) = &playlist.open_segment {
for part in &open.parts {
live.insert(url::resolve(base, &part.uri));
}
if let Some(map) = &open.map {
live.insert(url::resolve(base, &map.uri));
}
}
if let Some(hint) = playlist
.low_latency
.as_ref()
.and_then(|ll| ll.preload_hint_part.as_ref())
{
live.insert(url::resolve(base, hint));
}
self.byte_range_cursor.retain(|u, _| live.contains(u));
}
}
fn continuity(&self, new: &MediaPlaylist) -> Continuity {
let Some(prev) = &self.last_full_playlist else {
return if self
.last_media_sequence
.is_some_and(|p| new.media_sequence < p)
{
Continuity::Restart
} else {
Continuity::Continues
};
};
let (p0, n0) = (prev.media_sequence, new.media_sequence);
let regressed = n0 < self.last_media_sequence.unwrap_or(p0).max(p0);
let mut overlap = false;
for (i, seg) in new.segments.iter().enumerate() {
let Some(msn) = n0.checked_add(index_u64(i)) else {
return Continuity::Restart;
};
let Some(old) = msn
.checked_sub(p0)
.and_then(|d| usize::try_from(d).ok())
.and_then(|d| prev.segments.get(d))
else {
continue;
};
if old.uri != seg.uri
|| old.duration != seg.duration
|| old.byte_range != seg.byte_range
{
return Continuity::Restart;
}
if let (Some(a), Some(b)) = (program_date_time(old), program_date_time(seg))
&& a != b
{
return Continuity::Restart;
}
overlap = true;
}
if !regressed && new.discontinuity_sequence < prev.discontinuity_sequence {
return Continuity::Restart;
}
if !regressed {
return Continuity::Continues;
}
if overlap {
return Continuity::Lagging;
}
let old_uris: BTreeSet<&str> = prev.segments.iter().map(|s| s.uri.as_str()).collect();
if new
.segments
.iter()
.any(|s| old_uris.contains(s.uri.as_str()))
{
return Continuity::Restart;
}
let new_last = new.segments.iter().rev().find_map(program_date_time_ms);
let prev_first = prev.segments.iter().find_map(program_date_time_ms);
match (new_last, prev_first) {
(Some(end), Some(start)) if end < start => Continuity::OlderWindow,
_ => Continuity::Restart,
}
}
fn restart_after_regression(&mut self) -> Result<()> {
self.flush_ts_demux(ResourceId::Init)?;
self.ts_demux = None;
self.ts_specs.clear();
self.last_ts_init = None;
self.pending_actions
.retain(|a| !matches!(a, Action::FetchResource { .. }));
self.pending_demux.clear();
self.requested.clear();
self.fulfilled.clear();
self.delivered_parts.clear();
self.delivered_segments.clear();
self.discontinuous_msns.clear();
self.discontinuity_emitted.clear();
self.byte_range_cursor.clear();
self.outstanding_fetches = 0;
self.last_full_playlist = None;
self.init_uri = None;
self.init_bytes = None;
self.init_emitted = false;
self.joined = false;
self.pending_outputs.push_back(Output::Discontinuity);
Ok(())
}
fn maybe_emit_end_of_stream(&mut self) -> Result<()> {
if self.saw_endlist && !self.end_emitted && self.outstanding_fetches == 0 {
self.flush_ts_demux(ResourceId::Init)?;
self.pending_outputs.push_back(Output::EndOfStream);
self.end_emitted = true;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn on_resource_rejects_a_never_requested_id() {
let mut client = HlsClient::new("http://example.com/playlist.m3u8");
let id = ResourceId::Segment { msn: 0 };
let err = client
.on_resource(id, b"some bytes")
.expect_err("an id the client never requested must be rejected");
assert!(
matches!(err, Error::UnrequestedResource { id: got } if got == id),
"wrong error variant: {err:?}"
);
let err = client
.on_resource(ResourceId::Init, b"init bytes")
.expect_err("an unrequested Init must be rejected");
assert!(
matches!(
err,
Error::UnrequestedResource {
id: ResourceId::Init
}
),
"wrong error variant: {err:?}"
);
}
#[test]
fn on_resource_accepts_a_previously_requested_id() {
let mut client = HlsClient::new("http://example.com/playlist.m3u8");
let id = ResourceId::Segment { msn: 0 };
client.request_resource(id, "http://example.com/seg0.m4s".to_string(), None);
let result = client.on_resource(id, b"some bytes");
assert!(
result.is_ok(),
"a requested id must be accepted: {result:?}"
);
assert!(
client.pending_demux.iter().any(|(bid, _)| *bid == id),
"expected the resource to be buffered pending the init segment"
);
}
#[test]
fn is_ts_segment_true_when_no_map_seen_and_sync_byte_present() {
let client = HlsClient::new("http://example.com/playlist.m3u8");
assert!(client.is_ts_segment(&[TS_SYNC_BYTE, 0x40, 0x11, 0x00]));
}
#[test]
fn is_ts_segment_false_for_an_isobmff_resource_with_no_map_seen() {
let client = HlsClient::new("http://example.com/playlist.m3u8");
let ftyp_box = b"\x00\x00\x00\x18ftypiso5\x00\x00\x02\x00iso5iso6mp41";
assert!(!client.is_ts_segment(ftyp_box));
}
#[test]
fn is_ts_segment_false_once_a_map_has_been_requested() {
let mut client = HlsClient::new("http://example.com/playlist.m3u8");
client
.ensure_init_requested(&MapTag {
uri: "init.mp4".to_string(),
byte_range: None,
extra_attrs: Vec::new(),
})
.expect("ensure_init_requested succeeds");
assert!(!client.is_ts_segment(&[TS_SYNC_BYTE, 0x40, 0x11, 0x00]));
}
#[test]
fn resolve_byte_range_rejects_an_offset_plus_length_that_overflows_u64() {
let mut client = HlsClient::new("http://example.com/playlist.m3u8");
let br = Some(ByteRange {
length: u64::MAX,
offset: Some(1),
});
let err = client
.resolve_byte_range("http://example.com/seg0.m4s", &br)
.expect_err("offset 1 + length u64::MAX must overflow and be rejected");
assert!(
matches!(
err,
Error::ByteRangeOverflow {
offset: 1,
length: u64::MAX,
..
}
),
"wrong error variant/fields: {err:?}"
);
}
#[test]
fn resolve_byte_range_rejects_a_cursor_accumulation_that_overflows_u64() {
let mut client = HlsClient::new("http://example.com/playlist.m3u8");
let url = "http://example.com/seg0.m4s";
let seed = Some(ByteRange {
length: u64::MAX - 10,
offset: Some(5),
});
let (offset, length) = client
.resolve_byte_range(url, &seed)
.expect("seed range does not itself overflow")
.expect("Some for a Some(ByteRange)");
assert_eq!((offset, length), (5, u64::MAX - 10));
let next = Some(ByteRange {
length: 100,
offset: None, });
let err = client
.resolve_byte_range(url, &next)
.expect_err("cursor + next length must overflow and be rejected");
assert!(
matches!(err, Error::ByteRangeOverflow { length: 100, .. }),
"wrong error variant/fields: {err:?}"
);
}
#[test]
fn merge_delta_does_not_panic_on_an_overflowing_skipped_segments() {
let mut client = HlsClient::new("http://example.com/playlist.m3u8");
let prev = MediaPlaylist {
media_sequence: 0,
segments: vec![MediaSegment::default(); 2],
..Default::default()
};
client.last_full_playlist = Some(prev);
let delta = MediaPlaylist {
media_sequence: 10, skip: Some(broadcast_hls::SkipInfo {
skipped_segments: u64::MAX - 5,
..Default::default()
}),
segments: Vec::new(),
..Default::default()
};
let merged = client.merge_delta(delta.clone());
assert_eq!(
merged, delta,
"an unmergeable skip count must fall back to the delta as-is, not panic"
);
}
fn byte_range_playlist(first_msn: u64, file: &str) -> String {
format!(
"#EXTM3U\n#EXT-X-VERSION:4\n#EXT-X-TARGETDURATION:2\n\
#EXT-X-MEDIA-SEQUENCE:{first_msn}\n\
#EXTINF:2.0,\n#EXT-X-BYTERANGE:100@0\n{file}\n\
#EXTINF:2.0,\n#EXT-X-BYTERANGE:50\n{file}\n"
)
}
#[test]
fn state_is_pruned_to_the_current_window() {
let mut client = HlsClient::new("http://example.com/p.m3u8");
client
.on_playlist(byte_range_playlist(0, "a.ts").as_bytes())
.unwrap();
assert_eq!(
client.byte_range_cursor.iter().collect::<Vec<_>>(),
vec![(&"http://example.com/a.ts".to_string(), &150u64)]
);
client
.on_resource(ResourceId::Segment { msn: 0 }, b"opaque")
.unwrap();
client
.on_playlist(byte_range_playlist(1000, "b.ts").as_bytes())
.unwrap();
let requested: Vec<ResourceId> = client.requested.iter().copied().collect();
assert_eq!(
requested,
vec![
ResourceId::Segment { msn: 1 },
ResourceId::Segment { msn: 1000 },
ResourceId::Segment { msn: 1001 },
]
);
assert!(client.fulfilled.is_empty(), "{:?}", client.fulfilled);
assert_eq!(
client.byte_range_cursor.iter().collect::<Vec<_>>(),
vec![(&"http://example.com/b.ts".to_string(), &150u64)],
"the cursor of the unreferenced a.ts must be dropped"
);
}
#[test]
fn discontinuity_bookkeeping_below_the_window_is_dropped() {
let mut client = HlsClient::new("http://example.com/p.m3u8");
let pl = |first: u64| {
format!(
"#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:2\n\
#EXT-X-MEDIA-SEQUENCE:{first}\n#EXT-X-DISCONTINUITY\n#EXTINF:2.0,\ns.ts\n"
)
};
client.on_playlist(pl(5).as_bytes()).unwrap();
client
.on_resource(ResourceId::Segment { msn: 5 }, b"opaque")
.unwrap();
assert_eq!(
client
.discontinuous_msns
.iter()
.copied()
.collect::<Vec<_>>(),
vec![5]
);
client.on_playlist(pl(9).as_bytes()).unwrap();
assert_eq!(
client
.discontinuous_msns
.iter()
.copied()
.collect::<Vec<_>>(),
vec![9]
);
assert!(client.discontinuity_emitted.is_empty());
assert!(client.delivered_segments.is_empty());
}
#[test]
fn next_wait_reports_the_queued_wait_hint_without_consuming_it() {
use core::time::Duration;
let mut c = HlsClient::new("http://h/p.m3u8");
assert_eq!(c.next_wait(), None, "the first queued action is a fetch");
let _ = c.poll();
c.on_playlist(
b"#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:2\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:2.0,\nseg0.ts\n",
)
.unwrap();
while c.next_wait().is_none() {
c.poll()
.expect("a WaitMs must be queued after a non-blocking live playlist");
}
assert_eq!(
c.next_wait(),
Some(Duration::from_millis(1000)),
"half the 2 s target duration"
);
assert_eq!(
c.next_wait(),
Some(Duration::from_millis(1000)),
"peeking does not consume"
);
let t0 = std::time::Instant::now();
let first = c.poll_timeout(t0).expect("a wait is at the front");
assert_eq!(first, t0 + Duration::from_millis(1000));
for later in [250u64, 600, 999, 5000] {
assert_eq!(
c.poll_timeout(t0 + Duration::from_millis(later)),
Some(first),
"re-query at +{later} ms must not slide the deadline"
);
}
assert!(matches!(c.poll(), Some(Action::WaitMs(1000))));
assert_eq!(c.poll_timeout(t0 + Duration::from_secs(9)), None);
}
#[test]
fn rfc3339_accepts_the_spellings_the_old_parser_accepted() {
assert_eq!(
parse_rfc3339_ms("2026-10-02 10:00:00Z"),
Some(1_790_935_200_000),
"space separator"
);
assert_eq!(
parse_rfc3339_ms("2026-10-02t10:00:00z"),
Some(1_790_935_200_000),
"lower-case t and z"
);
assert_eq!(
parse_rfc3339_ms("2026-10-02T10:00:00.123456789Z"),
Some(1_790_935_200_123),
"extra digits truncated"
);
assert_eq!(
parse_rfc3339_ms("1969-12-31T23:59:59Z"),
Some(-1_000),
"before the epoch"
);
}
#[test]
fn rfc3339_offset_spellings_all_name_the_same_instant() {
let want = Some(1_790_935_200_000);
for text in [
"2026-10-02T12:00:00+02:00",
"2026-10-02T12:00:00+0200",
"2026-10-02T05:00:00-05:00",
"2026-10-02T05:00:00-0500",
"2026-10-02T10:00:00+00:00",
"2026-10-02T10:00:00-00:00",
] {
assert_eq!(parse_rfc3339_ms(text), want, "{text}");
}
let hour_only = parse_rfc3339_ms("2026-10-02T12:00:00+02");
assert!(hour_only == want || hour_only.is_none(), "{hour_only:?}");
}
#[test]
fn a_leap_second_is_clamped_to_the_last_second_of_the_minute() {
assert_eq!(
parse_rfc3339_ms("2016-12-31T23:59:60Z"),
Some(1_483_228_799_000)
);
}
#[test]
fn rfc3339_parses_to_literal_epoch_milliseconds() {
assert_eq!(
parse_rfc3339_ms("2026-10-02T10:00:00Z"),
Some(1_790_935_200_000)
);
assert_eq!(
parse_rfc3339_ms("2026-10-02T10:00:00.250Z"),
Some(1_790_935_200_250)
);
assert_eq!(
parse_rfc3339_ms("2026-10-02T10:00:00.5Z"),
Some(1_790_935_200_500)
);
assert_eq!(parse_rfc3339_ms("1970-01-01T00:00:00Z"), Some(0));
assert_eq!(
parse_rfc3339_ms("2000-02-29T23:59:59Z"),
Some(951_868_799_000)
);
assert_eq!(
parse_rfc3339_ms("2026-10-02T12:00:00+02:00"),
Some(1_790_935_200_000)
);
assert_eq!(
parse_rfc3339_ms("2026-10-02T05:00:00-0500"),
Some(1_790_935_200_000)
);
for bad in [
"",
"2026-10-02",
"2026-13-02T10:00:00Z",
"2026-10-02T25:00:00Z",
"2026-10-02T10:00:00",
"2026-10-02T10:00:00Zjunk",
"2026-10-02T10:00:00.Z",
"2026-10-02T10:00:00+2:00",
"xxxx-10-02T10:00:00Z",
] {
assert_eq!(parse_rfc3339_ms(bad), None, "{bad:?}");
}
}
}