moq-hls 0.5.20

HLS / LL-HLS / DASH gateway for Media over QUIC
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
//! Hand-written MPEG-DASH manifest (MPD) generation.
//!
//! Rendered purely from timeline records, exactly like the HLS playlists: each record is one
//! complete aligned segment carrying its own `pts` and `duration` in the timeline's timescale,
//! which map 1:1 onto a `SegmentTimeline`'s `S@t`/`S@d`. Segments are addressed by `$Time$`
//! (the record's `pts`) rather than `$Number$`: with explicit `t` on every `S`, a rendition
//! that skips a span (a gap) or a client that joins mid-window never mis-addresses a segment
//! the way positional numbering would.
//!
//! URIs are relative to the manifest (`/<broadcast>/manifest.mpd`), so a rendition's
//! `<kind>/<name>/...` resources resolve under the broadcast directory, shared byte-for-byte
//! with the HLS routes (only the time-addressed segment alias `seg/t<pts>.m4s` is DASH's own).

use std::collections::BTreeMap;
use std::fmt::Write;
use std::sync::Arc;
use std::time::{Duration, SystemTime};

use super::Kind;

/// One DASH representation: master-level metadata plus its slice of the shared timeline.
pub(crate) struct Representation {
	/// Rendition name (the `<name>` in its `<kind>/<name>/...` paths).
	pub name: String,
	/// Whether this is a video or audio rendition (also the URL path component).
	pub kind: Kind,
	/// `@bandwidth`, in bits per second.
	pub bandwidth: u64,
	/// RFC 6381 codec string for `@codecs`.
	pub codec: String,
	/// Coded width for `@width` (video only).
	pub width: Option<u32>,
	/// Coded height for `@height` (video only).
	pub height: Option<u32>,
	/// Frames per second for `@frameRate` (video only).
	pub framerate: Option<f64>,
	/// Samples per second for `@audioSamplingRate` (audio only).
	pub sample_rate: Option<u32>,
	/// Channel count for `AudioChannelConfiguration` (audio only).
	pub channel_count: Option<u32>,
	/// The timeline's timescale (units per second), for `SegmentTemplate@timescale`.
	pub timescale: u32,
	/// `(t, d)` per listed segment in `timescale` units, oldest first: the record's `pts` and
	/// `duration` verbatim. Gap segments (no content for this rendition) and zero-duration
	/// segments (unrequestable: they'd sit exactly at the presentation end) are omitted; the
	/// next entry's explicit `t` conveys the hole.
	pub segments: Vec<(u64, u64)>,
	/// Whether this rendition's window has ended (the broadcast finished). Not rendered;
	/// [`Manifest`] callers use it to pick static vs dynamic.
	pub ended: bool,
	/// The init segment's content hash (`init.{init}.mp4`).
	pub init: String,
	/// The publisher run every segment URL carries (`seg/{generation}.t$Time$.m4s`).
	pub generation: Option<Arc<str>>,
}

/// Everything a manifest render needs; built by `Broadcaster::manifest`.
pub(crate) struct Manifest {
	/// The wall-clock time of timeline `pts` 0 (`MPD@availabilityStartTime`). Required while
	/// live (`type="dynamic"`); ignored once [`finished`](Self::finished).
	pub availability_start: Option<SystemTime>,
	/// When this render happened (`MPD@publishTime`, dynamic only).
	pub publish: SystemTime,
	/// The playlist window (`MPD@timeShiftBufferDepth`, dynamic only).
	pub window: Duration,
	/// The broadcast ended: render a `static` presentation instead of a `dynamic` one.
	pub finished: bool,
	/// Video representations, in catalog order.
	pub video: Vec<Representation>,
	/// Audio representations, in catalog order.
	pub audio: Vec<Representation>,
}

/// Escape a string for use inside an XML attribute value.
fn escape(value: &str) -> String {
	let mut out = String::with_capacity(value.len());
	for c in value.chars() {
		match c {
			'&' => out.push_str("&amp;"),
			'<' => out.push_str("&lt;"),
			'>' => out.push_str("&gt;"),
			'"' => out.push_str("&quot;"),
			'\'' => out.push_str("&apos;"),
			_ => out.push(c),
		}
	}
	out
}

/// An `xs:duration` with millisecond precision (`PT2.000S`).
fn xs_duration(duration: Duration) -> String {
	format!("PT{:.3}S", duration.as_secs_f64())
}

/// `@frameRate` must be an integer or a fraction; approximate a fractional rate over 1000
/// (29.97 -> `29970/1000`).
fn frame_rate(rate: f64) -> Option<String> {
	if !rate.is_finite() || rate <= 0.0 {
		return None;
	}
	if rate.fract() == 0.0 {
		Some(format!("{}", rate as u64))
	} else {
		Some(format!("{}/1000", (rate * 1000.0).round() as u64))
	}
}

