use std::collections::BTreeMap;
use std::path::PathBuf;
use broadcast_common::Unpackage;
use hls_runtime::client::{Action, HlsClient, Output};
use transmux::{CodecConfig, TsDemux};
const PLAYLIST_URL: &str = "http://fixture/index.m3u8";
const TS_SYNC_BYTE: u8 = 0x47;
fn fixture_dir() -> PathBuf {
PathBuf::from(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/ts-hls"
))
}
fn read_fixture(name: &str) -> Vec<u8> {
let path = fixture_dir().join(name);
std::fs::read(&path).unwrap_or_else(|e| panic!("read fixture {}: {e}", path.display()))
}
fn segment_names_from_playlist(playlist_text: &str) -> Vec<String> {
playlist_text
.lines()
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(|l| l.to_string())
.collect()
}
fn drive_to_end(client: &mut HlsClient) -> Vec<Output> {
let mut outputs = Vec::new();
loop {
match client.poll() {
Some(Action::FetchPlaylist { url, blocking, .. }) => {
assert_eq!(url, PLAYLIST_URL);
assert!(
blocking.is_none(),
"this fixture's playlist has ENDLIST and no LL-HLS server-control, so \
the client must never ask for a blocking reload"
);
let text = read_fixture("index.m3u8");
client.on_playlist(&text).expect("fixture playlist parses");
}
Some(Action::FetchResource { id, url, .. }) => {
let name = url.rsplit('/').next().expect("url has a path segment");
let bytes = read_fixture(name);
client
.on_resource(id, &bytes)
.unwrap_or_else(|e| panic!("demux fixture resource {name}: {e}"));
}
Some(Action::WaitMs(_)) => {}
Some(other) => panic!("unexpected action for this VOD fixture: {other:?}"),
None => break,
}
while let Some(output) = client.next_output() {
outputs.push(output);
}
}
outputs
}
fn oracle_track_totals(segment_names: &[String]) -> BTreeMap<u32, usize> {
let mut totals = BTreeMap::new();
for name in segment_names {
let bytes = read_fixture(name);
let media = TsDemux::new()
.demux(&bytes)
.unwrap_or_else(|e| panic!("oracle demux of {name} failed: {e}"));
for track in media.tracks {
*totals.entry(track.spec.track_id).or_insert(0) += track.samples.len();
}
}
totals
}
#[test]
fn fixture_is_genuinely_classic_ts_segment_hls() {
let playlist_text = read_fixture("index.m3u8");
let playlist_text = String::from_utf8(playlist_text).expect("playlist is UTF-8");
assert!(
!playlist_text.contains("EXT-X-MAP"),
"fixture must carry NO EXT-X-MAP (classic TS-segment HLS has no init resource):\n{playlist_text}"
);
let names = segment_names_from_playlist(&playlist_text);
assert!(
names.len() >= 2,
"expect at least two segments from the ffmpeg -hls_time 2 generation: {names:?}"
);
for name in &names {
let bytes = read_fixture(name);
assert_eq!(
bytes.first().copied(),
Some(TS_SYNC_BYTE),
"segment {name} must start with the MPEG-TS sync byte 0x47"
);
}
}
#[test]
fn client_ingests_classic_ts_segment_hls_end_to_end() {
let playlist_text = read_fixture("index.m3u8");
let playlist_text_str = String::from_utf8(playlist_text).expect("playlist is UTF-8");
let segment_names = segment_names_from_playlist(&playlist_text_str);
let mut client = HlsClient::new(PLAYLIST_URL);
let outputs = drive_to_end(&mut client);
assert!(
!outputs.is_empty(),
"the client must produce output for a real TS-HLS fixture -- empty output means the \
TS routing never fired and every segment is stuck buffered forever"
);
let init_positions: Vec<usize> = outputs
.iter()
.enumerate()
.filter(|(_, o)| matches!(o, Output::Init(_)))
.map(|(i, _)| i)
.collect();
assert_eq!(
init_positions.len(),
1,
"exactly one synthesized Output::Init expected for classic TS-HLS: {outputs:?}"
);
let first_samples_pos = outputs
.iter()
.position(|o| matches!(o, Output::Samples { .. }))
.expect("at least one Output::Samples batch expected");
assert!(
init_positions[0] < first_samples_pos,
"Output::Init must precede every Output::Samples"
);
let Output::Init(init_bytes) = &outputs[init_positions[0]] else {
unreachable!("checked above")
};
let init_media = transmux::Fmp4Demux::new()
.unpackage(init_bytes.as_slice())
.expect("the synthesized Init segment must itself be a valid, demuxable fMP4 init");
assert!(
init_media
.tracks
.iter()
.any(|t| matches!(t.spec.config, CodecConfig::Avc { .. })),
"synthesized Init must expose the fixture's AVC video track: {:?}",
init_media.tracks
);
assert!(
init_media
.tracks
.iter()
.any(|t| matches!(t.spec.config, CodecConfig::Aac { .. })),
"synthesized Init must expose the fixture's AAC audio track: {:?}",
init_media.tracks
);
assert!(
matches!(outputs.last(), Some(Output::EndOfStream)),
"a VOD (ENDLIST) playlist with nothing outstanding must end in Output::EndOfStream: \
{outputs:?}"
);
let mut got_totals: BTreeMap<u32, usize> = BTreeMap::new();
for output in &outputs {
if let Output::Samples { track_id, samples } = output {
*got_totals.entry(*track_id).or_insert(0) += samples.len();
}
}
let want_totals = oracle_track_totals(&segment_names);
assert!(
!want_totals.is_empty(),
"sanity: the oracle itself must see at least one track"
);
assert_eq!(
got_totals, want_totals,
"client-emitted per-track sample counts must match the direct TsDemux oracle exactly \
(no drops/dupes/misroutes)"
);
}