///|
pub fn validate_playlist(playlist : HlsPlaylist) -> ValidationReport {
let issues : Array[ValidationIssue] = []
validate_shape(playlist, issues)
validate_media_playlist(playlist, issues)
validate_master_playlist(playlist, issues)
validate_uri_audit(playlist, issues)
report_from_issues(issues)
}
///|
fn validate_shape(playlist : HlsPlaylist, issues : Array[ValidationIssue]) -> Unit {
if playlist.kind == Empty {
issues.push(
ValidationIssue::error(
"playlist-empty",
"playlist has no media segments, variants, or renditions",
),
)
}
if playlist.kind == Hybrid {
issues.push(
ValidationIssue::warning(
"playlist-hybrid",
"playlist mixes media segments with master playlist entries",
),
)
}
if playlist.version < 0 {
issues.push(
ValidationIssue::error(
"version-invalid",
"EXT-X-VERSION must be non-negative",
),
)
}
}
///|
fn validate_media_playlist(
playlist : HlsPlaylist,
issues : Array[ValidationIssue],
) -> Unit {
if playlist.segments.length() == 0 {
return
}
if playlist.target_duration <= 0 {
issues.push(
ValidationIssue::error(
"target-duration-missing",
"media playlists should declare EXT-X-TARGETDURATION",
),
)
}
let mut max_duration = 0
let mut encrypted = 0
let mut last_uri = ""
for segment in playlist.segments {
if segment.uri == "" {
issues.push(
ValidationIssue::error(
"segment-uri-empty",
"segment URI is empty",
line=segment.line,
),
)
}
if segment.uri == last_uri {
issues.push(
ValidationIssue::warning(
"segment-uri-duplicate-adjacent",
"adjacent media segments use the same URI",
line=segment.line,
),
)
}
last_uri = segment.uri
if segment.duration_ms <= 0 {
issues.push(
ValidationIssue::error(
"segment-duration-invalid",
"segment duration must be positive",
line=segment.line,
),
)
}
if segment.duration_ms > max_duration {
max_duration = segment.duration_ms
}
match segment.byte_range {
Some(range) => validate_byte_range(range, segment.line, issues)
None => ()
}
match segment.key {
Some(key) => {
if key.key_method != "NONE" {
encrypted += 1
}
validate_key(key, issues)
}
None => ()
}
match segment.map {
Some(map) =>
match map.byte_range {
Some(range) => validate_byte_range(range, map.line, issues)
None => ()
}
None => ()
}
}
if playlist.target_duration > 0 &&
max_duration > playlist.target_duration * 1000 {
issues.push(
ValidationIssue::error(
"target-duration-too-small",
"largest segment duration \{millis_to_string(max_duration)} exceeds EXT-X-TARGETDURATION \{playlist.target_duration}",
),
)
}
if encrypted > 0 && playlist.version > 0 && playlist.version < 2 {
issues.push(
ValidationIssue::warning(
"version-encryption-low",
"encrypted playlists normally declare EXT-X-VERSION 2 or newer",
),
)
}
}
///|
fn validate_master_playlist(
playlist : HlsPlaylist,
issues : Array[ValidationIssue],
) -> Unit {
for variant in playlist.variants {
if variant.uri == "" {
issues.push(
ValidationIssue::error(
"variant-uri-empty",
"variant URI is empty",
line=variant.line,
),
)
}
if variant.bandwidth <= 0 {
issues.push(
ValidationIssue::error(
"variant-bandwidth-invalid",
"variant BANDWIDTH must be positive",
line=variant.line,
),
)
}
match variant.average_bandwidth {
Some(value) =>
if value <= 0 {
issues.push(
ValidationIssue::error(
"variant-average-bandwidth-invalid",
"AVERAGE-BANDWIDTH must be positive",
line=variant.line,
),
)
}
None => ()
}
match variant.resolution {
Some(resolution) =>
if resolution.width <= 0 || resolution.height <= 0 {
issues.push(
ValidationIssue::error(
"variant-resolution-invalid",
"RESOLUTION width and height must be positive",
line=variant.line,
),
)
}
None => ()
}
match variant.audio_group {
Some(group_id) =>
if !rendition_group_exists(playlist.renditions, "AUDIO", group_id) {
issues.push(
ValidationIssue::warning(
"variant-audio-group-missing",
"variant references an AUDIO group with no matching EXT-X-MEDIA",
line=variant.line,
),
)
}
None => ()
}
match variant.subtitles_group {
Some(group_id) =>
if !rendition_group_exists(playlist.renditions, "SUBTITLES", group_id) {
issues.push(
ValidationIssue::warning(
"variant-subtitles-group-missing",
"variant references a SUBTITLES group with no matching EXT-X-MEDIA",
line=variant.line,
),
)
}
None => ()
}
}
for rendition in playlist.renditions {
if rendition.rendition_type == "" ||
rendition.group_id == "" ||
rendition.name == "" {
issues.push(
ValidationIssue::error(
"rendition-required-empty",
"rendition TYPE, GROUP-ID, and NAME must be present",
line=rendition.line,
),
)
}
if rendition.forced && rendition.rendition_type != "SUBTITLES" {
issues.push(
ValidationIssue::warning(
"rendition-forced-non-subtitles",
"FORCED is normally meaningful for subtitle renditions",
line=rendition.line,
),
)
}
}
}
///|
fn rendition_group_exists(
renditions : Array[Rendition],
rendition_type : String,
group_id : String,
) -> Bool {
for rendition in renditions {
if rendition.rendition_type == rendition_type && rendition.group_id == group_id {
return true
}
}
false
}
///|
fn validate_uri_audit(playlist : HlsPlaylist, issues : Array[ValidationIssue]) -> Unit {
let audit = audit_uris(playlist)
if audit.empty_uris > 0 {
issues.push(
ValidationIssue::error(
"uri-empty",
"playlist contains one or more empty URIs",
),
)
}
if audit.parent_traversal_uris > 0 {
issues.push(
ValidationIssue::warning(
"uri-parent-traversal",
"playlist contains URIs with parent directory traversal",
),
)
}
}
///|
fn validate_key(key : KeyInfo, issues : Array[ValidationIssue]) -> Unit {
if key.key_method == "" {
issues.push(
ValidationIssue::error(
"key-method-empty",
"EXT-X-KEY METHOD is empty",
line=key.line,
),
)
}
if key.key_method != "NONE" && key.uri == None {
issues.push(
ValidationIssue::error(
"key-uri-missing",
"EXT-X-KEY with a non-NONE METHOD should include URI",
line=key.line,
),
)
}
}
///|
fn validate_byte_range(
range : ByteRange,
line : Int,
issues : Array[ValidationIssue],
) -> Unit {
if range.length <= 0 {
issues.push(
ValidationIssue::error(
"byte-range-length-invalid",
"byte range length must be positive",
line~,
),
)
}
match range.offset {
Some(offset) =>
if offset < 0 {
issues.push(
ValidationIssue::error(
"byte-range-offset-invalid",
"byte range offset must be non-negative",
line~,
),
)
}
None => ()
}
}
///|
fn report_from_issues(issues : Array[ValidationIssue]) -> ValidationReport {
let mut errors = 0
let mut warnings = 0
let mut infos = 0
for issue in issues {
match issue.severity {
Error => errors += 1
Warning => warnings += 1
Info => infos += 1
}
}
{ ok: errors == 0, errors, warnings, infos, issues }
}