///|
pub(all) enum LowLatencyHintType {
Part
Map
} derive(Eq, @debug.Debug)
///|
pub(all) struct PartInfo {
target_duration_ms : Int
line : Int
} derive(Eq, @debug.Debug)
///|
pub(all) struct ServerControl {
can_skip_until_ms : Int?
can_block_reload : Bool
hold_back_ms : Int?
part_hold_back_ms : Int?
can_skip_dateranges : Bool
line : Int
} derive(Eq, @debug.Debug)
///|
pub(all) struct PartialSegment {
uri : String
duration_ms : Int
independent : Bool
gap : Bool
byte_range : ByteRange?
line : Int
} derive(Eq, @debug.Debug)
///|
pub(all) struct PreloadHint {
hint_type : LowLatencyHintType
uri : String
byte_range_start : Int?
byte_range_length : Int?
line : Int
} derive(Eq, @debug.Debug)
///|
pub(all) struct RenditionReport {
uri : String
last_msn : Int?
last_part : Int?
line : Int
} derive(Eq, @debug.Debug)
///|
pub(all) struct SkipInfo {
skipped_segments : Int
recently_removed_dateranges : String?
line : Int
} derive(Eq, @debug.Debug)
///|
pub(all) struct LowLatencyProfile {
part_info : PartInfo?
server_control : ServerControl?
parts : Array[PartialSegment]
preload_hints : Array[PreloadHint]
rendition_reports : Array[RenditionReport]
skip : SkipInfo?
unknown_low_latency_tags : Array[String]
} derive(Eq, @debug.Debug)
///|
pub fn parse_low_latency_profile(
input : String,
) -> Result[LowLatencyProfile, ParseError] {
let parts : Array[PartialSegment] = []
let preload_hints : Array[PreloadHint] = []
let rendition_reports : Array[RenditionReport] = []
let unknown_low_latency_tags : Array[String] = []
let mut part_info : PartInfo? = None
let mut server_control : ServerControl? = None
let mut skip : SkipInfo? = None
let lines = split_char(normalize_newlines(input), '\n')
let mut seen_header = false
for index, raw_line in lines {
let line = index + 1
let text = clean(raw_line[:])
if text == "" {
continue
}
if text == "#EXTM3U" {
seen_header = true
continue
}
if !seen_header {
return Err(MissingHeader(line))
}
if text.has_prefix("#EXT-X-PART-INF:") {
match parse_part_info(after_llhls_colon(text), line) {
Ok(value) => part_info = Some(value)
Err(err) => return Err(err)
}
} else if text.has_prefix("#EXT-X-SERVER-CONTROL:") {
match parse_server_control(after_llhls_colon(text), line) {
Ok(value) => server_control = Some(value)
Err(err) => return Err(err)
}
} else if text.has_prefix("#EXT-X-PART:") {
match parse_partial_segment(after_llhls_colon(text), line) {
Ok(value) => parts.push(value)
Err(err) => return Err(err)
}
} else if text.has_prefix("#EXT-X-PRELOAD-HINT:") {
match parse_preload_hint(after_llhls_colon(text), line) {
Ok(value) => preload_hints.push(value)
Err(err) => return Err(err)
}
} else if text.has_prefix("#EXT-X-RENDITION-REPORT:") {
match parse_rendition_report(after_llhls_colon(text), line) {
Ok(value) => rendition_reports.push(value)
Err(err) => return Err(err)
}
} else if text.has_prefix("#EXT-X-SKIP:") {
match parse_skip_info(after_llhls_colon(text), line) {
Ok(value) => skip = Some(value)
Err(err) => return Err(err)
}
} else if text.has_prefix("#EXT-X-PART") ||
text.has_prefix("#EXT-X-SERVER-CONTROL") ||
text.has_prefix("#EXT-X-PRELOAD-HINT") ||
text.has_prefix("#EXT-X-RENDITION-REPORT") ||
text.has_prefix("#EXT-X-SKIP") {
push_unknown(unknown_low_latency_tags, line, text)
}
}
Ok({
part_info,
server_control,
parts,
preload_hints,
rendition_reports,
skip,
unknown_low_latency_tags,
})
}
///|
pub fn validate_low_latency_profile(
playlist : HlsPlaylist,
profile : LowLatencyProfile,
) -> ValidationReport {
let issues : Array[ValidationIssue] = []
validate_part_info(profile, issues)
validate_server_control(playlist, profile, issues)
validate_parts(profile, issues)
validate_preload_hints(profile, issues)
validate_rendition_reports(profile, issues)
validate_skip(profile, issues)
report_from_issues(issues)
}
///|
pub fn summarize_low_latency_profile(profile : LowLatencyProfile) -> String {
let part_target = match profile.part_info {
Some(info) => millis_to_string(info.target_duration_ms)
None => "-"
}
let can_block = match profile.server_control {
Some(control) => control.can_block_reload.to_string()
None => "false"
}
let skip_segments = match profile.skip {
Some(info) => info.skipped_segments.to_string()
None => "0"
}
"llhls=parts=\{profile.parts.length()}, part_target=\{part_target}, preload=\{profile.preload_hints.length()}, reports=\{profile.rendition_reports.length()}, block_reload=\{can_block}, skip=\{skip_segments}"
}
///|
pub fn part_duration_total(profile : LowLatencyProfile) -> Int {
let mut total = 0
for part in profile.parts {
total += part.duration_ms
}
total
}
///|
pub fn independent_part_count(profile : LowLatencyProfile) -> Int {
let mut count = 0
for part in profile.parts {
if part.independent {
count += 1
}
}
count
}
///|
pub fn gap_part_count(profile : LowLatencyProfile) -> Int {
let mut count = 0
for part in profile.parts {
if part.gap {
count += 1
}
}
count
}
///|
pub fn low_latency_uri_audit(profile : LowLatencyProfile) -> UriAudit {
let entries : Array[UriEntry] = []
for part in profile.parts {
entries.push({ role: SegmentUri, uri: part.uri, line: part.line })
}
for hint in profile.preload_hints {
entries.push({ role: SegmentUri, uri: hint.uri, line: hint.line })
}
for report in profile.rendition_reports {
entries.push({ role: VariantUri, uri: report.uri, line: report.line })
}
audit_uri_entries(entries)
}
///|
fn parse_part_info(raw : String, line : Int) -> Result[PartInfo, ParseError] {
match parse_attribute_list(raw, line~) {
Ok(attrs) =>
match required_attr(attrs, "PART-TARGET", line) {
Ok(value) =>
match parse_millis(value, line, "PART-TARGET") {
Ok(target_duration_ms) => Ok({ target_duration_ms, line })
Err(err) => Err(err)
}
Err(err) => Err(err)
}
Err(err) => Err(err)
}
}
///|
fn parse_server_control(
raw : String,
line : Int,
) -> Result[ServerControl, ParseError] {
match parse_attribute_list(raw, line~) {
Ok(attrs) => {
let can_skip_until_ms = match maybe_attr(attrs, "CAN-SKIP-UNTIL") {
Some(value) =>
match parse_millis(value, line, "CAN-SKIP-UNTIL") {
Ok(parsed) => Some(parsed)
Err(err) => return Err(err)
}
None => None
}
let hold_back_ms = match maybe_attr(attrs, "HOLD-BACK") {
Some(value) =>
match parse_millis(value, line, "HOLD-BACK") {
Ok(parsed) => Some(parsed)
Err(err) => return Err(err)
}
None => None
}
let part_hold_back_ms = match maybe_attr(attrs, "PART-HOLD-BACK") {
Some(value) =>
match parse_millis(value, line, "PART-HOLD-BACK") {
Ok(parsed) => Some(parsed)
Err(err) => return Err(err)
}
None => None
}
Ok({
can_skip_until_ms,
can_block_reload: attr_yes_no(attrs, "CAN-BLOCK-RELOAD"),
hold_back_ms,
part_hold_back_ms,
can_skip_dateranges: attr_yes_no(attrs, "CAN-SKIP-DATERANGES"),
line,
})
}
Err(err) => Err(err)
}
}
///|
fn parse_partial_segment(
raw : String,
line : Int,
) -> Result[PartialSegment, ParseError] {
match parse_attribute_list(raw, line~) {
Ok(attrs) => {
let uri = match required_attr(attrs, "URI", line) {
Ok(value) => value
Err(err) => return Err(err)
}
let duration_ms = match required_attr(attrs, "DURATION", line) {
Ok(value) =>
match parse_millis(value, line, "DURATION") {
Ok(parsed) => parsed
Err(err) => return Err(err)
}
Err(err) => return Err(err)
}
let byte_range = match maybe_attr(attrs, "BYTERANGE") {
Some(value) =>
match parse_byte_range(value, line) {
Ok(parsed) => Some(parsed)
Err(err) => return Err(err)
}
None => None
}
Ok({
uri,
duration_ms,
independent: attr_yes_no(attrs, "INDEPENDENT"),
gap: attr_yes_no(attrs, "GAP"),
byte_range,
line,
})
}
Err(err) => Err(err)
}
}
///|
fn parse_preload_hint(
raw : String,
line : Int,
) -> Result[PreloadHint, ParseError] {
match parse_attribute_list(raw, line~) {
Ok(attrs) => {
let hint_type = match required_attr(attrs, "TYPE", line) {
Ok(value) =>
match value {
"PART" => Part
"MAP" => Map
_ =>
return Err(
InvalidAttribute(line, "unsupported PRELOAD-HINT TYPE \{value}"),
)
}
Err(err) => return Err(err)
}
let uri = match required_attr(attrs, "URI", line) {
Ok(value) => value
Err(err) => return Err(err)
}
let byte_range_start = match maybe_attr(attrs, "BYTERANGE-START") {
Some(value) =>
match parse_unsigned_int(value, line, "BYTERANGE-START") {
Ok(parsed) => Some(parsed)
Err(err) => return Err(err)
}
None => None
}
let byte_range_length = match maybe_attr(attrs, "BYTERANGE-LENGTH") {
Some(value) =>
match parse_unsigned_int(value, line, "BYTERANGE-LENGTH") {
Ok(parsed) => Some(parsed)
Err(err) => return Err(err)
}
None => None
}
Ok({ hint_type, uri, byte_range_start, byte_range_length, line })
}
Err(err) => Err(err)
}
}
///|
fn parse_rendition_report(
raw : String,
line : Int,
) -> Result[RenditionReport, ParseError] {
match parse_attribute_list(raw, line~) {
Ok(attrs) => {
let uri = match required_attr(attrs, "URI", line) {
Ok(value) => value
Err(err) => return Err(err)
}
let last_msn = match maybe_attr(attrs, "LAST-MSN") {
Some(value) =>
match parse_unsigned_int(value, line, "LAST-MSN") {
Ok(parsed) => Some(parsed)
Err(err) => return Err(err)
}
None => None
}
let last_part = match maybe_attr(attrs, "LAST-PART") {
Some(value) =>
match parse_unsigned_int(value, line, "LAST-PART") {
Ok(parsed) => Some(parsed)
Err(err) => return Err(err)
}
None => None
}
Ok({ uri, last_msn, last_part, line })
}
Err(err) => Err(err)
}
}
///|
fn parse_skip_info(raw : String, line : Int) -> Result[SkipInfo, ParseError] {
match parse_attribute_list(raw, line~) {
Ok(attrs) => {
let skipped_segments = match
required_attr(attrs, "SKIPPED-SEGMENTS", line) {
Ok(value) =>
match parse_unsigned_int(value, line, "SKIPPED-SEGMENTS") {
Ok(parsed) => parsed
Err(err) => return Err(err)
}
Err(err) => return Err(err)
}
Ok({
skipped_segments,
recently_removed_dateranges: maybe_attr(
attrs, "RECENTLY-REMOVED-DATERANGES",
),
line,
})
}
Err(err) => Err(err)
}
}
///|
fn validate_part_info(
profile : LowLatencyProfile,
issues : Array[ValidationIssue],
) -> Unit {
if profile.parts.length() > 0 && profile.part_info == None {
issues.push(
ValidationIssue::warning(
"llhls-part-info-missing", "EXT-X-PART entries are present but EXT-X-PART-INF is missing",
),
)
}
match profile.part_info {
Some(info) =>
if info.target_duration_ms <= 0 {
issues.push(
ValidationIssue::error(
"llhls-part-target-invalid",
"PART-TARGET must be positive",
line=info.line,
),
)
}
None => ()
}
}
///|
fn validate_server_control(
playlist : HlsPlaylist,
profile : LowLatencyProfile,
issues : Array[ValidationIssue],
) -> Unit {
match profile.server_control {
Some(control) => {
match control.hold_back_ms {
Some(value) =>
if playlist.target_duration > 0 &&
value < playlist.target_duration * 3000 {
issues.push(
ValidationIssue::warning(
"llhls-hold-back-low",
"HOLD-BACK is usually at least three target durations",
line=control.line,
),
)
}
None => ()
}
match control.part_hold_back_ms {
Some(value) =>
match profile.part_info {
Some(info) =>
if value < info.target_duration_ms * 2 {
issues.push(
ValidationIssue::warning(
"llhls-part-hold-back-low",
"PART-HOLD-BACK is usually at least two part target durations",
line=control.line,
),
)
}
None => ()
}
None => ()
}
match control.can_skip_until_ms {
Some(value) =>
if value <= 0 {
issues.push(
ValidationIssue::error(
"llhls-skip-window-invalid",
"CAN-SKIP-UNTIL must be positive when present",
line=control.line,
),
)
}
None => ()
}
}
None =>
if profile.preload_hints.length() > 0 || profile.skip != None {
issues.push(
ValidationIssue::info(
"llhls-server-control-missing", "low latency hints are present without EXT-X-SERVER-CONTROL",
),
)
}
}
}
///|
fn validate_parts(
profile : LowLatencyProfile,
issues : Array[ValidationIssue],
) -> Unit {
for part in profile.parts {
if part.uri == "" {
issues.push(
ValidationIssue::error(
"llhls-part-uri-empty",
"partial segment URI must not be empty",
line=part.line,
),
)
}
if part.duration_ms <= 0 {
issues.push(
ValidationIssue::error(
"llhls-part-duration-invalid",
"partial segment duration must be positive",
line=part.line,
),
)
}
match part.byte_range {
Some(range) => validate_byte_range(range, part.line, issues)
None => ()
}
match profile.part_info {
Some(info) =>
if info.target_duration_ms > 0 &&
part.duration_ms > info.target_duration_ms {
issues.push(
ValidationIssue::warning(
"llhls-part-exceeds-target",
"partial segment duration exceeds PART-TARGET",
line=part.line,
),
)
}
None => ()
}
}
}
///|
fn validate_preload_hints(
profile : LowLatencyProfile,
issues : Array[ValidationIssue],
) -> Unit {
for hint in profile.preload_hints {
if hint.uri == "" {
issues.push(
ValidationIssue::error(
"llhls-preload-uri-empty",
"preload hint URI must not be empty",
line=hint.line,
),
)
}
match hint.byte_range_length {
Some(value) =>
if value <= 0 {
issues.push(
ValidationIssue::error(
"llhls-preload-range-invalid",
"preload hint byte range length must be positive",
line=hint.line,
),
)
}
None => ()
}
}
}
///|
fn validate_rendition_reports(
profile : LowLatencyProfile,
issues : Array[ValidationIssue],
) -> Unit {
for report in profile.rendition_reports {
if report.uri == "" {
issues.push(
ValidationIssue::error(
"llhls-rendition-report-uri-empty",
"rendition report URI must not be empty",
line=report.line,
),
)
}
match report.last_part {
Some(value) =>
if value < 0 {
issues.push(
ValidationIssue::error(
"llhls-rendition-last-part-invalid",
"LAST-PART must be non-negative",
line=report.line,
),
)
}
None => ()
}
}
}
///|
fn validate_skip(
profile : LowLatencyProfile,
issues : Array[ValidationIssue],
) -> Unit {
match profile.skip {
Some(skip) =>
if skip.skipped_segments <= 0 {
issues.push(
ValidationIssue::error(
"llhls-skip-invalid",
"SKIPPED-SEGMENTS must be positive",
line=skip.line,
),
)
}
None => ()
}
}
///|
fn attr_yes_no(attrs : Array[HlsAttribute], name : String) -> Bool {
match maybe_attr(attrs, name) {
Some(value) => parse_yes_no(value)
None => false
}
}
///|
fn after_llhls_colon(text : String) -> String {
match text.split_once(":") {
Some((_, value)) => value.to_owned()
None => ""
}
}