389 lines
16 KiB
Rust
389 lines
16 KiB
Rust
//! HLS media playlist text (RFC 8216, and the second edition's partial segments): a
|
|
//! pure function of the ordered segments and the rendering parameters, so anyone
|
|
//! holding a playlist's bytes recomputes them, and a receipt over the bytes
|
|
//! (`wai.video.manifest`, http-delivery §8.6) binds every line.
|
|
//!
|
|
//! [`render_hls`] is the original rendering of `wai.video.manifest`: version 6, media
|
|
//! sequence 0, a derived target duration, a discontinuity before each ad and
|
|
//! `EXT-X-ENDLIST`. [`render_hls_with`] adds what an on-demand, live or low-latency WAI
|
|
//! playlist needs (http-delivery §10.4, §11): a media sequence and discontinuity
|
|
//! sequence, a fixed target duration, partial segments and their server control,
|
|
//! discontinuities and program date-times, `#WAI-LAYER` lines and a preload hint. With
|
|
//! [`HlsRender::legacy`] and no extras it gives [`render_hls`]'s bytes exactly.
|
|
//!
|
|
//! Pure text, no dependency: `http_binding` renders WAI playlists with it, and
|
|
//! `wai_video_manifest` seals them.
|
|
|
|
/// Whether a segment carries programme content or a stitched ad.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum SegmentKind {
|
|
Content,
|
|
Ad,
|
|
}
|
|
|
|
impl SegmentKind {
|
|
/// `content` or `ad`.
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
SegmentKind::Content => "content",
|
|
SegmentKind::Ad => "ad",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One entry in the ordered manifest composition.
|
|
///
|
|
/// `ad_id.is_some()` iff `kind == Ad`; a manifest receipt's `verify` rejects any other
|
|
/// combination, so the monetisation audit (the ad-bearing segments) is a single source
|
|
/// of truth rather than a second list that could disagree.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct Segment {
|
|
pub index: u64,
|
|
pub kind: SegmentKind,
|
|
/// The segment URI as it appears in the manifest (content or ad).
|
|
pub uri: String,
|
|
pub duration_ms: u64,
|
|
/// Present exactly for ad segments — the ad decision this break resolved to.
|
|
pub ad_id: Option<String>,
|
|
}
|
|
|
|
/// `EXT-X-PLAYLIST-TYPE`.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum PlaylistType {
|
|
Vod,
|
|
Event,
|
|
}
|
|
|
|
impl PlaylistType {
|
|
/// `VOD` or `EVENT`.
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
PlaylistType::Vod => "VOD",
|
|
PlaylistType::Event => "EVENT",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The playlist-wide rendering parameters (the header lines and the end).
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct HlsRender {
|
|
/// `EXT-X-VERSION`.
|
|
pub version: u8,
|
|
/// `EXT-X-MEDIA-SEQUENCE`.
|
|
pub media_sequence: u64,
|
|
/// `EXT-X-DISCONTINUITY-SEQUENCE`, written only when it is not 0.
|
|
pub discontinuity_sequence: u64,
|
|
/// `EXT-X-TARGETDURATION` in seconds; `None` derives it as the longest segment's
|
|
/// duration rounded up. A live playlist fixes it: it must not change between
|
|
/// revisions.
|
|
pub target_duration_s: Option<u64>,
|
|
/// `EXT-X-PART-INF:PART-TARGET`, in milliseconds.
|
|
pub part_target_ms: Option<u64>,
|
|
/// `EXT-X-SERVER-CONTROL:PART-HOLD-BACK`, in milliseconds: required with a part
|
|
/// target, and at least twice it.
|
|
pub part_hold_back_ms: Option<u64>,
|
|
/// `EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES`.
|
|
pub can_block_reload: bool,
|
|
/// `EXT-X-ENDLIST`.
|
|
pub end_list: bool,
|
|
/// `EXT-X-PLAYLIST-TYPE`.
|
|
pub playlist_type: Option<PlaylistType>,
|
|
/// `EXT-X-INDEPENDENT-SEGMENTS`: every segment decodes without the one before it.
|
|
pub independent_segments: bool,
|
|
/// `EXT-X-MAP:URI`, once, before the first segment: a classical playlist's
|
|
/// initialization section (a WAI playlist has none).
|
|
pub map_uri: Option<String>,
|
|
}
|
|
|
|
impl HlsRender {
|
|
/// [`render_hls`]'s parameters: version 6, media sequence 0, a derived target
|
|
/// duration, no parts, and the end of the list.
|
|
pub fn legacy() -> Self {
|
|
HlsRender {
|
|
version: 6,
|
|
media_sequence: 0,
|
|
discontinuity_sequence: 0,
|
|
target_duration_s: None,
|
|
part_target_ms: None,
|
|
part_hold_back_ms: None,
|
|
can_block_reload: false,
|
|
end_list: true,
|
|
playlist_type: None,
|
|
independent_segments: false,
|
|
map_uri: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A `#WAI-LAYER` line (http-delivery §10.4): the layer object a client may fetch to
|
|
/// depth `n` over the segment or part it follows. `n` is a locator only; the
|
|
/// envelope's `refines.layer` decides.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct LayerLine {
|
|
pub n: u8,
|
|
pub uri: String,
|
|
}
|
|
|
|
/// An `EXT-X-PART` line and the layer lines that follow it.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct PartLine {
|
|
pub duration_ms: u64,
|
|
pub independent: bool,
|
|
pub uri: String,
|
|
pub layers: Vec<LayerLine>,
|
|
}
|
|
|
|
/// What a rendering adds to one segment: a discontinuity, a program date-time, the
|
|
/// partial segments that precede its URI, a gap, the layer lines after its `EXTINF`, and
|
|
/// a preload hint (only on the playlist's last entry).
|
|
///
|
|
/// [`render_hls_with`] takes no extras, one per segment, or one per segment and one
|
|
/// more: the **open** segment at a live edge, whose parts are listed and whose
|
|
/// `EXTINF` and URI are not yet (it carries no layer lines of its own).
|
|
#[derive(Clone, Debug, PartialEq, Eq, Default)]
|
|
pub struct SegmentExtra {
|
|
pub discontinuity: bool,
|
|
/// `EXT-X-GAP`: the segment is absent here, and a sink takes the adjacent classical
|
|
/// segment (a class that takes the piece as floor only).
|
|
pub gap: bool,
|
|
pub program_date_time: Option<String>,
|
|
pub parts: Vec<PartLine>,
|
|
pub layers: Vec<LayerLine>,
|
|
pub preload_hint: Option<String>,
|
|
}
|
|
|
|
/// Why a rendering's inputs are refused (`playlist_malformed`, http-delivery §13): the
|
|
/// text would not read back as the lines it was given.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct RenderError(pub String);
|
|
|
|
impl RenderError {
|
|
/// `playlist_malformed`.
|
|
pub fn code(&self) -> &'static str {
|
|
"playlist_malformed"
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for RenderError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "playlist_malformed: {}", self.0)
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for RenderError {}
|
|
|
|
/// Milliseconds as a decimal number of seconds with three fractional digits, the same
|
|
/// on every platform.
|
|
pub fn seconds(ms: u64) -> String {
|
|
format!("{}.{:03}", ms / 1000, ms % 1000)
|
|
}
|
|
|
|
/// Deterministic HLS rendering: the manifest is a pure function of the ordered
|
|
/// segments, so `hash(render(segments))` is a stable identity a verifier can
|
|
/// recompute. Ad segments carry an `EXT-X-DISCONTINUITY` — the real SSAI splice
|
|
/// marker — so the stitched breaks are visible in the bytes, not only the
|
|
/// receipt. Target duration is derived (ceil of the longest segment) rather than
|
|
/// supplied, so nothing outside `segments` can move the output.
|
|
pub fn render_hls(segments: &[Segment]) -> String {
|
|
render_hls_with(segments, &HlsRender::legacy(), &[])
|
|
}
|
|
|
|
fn layer_lines(out: &mut String, layers: &[LayerLine]) {
|
|
for l in layers {
|
|
out.push_str(&format!("#WAI-LAYER:N={},URI=\"{}\"\n", l.n, l.uri));
|
|
}
|
|
}
|
|
|
|
fn part_lines(out: &mut String, parts: &[PartLine]) {
|
|
for p in parts {
|
|
out.push_str(&format!("#EXT-X-PART:DURATION={}", seconds(p.duration_ms)));
|
|
if p.independent {
|
|
out.push_str(",INDEPENDENT=YES");
|
|
}
|
|
out.push_str(&format!(",URI=\"{}\"\n", p.uri));
|
|
layer_lines(out, &p.layers);
|
|
}
|
|
}
|
|
|
|
/// The playlist `segments` render to under `r`, with `extras` (none, one per segment,
|
|
/// or one per segment and the open segment's). Each segment is written as: its
|
|
/// discontinuity (an ad's, or its extra's; one line), its program date-time, its
|
|
/// parts (each `EXT-X-PART` followed by its layer lines), its `EXT-X-GAP`, its
|
|
/// `EXTINF`, its layer lines, and its URI. The open segment follows with its
|
|
/// discontinuity, program date-time and parts; then the preload hint and
|
|
/// `EXT-X-ENDLIST`. The header is `EXTM3U`, `EXT-X-VERSION`, `EXT-X-TARGETDURATION`,
|
|
/// `EXT-X-PART-INF`, `EXT-X-SERVER-CONTROL`, `EXT-X-MEDIA-SEQUENCE`, and, those that
|
|
/// apply, `EXT-X-DISCONTINUITY-SEQUENCE` (when not 0), `EXT-X-PLAYLIST-TYPE`,
|
|
/// `EXT-X-INDEPENDENT-SEGMENTS` and `EXT-X-MAP`. Durations have three fractional digits.
|
|
///
|
|
/// Rendering never fails; [`check_render`] says whether the inputs read back as
|
|
/// themselves, and a WAI playlist is published or sealed only when they do.
|
|
pub fn render_hls_with(segments: &[Segment], r: &HlsRender, extras: &[SegmentExtra]) -> String {
|
|
let target = r.target_duration_s.unwrap_or_else(|| segments.iter().map(|s| s.duration_ms.div_ceil(1000)).max().unwrap_or(0));
|
|
let mut out = String::new();
|
|
out.push_str(&format!("#EXTM3U\n#EXT-X-VERSION:{}\n", r.version));
|
|
out.push_str(&format!("#EXT-X-TARGETDURATION:{target}\n"));
|
|
if let Some(pt) = r.part_target_ms {
|
|
out.push_str(&format!("#EXT-X-PART-INF:PART-TARGET={}\n", seconds(pt)));
|
|
}
|
|
let mut control: Vec<String> = Vec::new();
|
|
if r.can_block_reload {
|
|
control.push("CAN-BLOCK-RELOAD=YES".into());
|
|
}
|
|
if let Some(h) = r.part_hold_back_ms {
|
|
control.push(format!("PART-HOLD-BACK={}", seconds(h)));
|
|
}
|
|
if !control.is_empty() {
|
|
out.push_str(&format!("#EXT-X-SERVER-CONTROL:{}\n", control.join(",")));
|
|
}
|
|
out.push_str(&format!("#EXT-X-MEDIA-SEQUENCE:{}\n", r.media_sequence));
|
|
if r.discontinuity_sequence != 0 {
|
|
out.push_str(&format!("#EXT-X-DISCONTINUITY-SEQUENCE:{}\n", r.discontinuity_sequence));
|
|
}
|
|
if let Some(t) = r.playlist_type {
|
|
out.push_str(&format!("#EXT-X-PLAYLIST-TYPE:{}\n", t.label()));
|
|
}
|
|
if r.independent_segments {
|
|
out.push_str("#EXT-X-INDEPENDENT-SEGMENTS\n");
|
|
}
|
|
if let Some(m) = &r.map_uri {
|
|
out.push_str(&format!("#EXT-X-MAP:URI=\"{m}\"\n"));
|
|
}
|
|
let none = SegmentExtra::default();
|
|
for (i, s) in segments.iter().enumerate() {
|
|
let e = extras.get(i).unwrap_or(&none);
|
|
if s.kind == SegmentKind::Ad || e.discontinuity {
|
|
out.push_str("#EXT-X-DISCONTINUITY\n");
|
|
}
|
|
if let Some(pdt) = &e.program_date_time {
|
|
out.push_str(&format!("#EXT-X-PROGRAM-DATE-TIME:{pdt}\n"));
|
|
}
|
|
part_lines(&mut out, &e.parts);
|
|
if e.gap {
|
|
out.push_str("#EXT-X-GAP\n");
|
|
}
|
|
// Fixed 3-dp seconds so the byte output is stable across platforms.
|
|
out.push_str(&format!("#EXTINF:{},\n", seconds(s.duration_ms)));
|
|
layer_lines(&mut out, &e.layers);
|
|
out.push_str(&s.uri);
|
|
out.push('\n');
|
|
}
|
|
if let Some(open) = extras.get(segments.len()) {
|
|
if open.discontinuity {
|
|
out.push_str("#EXT-X-DISCONTINUITY\n");
|
|
}
|
|
if let Some(pdt) = &open.program_date_time {
|
|
out.push_str(&format!("#EXT-X-PROGRAM-DATE-TIME:{pdt}\n"));
|
|
}
|
|
part_lines(&mut out, &open.parts);
|
|
}
|
|
if let Some(hint) = extras.last().and_then(|e| e.preload_hint.as_ref()) {
|
|
out.push_str(&format!("#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"{hint}\"\n"));
|
|
}
|
|
if r.end_list {
|
|
out.push_str("#EXT-X-ENDLIST\n");
|
|
}
|
|
out
|
|
}
|
|
|
|
fn uri_ok(u: &str, quoted: bool) -> bool {
|
|
!u.is_empty() && !u.starts_with('#') && !u.bytes().any(|b| b == b'\r' || b == b'\n' || (quoted && b == b'"'))
|
|
}
|
|
|
|
fn check_layers(layers: &[LayerLine], at: &str) -> Result<(), RenderError> {
|
|
let mut last = 0u8;
|
|
for l in layers {
|
|
if l.n == 0 || l.n <= last {
|
|
return Err(RenderError(format!("{at}: layer numbers are 1 to 255 and strictly increasing")));
|
|
}
|
|
if !uri_ok(&l.uri, true) {
|
|
return Err(RenderError(format!("{at}: a layer URI is empty or holds a quote, CR or LF")));
|
|
}
|
|
last = l.n;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Whether `segments`, `r` and `extras` render to a playlist that reads back as them
|
|
/// (`playlist_malformed` otherwise):
|
|
///
|
|
/// - `extras` is empty, one per segment, or one more (the open segment, which carries
|
|
/// no layer lines, needs a part before a discontinuity or a date-time, and is not
|
|
/// in a playlist that ends);
|
|
/// - a preload hint is on the last extra only, and not in a playlist that ends;
|
|
/// - parts need a part target, and a part target needs a part hold-back of at least
|
|
/// twice it (the HLS second edition requires both);
|
|
/// - every URI is non-empty, does not begin with `#`, and holds no CR or LF, and a
|
|
/// quoted one (a part's, a layer's, a hint's, the map's) no `"`;
|
|
/// - the open segment has no gap, and a gap segment carries no parts or layer lines
|
|
/// (it offers no object there, so nothing refines or divides one);
|
|
/// - a program date-time is ASCII letters, digits and `:.+-` only;
|
|
/// - layer numbers are 1 to 255 and strictly increasing after each segment or part;
|
|
/// - the version is at least 1.
|
|
pub fn check_render(segments: &[Segment], r: &HlsRender, extras: &[SegmentExtra]) -> Result<(), RenderError> {
|
|
let n = segments.len();
|
|
if !(extras.is_empty() || extras.len() == n || extras.len() == n + 1) {
|
|
return Err(RenderError(format!("{} extras for {n} segments", extras.len())));
|
|
}
|
|
if r.version == 0 {
|
|
return Err(RenderError("version 0".into()));
|
|
}
|
|
if r.map_uri.as_deref().is_some_and(|m| !uri_ok(m, true)) {
|
|
return Err(RenderError("the map URI is empty or holds a quote, CR or LF".into()));
|
|
}
|
|
if let Some(pt) = r.part_target_ms {
|
|
match r.part_hold_back_ms {
|
|
Some(h) if pt > 0 && h >= pt.saturating_mul(2) => {}
|
|
_ => return Err(RenderError("a part target needs a part hold-back of at least twice it".into())),
|
|
}
|
|
}
|
|
for (i, s) in segments.iter().enumerate() {
|
|
if !uri_ok(&s.uri, false) {
|
|
return Err(RenderError(format!("segment {i}: its URI is empty, begins with # or holds CR or LF")));
|
|
}
|
|
}
|
|
for (i, e) in extras.iter().enumerate() {
|
|
let at = if i == n { "the open segment".to_string() } else { format!("segment {i}") };
|
|
if let Some(p) = &e.program_date_time {
|
|
if p.is_empty() || !p.bytes().all(|b| b.is_ascii_alphanumeric() || b":.+-".contains(&b)) {
|
|
return Err(RenderError(format!("{at}: a program date-time outside letters, digits and :.+-")));
|
|
}
|
|
}
|
|
if !e.parts.is_empty() && r.part_target_ms.is_none() {
|
|
return Err(RenderError(format!("{at}: parts with no part target")));
|
|
}
|
|
for p in &e.parts {
|
|
if !uri_ok(&p.uri, true) {
|
|
return Err(RenderError(format!("{at}: a part URI is empty or holds a quote, CR or LF")));
|
|
}
|
|
check_layers(&p.layers, &at)?;
|
|
}
|
|
check_layers(&e.layers, &at)?;
|
|
if i < n && e.gap && (!e.parts.is_empty() || !e.layers.is_empty()) {
|
|
return Err(RenderError(format!("{at}: a gap carries parts or layer lines")));
|
|
}
|
|
if let Some(h) = &e.preload_hint {
|
|
if i + 1 != extras.len() || r.end_list {
|
|
return Err(RenderError(format!("{at}: a preload hint is on the last entry of a playlist that does not end")));
|
|
}
|
|
if !uri_ok(h, true) {
|
|
return Err(RenderError(format!("{at}: the hint URI is empty or holds a quote, CR or LF")));
|
|
}
|
|
}
|
|
if i == n {
|
|
if r.end_list {
|
|
return Err(RenderError("an open segment in a playlist that ends".into()));
|
|
}
|
|
if !e.layers.is_empty() || e.gap {
|
|
return Err(RenderError("the open segment carries layer lines or a gap of its own".into()));
|
|
}
|
|
if e.parts.is_empty() && (e.discontinuity || e.program_date_time.is_some()) {
|
|
return Err(RenderError("the open segment's discontinuity or date-time comes before a part".into()));
|
|
}
|
|
if e.parts.is_empty() && e.preload_hint.is_none() {
|
|
return Err(RenderError("an open segment with no part and no hint".into()));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|