use alloc::collections::btree_map::{BTreeMap, Entry};
use broadcast_common::Parse;
use crate::Diagnostic;
use crate::Report;
use crate::report::{Finding, Location, Severity};
use mpeg_ts::ts::SectionReassembler;
use mpeg_ts::ts::{TS_PACKET_SIZE, TsPacket};
const SCTE35_TABLE_ID: u8 = 0xFC;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SpliceInsertState {
Open,
Closed,
}
#[derive(Default)]
struct Scte35PidState {
reassembler: SectionReassembler,
events: BTreeMap<u32, SpliceInsertState>,
}
#[derive(Debug, Clone, Copy)]
pub struct Scte35Check;
const SCTE35_PID: u16 = 0x01F0;
impl Diagnostic for Scte35Check {
fn run(&self, ts: &[u8], report: &mut Report) {
let n_packets = ts.len() / TS_PACKET_SIZE;
let mut pid_states: BTreeMap<u16, Scte35PidState> = BTreeMap::new();
for i in 0..n_packets {
let offset = i * TS_PACKET_SIZE;
let raw = &ts[offset..offset + TS_PACKET_SIZE];
let Ok(pkt) = TsPacket::parse(raw) else {
continue;
};
let pid = pkt.header.pid;
if pid != SCTE35_PID {
continue;
}
let payload = match pkt.payload {
Some(pl) => pl,
None => continue,
};
let pusi = pkt.header.pusi;
let state = pid_states.entry(pid).or_default();
state.reassembler.feed(payload, pusi);
while let Some(section) = state.reassembler.pop_section() {
let section_data = §ion[..];
if section_data.is_empty() || section_data[0] != SCTE35_TABLE_ID {
continue;
}
let Ok(sis) = scte35_splice::SpliceInfoSection::parse(section_data) else {
continue;
};
let Some(ref clear) = sis.clear else {
continue;
};
let command = &clear.command;
let scte35_splice::commands::AnyCommand::SpliceInsert(si) = command else {
continue;
};
if si.splice_event_cancel_indicator {
continue;
}
let eid = si.splice_event_id;
let oon = si.out_of_network_indicator;
match state.events.entry(eid) {
Entry::Vacant(entry) => {
if oon {
entry.insert(SpliceInsertState::Open);
} else {
entry.insert(SpliceInsertState::Closed);
}
}
Entry::Occupied(mut entry) => {
match *entry.get() {
SpliceInsertState::Open => {
if oon {
report.push(Finding::new(
Severity::Warning,
Location::new(i, pid),
"scte35-dup-out",
alloc::format!(
"duplicate open splice_insert: out event_id {eid} \
with no intervening in",
),
));
} else {
entry.insert(SpliceInsertState::Closed);
}
}
SpliceInsertState::Closed => {
if oon {
entry.insert(SpliceInsertState::Open);
}
}
}
}
}
}
}
for (&pid, state) in pid_states.iter() {
for (&eid, &status) in state.events.iter() {
if status == SpliceInsertState::Open {
report.push(Finding::new(
Severity::Warning,
Location::new(n_packets.saturating_sub(1), pid),
"scte35-unbalanced",
alloc::format!(
"unbalanced splice_insert: out event_id {eid} with no matching in",
),
));
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::Report;
fn make_packet(payload: &[u8], pid: u16, cc: u8) -> Vec<u8> {
let mut pkt = vec![0x47u8; 188];
pkt[1] = 0x40 | (((pid >> 8) as u8) & 0x1F); pkt[2] = (pid & 0xFF) as u8;
pkt[3] = 0x10 | (cc & 0x0F); let write_end = 4 + payload.len().min(184);
pkt[4..write_end].copy_from_slice(payload);
pkt
}
fn make_splice_insert_section(event_id: u32, out_of_network: bool, cancel: bool) -> Vec<u8> {
use broadcast_common::Serialize;
use scte35_splice::SpliceInfoSection;
use scte35_splice::commands::SpliceInsert;
let si = SpliceInsert {
splice_event_id: event_id,
splice_event_cancel_indicator: cancel,
out_of_network_indicator: out_of_network,
program_splice_flag: true,
splice_immediate_flag: true,
..SpliceInsert::default()
};
let command = scte35_splice::commands::AnyCommand::SpliceInsert(si);
let sis = SpliceInfoSection::new_clear(command, &[]);
let mut buf = vec![0u8; sis.serialized_len()];
sis.serialize_into(&mut buf).unwrap();
buf
}
#[test]
fn empty_pid_no_findings() {
let mut ts = Vec::new();
for _ in 0..3 {
let mut pkt = vec![0x47u8; 188];
pkt[1] = 0x01;
pkt[2] = 0xF0; pkt[3] = 0x10; ts.extend_from_slice(&pkt);
}
let mut report = Report::new();
Scte35Check.run(&ts, &mut report);
assert!(
report.is_empty(),
"expected no findings, got {:?}",
report.findings()
);
}
#[test]
fn balanced_pair_no_findings() {
let pid = 0x01F0u16;
let out_bytes = make_splice_insert_section(100, true, false);
let in_bytes = make_splice_insert_section(100, false, false);
let mut payload = Vec::new();
payload.push(0x00); payload.extend_from_slice(&out_bytes);
payload.extend_from_slice(&in_bytes);
let ts = make_packet(&payload, pid, 0);
let mut report = Report::new();
Scte35Check.run(&ts, &mut report);
assert!(
report.is_empty(),
"balanced pair should have no findings, got {:?}",
report.findings()
);
}
#[test]
fn unbalanced_out_produces_finding() {
let pid = 0x01F0u16;
let out_bytes = make_splice_insert_section(42, true, false);
let mut payload = Vec::new();
payload.push(0x00);
payload.extend_from_slice(&out_bytes);
let ts = make_packet(&payload, pid, 0);
let mut report = Report::new();
Scte35Check.run(&ts, &mut report);
let unbal: Vec<_> = report
.findings()
.iter()
.filter(|f| f.rule_id == "scte35-unbalanced")
.collect();
assert_eq!(
unbal.len(),
1,
"expected 1 unbalanced finding for event_id 42, got {:?}",
report.findings()
);
assert!(
unbal[0].message.contains("42"),
"message should reference event_id 42: {}",
unbal[0].message
);
}
#[test]
fn duplicate_out_produces_finding() {
let pid = 0x01F0u16;
let out1 = make_splice_insert_section(7, true, false);
let out2 = make_splice_insert_section(7, true, false);
let mut payload = Vec::new();
payload.push(0x00);
payload.extend_from_slice(&out1);
payload.extend_from_slice(&out2);
let ts = make_packet(&payload, pid, 0);
let mut report = Report::new();
Scte35Check.run(&ts, &mut report);
let dup: Vec<_> = report
.findings()
.iter()
.filter(|f| f.rule_id == "scte35-dup-out")
.collect();
assert_eq!(
dup.len(),
1,
"expected 1 duplicate-out finding for event_id 7, got {:?}",
report.findings()
);
assert!(
dup[0].message.contains("7"),
"message should reference event_id 7: {}",
dup[0].message
);
}
#[test]
fn cancelled_event_ignored() {
let pid = 0x01F0u16;
let cancel = make_splice_insert_section(99, true, true);
let mut payload = Vec::new();
payload.push(0x00);
payload.extend_from_slice(&cancel);
let ts = make_packet(&payload, pid, 0);
let mut report = Report::new();
Scte35Check.run(&ts, &mut report);
assert!(
report.is_empty(),
"cancelled event should produce no findings, got {:?}",
report.findings()
);
}
}