/// The largest listed segment duration in whole seconds, for `MPD@maxSegmentDuration` (and the
/// update cadence). Like HLS's target duration, derived from the segments when the publisher
/// declared no bound.
fn max_segment_duration<'a>(representations: impl Iterator<Item = &'a Representation>) -> u64 {
	representations
		.flat_map(|rep| {
			let timescale = rep.timescale.max(1) as u64;
			rep.segments.iter().map(move |(_, d)| d.div_ceil(timescale))
		})
		.max()
		.unwrap_or(0)
		.max(1)
}

fn render_representation(out: &mut String, rep: &Representation, suffix: &str) {
	let kind = rep.kind.as_str();
	let name = escape(&rep.name);
	let init = escape(&rep.init);
	let generation = rep
		.generation
		.as_deref()
		.map(|g| format!("{}.", escape(g)))
		.unwrap_or_default();

	let mut attrs = format!(
		"id=\"{kind}/{name}\" bandwidth=\"{}\" codecs=\"{}\"",
		rep.bandwidth,
		escape(&rep.codec)
	);
	if let (Some(width), Some(height)) = (rep.width, rep.height) {
		let _ = write!(attrs, " width=\"{width}\" height=\"{height}\"");
	}
	if let Some(rate) = rep.framerate.and_then(frame_rate) {
		let _ = write!(attrs, " frameRate=\"{rate}\"");
	}
	if let Some(sample_rate) = rep.sample_rate {
		let _ = write!(attrs, " audioSamplingRate=\"{sample_rate}\"");
	}
	let _ = writeln!(out, "      <Representation {attrs}>");

	if let Some(channels) = rep.channel_count {
		let _ = writeln!(
			out,
			"        <AudioChannelConfiguration schemeIdUri=\"urn:mpeg:dash:23003:3:audio_channel_configuration:2011\" value=\"{channels}\"/>"
		);
	}

	let _ = writeln!(
		out,
		"        <SegmentTemplate timescale=\"{}\" initialization=\"{kind}/{name}/init.{init}.mp4{suffix}\" media=\"{kind}/{name}/seg/{generation}t$Time$.m4s{suffix}\">",
		rep.timescale.max(1)
	);
	let _ = writeln!(out, "          <SegmentTimeline>");
	for (t, d) in &rep.segments {
		// Explicit `t` on every S: durations never accumulate into the address, so a gap (an
		// omitted segment) or timescale rounding can't shift what `$Time$` resolves to.
		let _ = writeln!(out, "            <S t=\"{t}\" d=\"{d}\"/>");
	}
	let _ = writeln!(out, "          </SegmentTimeline>");
	let _ = writeln!(out, "        </SegmentTemplate>");
	let _ = writeln!(out, "      </Representation>");
}

fn render_adaptation_sets(out: &mut String, kind: Kind, representations: &[Representation], suffix: &str) {
	// Representations in one AdaptationSet must be seamlessly switchable, so group by codec
	// (mirroring the HLS master's audio groups).
	let mut codecs = BTreeMap::<&str, Vec<&Representation>>::new();
	for rep in representations {
		if rep.segments.is_empty() {
			// Nothing listable (yet, or a gap-only window): a Representation with an empty
			// SegmentTimeline is unplayable, so leave it out of this render.
			continue;
		}
		codecs.entry(&rep.codec).or_default().push(rep);
	}

	let (content, mime) = match kind {
		Kind::Video => ("video", "video/mp4"),
		Kind::Audio => ("audio", "audio/mp4"),
	};
	for representations in codecs.values() {
		// Segment boundaries come from the broadcast's single timeline, so alignment across
		// renditions is guaranteed by construction; groups open on keyframes.
		let _ = writeln!(
			out,
			"    <AdaptationSet contentType=\"{content}\" mimeType=\"{mime}\" segmentAlignment=\"true\" startWithSAP=\"1\">"
		);
		for rep in representations {
			render_representation(out, rep, suffix);
		}
		let _ = writeln!(out, "    </AdaptationSet>");
	}
}

