use crate::report::{Finding, Location, Report, Severity};
use alloc::vec::Vec;
pub fn check_hls_playlist(text: &str, report: &mut Report) {
check_daterange_lines(text, report);
match broadcast_hls::MediaPlaylist::parse(text) {
Ok(media) => {
validate_media_playlist(&media, report);
legacy_line_checks(text, report);
}
Err(_media_err) => {
match broadcast_hls::MasterPlaylist::parse(text) {
Ok(master) => {
validate_master_playlist(&master, report);
}
Err(_master_err) => {
report.push(Finding::new(
Severity::Error,
Location::new(1, 0),
"hls-parse-error",
"Playlist failed to parse as a valid HLS Media or Multivariant Playlist",
));
legacy_line_checks(text, report);
}
}
}
}
}
fn validate_media_playlist(pl: &broadcast_hls::MediaPlaylist, report: &mut Report) {
if pl.endlist
&& let Some(ref ll) = pl.low_latency
&& ll.preload_hint_part.is_some()
{
report.push(Finding::new(
Severity::Error,
Location::new(1, 0),
"hls-preload-hint-with-endlist",
"Playlist carries EXT-X-ENDLIST and EXT-X-PRELOAD-HINT — §4.4.5.3: a playlist with ENDLIST MUST NOT contain PRELOAD-HINT",
));
}
if pl.skip.is_some() {
let has_can_skip = pl
.low_latency
.as_ref()
.and_then(|ll| ll.can_skip_until)
.is_some();
if !has_can_skip {
report.push(Finding::new(
Severity::Error,
Location::new(1, 0),
"hls-skip-without-can-skip-until",
"EXT-X-SKIP present but EXT-X-SERVER-CONTROL has no CAN-SKIP-UNTIL — §4.4.3.8",
));
}
}
if let Some(ref ll) = pl.low_latency {
let part_target = ll.part_target;
if part_target > 0.0 {
let lower = part_target * HLS_PART_DURATION_MIN_FRACTION;
let upper = part_target;
for (seg_idx, seg) in pl.segments.iter().enumerate() {
let part_count = seg.parts.len();
for (part_idx, part) in seg.parts.iter().enumerate() {
let is_last_of_seg = part_idx == part_count.saturating_sub(1);
let next_is_gap = seg.parts.get(part_idx + 1).is_some_and(|p| p.gap);
if !part.independent
&& !part.gap
&& !is_last_of_seg
&& !next_is_gap
&& (part.duration < lower || part.duration > upper)
{
report.push(Finding::new(
Severity::Error,
Location::new(seg_idx + 1, 0),
"hls-part-duration-range",
alloc::format!(
"Partial segment {part_idx} of segment {seg_idx} has duration {} — must be in [{lower:.3}, {upper:.3}] per §4.4.4.9",
part.duration,
),
));
}
}
}
}
}
}
fn validate_master_playlist(_pl: &broadcast_hls::MasterPlaylist, _report: &mut Report) {
}
fn check_daterange_lines(text: &str, report: &mut Report) {
for (i, line) in text.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("#EXT-X-DATERANGE:")
&& timed_metadata::DateRange::parse_tag_line(trimmed).is_err()
{
report.push(Finding::new(
Severity::Error,
Location::new(i + 1, 0),
"hls-malformed-daterange",
"Malformed #EXT-X-DATERANGE line — §4.4.5.1",
));
}
}
}
fn legacy_line_checks(text: &str, report: &mut Report) {
let lines: Vec<&str> = text.lines().collect();
let first_non_empty = lines.iter().find(|l| !l.trim().is_empty());
match first_non_empty {
Some(line) if line.trim() == "#EXTM3U" => { }
_ => {
report.push(Finding::new(
Severity::Error,
Location::new(1, 0),
"hls-missing-extm3u",
"First non-empty line must be exactly '#EXTM3U' — §4.4.1.1",
));
}
}
let mut has_targetduration = false;
let mut targetduration_val: u64 = 0;
let mut extinf_line_nums: Vec<usize> = Vec::new();
let mut extinf_durations: Vec<f64> = Vec::new();
for (i, line) in lines.iter().enumerate() {
let line_num = i + 1;
let trimmed = line.trim();
if trimmed.starts_with("#EXT-X-TARGETDURATION:") {
has_targetduration = true;
if let Some(val_str) = trimmed.strip_prefix("#EXT-X-TARGETDURATION:") {
targetduration_val = val_str.trim().parse::<u64>().unwrap_or(0);
}
}
if trimmed.starts_with("#EXTINF:") {
extinf_line_nums.push(line_num);
if let Some(dur_str) = trimmed.strip_prefix("#EXTINF:") {
let dur = dur_str.split(',').next().unwrap_or("0");
let parsed: f64 = dur.trim().parse().unwrap_or(0.0);
extinf_durations.push(parsed);
} else {
extinf_durations.push(0.0);
}
}
}
if !extinf_line_nums.is_empty() && !has_targetduration {
report.push(Finding::new(
Severity::Error,
Location::new(1, 0),
"hls-missing-targetduration",
"Media playlist with EXTINF entries must include #EXT-X-TARGETDURATION — §4.4.3.1",
));
}
if has_targetduration {
for (idx, &dur) in extinf_durations.iter().enumerate() {
let rounded = (dur + 0.5) as u64;
if rounded > targetduration_val {
report.push(Finding::new(
Severity::Error,
Location::new(extinf_line_nums[idx], 0),
"hls-extinf-exceeds-target",
alloc::format!(
"EXTINF duration {dur} (rounded to {rounded}) exceeds TARGETDURATION {targetduration_val} — §4.4.3.1",
),
));
}
}
}
}
const HLS_PART_DURATION_MIN_FRACTION: f64 = 0.85;