/// Render the manifest.
///
/// `query` is an optional query string (without the leading `?`, e.g. `jwt=<token>`) appended
/// to every child URL (each representation's init and media templates), so a stock player that
/// does not replay request headers still carries a credential on its follow-up requests.
pub(crate) fn render_manifest(manifest: &Manifest, query: Option<&str>) -> String {
	let suffix = query.map(|q| format!("?{}", escape(q))).unwrap_or_default();
	let representations = || manifest.video.iter().chain(&manifest.audio);
	let target = max_segment_duration(representations());

	let mut out = String::new();
	let _ = writeln!(out, "<?xml version=\"1.0\" encoding=\"utf-8\"?>");
	let _ = write!(
		out,
		"<MPD xmlns=\"urn:mpeg:dash:schema:mpd:2011\" profiles=\"urn:mpeg:dash:profile:isoff-live:2011\""
	);
	if manifest.finished {
		// Presentation time stays anchored at pts 0 in both states (no
		// presentationTimeOffset), so a player that followed the live presentation keeps the
		// same segment-to-time mapping when the manifest turns static; DASH forbids shifting
		// representation timing across MPD updates. The duration therefore spans from pts 0,
		// and a fresh static viewer starts at the earliest listed S@t (players seek to the
		// first timeline entry, not to 0).
		let duration = representations()
			.filter_map(|rep| {
				let (t, d) = rep.segments.last()?;
				let timescale = rep.timescale.max(1) as u64;
				Some(Duration::from_nanos(
					((t + d) as u128 * 1_000_000_000 / timescale as u128) as u64,
				))
			})
			.max()
			.unwrap_or_default();
		let _ = write!(
			out,
			" type=\"static\" mediaPresentationDuration=\"{}\"",
			xs_duration(duration)
		);
	} else {
		let availability = manifest.availability_start.unwrap_or(SystemTime::UNIX_EPOCH);
		// Reload cadence and live delay follow HLS conventions: players refresh about once
		// per segment and sit a few segments behind the live edge (bounded by the window).
		let update = Duration::from_secs(target);
		let delay = Duration::from_secs(3 * target).min(manifest.window.max(update));
		let _ = write!(
			out,
			" type=\"dynamic\" availabilityStartTime=\"{}\" publishTime=\"{}\" minimumUpdatePeriod=\"{}\" timeShiftBufferDepth=\"{}\" suggestedPresentationDelay=\"{}\"",
			humantime::format_rfc3339_millis(availability),
			humantime::format_rfc3339_millis(manifest.publish),
			xs_duration(update),
			xs_duration(manifest.window),
			xs_duration(delay),
		);
	}
	let _ = writeln!(
		out,
		" minBufferTime=\"{}\" maxSegmentDuration=\"{}\">",
		xs_duration(Duration::from_secs(target)),
		xs_duration(Duration::from_secs(target)),
	);

	let _ = writeln!(out, "  <Period id=\"0\" start=\"PT0.000S\">");
	render_adaptation_sets(&mut out, Kind::Video, &manifest.video, &suffix);
	render_adaptation_sets(&mut out, Kind::Audio, &manifest.audio, &suffix);
	let _ = writeln!(out, "  </Period>");
	let _ = writeln!(out, "</MPD>");
	out
}

#[cfg(test)]
mod tests {
	use super::*;

	fn video(segments: Vec<(u64, u64)>, ended: bool) -> Representation {
		Representation {
			name: "video0".into(),
			kind: Kind::Video,
			bandwidth: 2_500_000,
			codec: "avc1.42c01f".into(),
			width: Some(1280),
			height: Some(720),
			framerate: Some(30.0),
			sample_rate: None,
			channel_count: None,
			timescale: 1000,
			segments,
			ended,
			init: "0123abcd".into(),
			generation: None,
		}
	}

	fn audio(segments: Vec<(u64, u64)>, ended: bool) -> Representation {
		Representation {
			name: "audio0".into(),
			kind: Kind::Audio,
			bandwidth: 128_000,
			codec: "mp4a.40.2".into(),
			width: None,
			height: None,
			framerate: None,
			sample_rate: Some(48_000),
			channel_count: Some(2),
			timescale: 1000,
			segments,
			ended,
			init: "4567cdef".into(),
			generation: None,
		}
	}

	#[test]
	fn renders_dynamic_manifest() {
		let manifest = Manifest {
			availability_start: Some(SystemTime::UNIX_EPOCH + Duration::from_millis(1_751_846_400_123)),
			publish: SystemTime::UNIX_EPOCH + Duration::from_millis(1_751_846_410_000),
			window: Duration::from_secs(16),
			finished: false,
			video: vec![video(vec![(0, 2_000), (2_000, 2_000)], false)],
			audio: vec![audio(vec![(0, 2_000), (2_000, 2_000)], false)],
		};

		let out = render_manifest(&manifest, None);
		assert!(out.starts_with("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"));
		assert!(out.contains(" type=\"dynamic\""));
		assert!(out.contains(" availabilityStartTime=\"2025-07-07T00:00:00.123Z\""));
		assert!(out.contains(" publishTime=\"2025-07-07T00:00:10.000Z\""));
		assert!(out.contains(" timeShiftBufferDepth=\"PT16.000S\""));
		assert!(out.contains(" minimumUpdatePeriod=\"PT2.000S\""));
		assert!(out.contains(" suggestedPresentationDelay=\"PT6.000S\""));
		assert!(out.contains(" maxSegmentDuration=\"PT2.000S\""));
		assert!(out.contains(
			"<AdaptationSet contentType=\"video\" mimeType=\"video/mp4\" segmentAlignment=\"true\" startWithSAP=\"1\">"
		));
		assert!(out.contains(
			"<Representation id=\"video/video0\" bandwidth=\"2500000\" codecs=\"avc1.42c01f\" width=\"1280\" height=\"720\" frameRate=\"30\">"
		));
		assert!(out.contains(
			"<Representation id=\"audio/audio0\" bandwidth=\"128000\" codecs=\"mp4a.40.2\" audioSamplingRate=\"48000\">"
		));
		assert!(out.contains(
			"<AudioChannelConfiguration schemeIdUri=\"urn:mpeg:dash:23003:3:audio_channel_configuration:2011\" value=\"2\"/>"
		));
		assert!(out.contains(
			"<SegmentTemplate timescale=\"1000\" initialization=\"video/video0/init.0123abcd.mp4\" media=\"video/video0/seg/t$Time$.m4s\">"
		));
		assert!(out.contains("<S t=\"0\" d=\"2000\"/>"));
		assert!(out.contains("<S t=\"2000\" d=\"2000\"/>"));
		assert!(!out.contains("mediaPresentationDuration"));
	}

	#[test]
	fn renders_static_manifest_when_finished() {
		let manifest = Manifest {
			availability_start: None,
			publish: SystemTime::UNIX_EPOCH,
			window: Duration::from_secs(16),
			finished: true,
			// The window starts mid-broadcast: presentation time stays anchored at pts 0 (no
			// presentationTimeOffset), so the duration spans the lead-in and a live session
			// keeps its segment-to-time mapping across the live-to-static reload.
			video: vec![video(vec![(10_000, 2_000), (12_000, 2_500)], true)],
			audio: Vec::new(),
		};

		let out = render_manifest(&manifest, None);
		assert!(out.contains(" type=\"static\""));
		assert!(out.contains(" mediaPresentationDuration=\"PT14.500S\""));
		assert!(out.contains(" maxSegmentDuration=\"PT3.000S\""));
		assert!(!out.contains("presentationTimeOffset"));
		assert!(!out.contains("availabilityStartTime"));
		assert!(!out.contains("minimumUpdatePeriod"));
		assert!(!out.contains("timeShiftBufferDepth"));
	}

	#[test]
	fn query_rides_child_urls_escaped() {
		let manifest = Manifest {
			availability_start: Some(SystemTime::UNIX_EPOCH),
			publish: SystemTime::UNIX_EPOCH,
			window: Duration::from_secs(16),
			finished: false,
			video: vec![video(vec![(0, 2_000)], false)],
			audio: Vec::new(),
		};

		let out = render_manifest(&manifest, Some("jwt=abc.def&x=1"));
		assert!(out.contains("initialization=\"video/video0/init.0123abcd.mp4?jwt=abc.def&amp;x=1\""));
		assert!(out.contains("media=\"video/video0/seg/t$Time$.m4s?jwt=abc.def&amp;x=1\""));
	}

	#[test]
	fn generation_rides_every_media_url() {
		let mut rep = video(vec![(0, 2_000)], false);
		rep.generation = Some("run-7".into());
		let manifest = Manifest {
			availability_start: Some(SystemTime::UNIX_EPOCH),
			publish: SystemTime::UNIX_EPOCH,
			window: Duration::from_secs(16),
			finished: false,
			video: vec![rep],
			audio: Vec::new(),
		};

		let out = render_manifest(&manifest, None);
		assert!(out.contains("initialization=\"video/video0/init.0123abcd.mp4\""));
		assert!(out.contains("media=\"video/video0/seg/run-7.t$Time$.m4s\""));
	}

	#[test]
	fn fractional_frame_rate_renders_as_fraction() {
		assert_eq!(frame_rate(30.0).as_deref(), Some("30"));
		assert_eq!(frame_rate(29.97).as_deref(), Some("29970/1000"));
		assert_eq!(frame_rate(0.0), None);
	}

	#[test]
	fn empty_and_gap_only_representations_are_omitted() {
		let manifest = Manifest {
			availability_start: Some(SystemTime::UNIX_EPOCH),
			publish: SystemTime::UNIX_EPOCH,
			window: Duration::from_secs(16),
			finished: false,
			video: vec![video(vec![(0, 2_000)], false)],
			audio: vec![audio(Vec::new(), false)],
		};

		let out = render_manifest(&manifest, None);
		assert!(out.contains("id=\"video/video0\""));
		assert!(!out.contains("id=\"audio/audio0\""), "an empty timeline is unplayable");
	}
}