Skip to main content

moq_hls/export/
rendition.rs

1//! One rendition: playlists from its view of the broadcast timeline, segments fetched on
2//! demand.
3
4use std::sync::{Arc, Mutex, RwLock};
5use std::task::Poll;
6use std::time::{Duration, SystemTime};
7
8use bytes::Bytes;
9use hang::catalog::{Archive, AudioConfig, Clock, VideoConfig};
10use moq_mux::container::fmp4::Muxer;
11use moq_mux::timeline::Entry;
12use sha2::{Digest, Sha256};
13
14use super::playlist::{Segment, Snapshot};
15use super::upstream::Upstream;
16use super::{mpd, segments};
17use crate::Result;
18
19/// Fallback advertised bitrates when the catalog doesn't carry one.
20const DEFAULT_VIDEO_BITRATE: u64 = 2_000_000;
21const DEFAULT_AUDIO_BITRATE: u64 = 128_000;
22
23/// The fallback advertised bitrate for a rendition of this kind.
24fn default_bandwidth(kind: Kind) -> u64 {
25	match kind {
26		Kind::Video => DEFAULT_VIDEO_BITRATE,
27		Kind::Audio => DEFAULT_AUDIO_BITRATE,
28	}
29}
30
31/// Upper bound on the groups fetched for one segment, so a corrupt timeline can't turn one
32/// HTTP request into an endless fetch loop.
33const MAX_SEGMENT_GROUPS: u64 = 1024;
34
35/// Whether a rendition carries video or audio.
36///
37/// Also the first URL path component of a rendition (`/{broadcast}/{kind}/{name}/...`),
38/// so video and audio renditions that share a name don't collide.
39#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
40pub enum Kind {
41	/// A video rendition.
42	Video,
43	/// An audio rendition.
44	Audio,
45}
46
47impl Kind {
48	/// Parse a rendition URL path component.
49	pub fn parse(value: &str) -> Option<Self> {
50		match value {
51			"video" => Some(Self::Video),
52			"audio" => Some(Self::Audio),
53			_ => None,
54		}
55	}
56
57	/// The URL path component for this kind (`"video"` / `"audio"`).
58	pub fn as_str(self) -> &'static str {
59		match self {
60			Kind::Video => "video",
61			Kind::Audio => "audio",
62		}
63	}
64}
65
66/// How far behind the live edge a cursor's feed still asks for a group. A group resolves
67/// into a row only once its successor starts, so a cursor keeping up fetches it at the live
68/// edge; the budget only has to cover a burst (a publisher catching up) and a cursor that
69/// fell behind. The default zero would deliver only the newest group of a burst.
70const FEED_MAX_DELAY: Duration = Duration::from_secs(30);
71
72/// A built init segment, with the content hash that versions its URL (`init.{hash}.mp4`).
73///
74/// Hashing the bytes rather than a list of catalog fields covers every input that shapes the
75/// init, and lets two edges serving the same rendition agree on the URL without coordinating.
76struct Init {
77	bytes: Bytes,
78	hash: String,
79}
80
81impl Init {
82	fn new(bytes: Bytes) -> Self {
83		// 64 bits: the URL only has to tell apart the inits one rendition name ever carries.
84		let hash = Sha256::digest(&bytes)[..8].iter().map(|b| format!("{b:02x}")).collect();
85		Self { bytes, hash }
86	}
87}
88
89/// The publisher run a rendition lists: the label its segment URLs carry and the init built
90/// from its media. Read and replaced together, so a render never pairs one run's label with
91/// another run's init.
92#[derive(Clone, Default)]
93struct Run {
94	generation: Option<Arc<str>>,
95	init: Option<Arc<Init>>,
96	/// Bumped by every restart, so an init build that straddles one isn't cached for the new run.
97	epoch: u64,
98}
99
100/// The rendition's catalog config, kept whole so a [`Muxer`] can be built per request.
101enum Config {
102	Video(VideoConfig),
103	Audio(AudioConfig),
104}
105
106/// The broadcast serving this rendition's media, rebound when a sibling publisher is replaced.
107struct Media {
108	handle: Mutex<Handle>,
109	/// When set, a Dropped sibling is rebound through this source rather than keeping the
110	/// replaced publisher's rows listed.
111	sibling: Option<(moq_mux::Source, moq_net::path::RelativeOwned)>,
112}
113
114struct Handle {
115	binding: Arc<moq_mux::Binding>,
116	/// Skip incoming timeline rows until the current bind resolves (set when a sibling is rebound).
117	waiting: bool,
118}
119
120impl Media {
121	fn bind(upstream: &Upstream, rel: Option<&moq_net::path::RelativeOwned>) -> moq_mux::Result<Self> {
122		Ok(Self {
123			handle: Mutex::new(Handle {
124				binding: Arc::new(upstream.bind(rel)?),
125				waiting: false,
126			}),
127			sibling: sibling(upstream, rel),
128		})
129	}
130
131	/// Drop listed rows and rebind when a sibling publisher has been replaced; return whether
132	/// incoming timeline rows should be listed.
133	fn admits(&self, window: &segments::Producer) -> bool {
134		self.sync(window);
135		!self.handle.lock().expect("media lock poisoned").waiting
136	}
137
138	/// Drop listed rows and rebind when a sibling publisher has been replaced; return the
139	/// binding those rows belong to. Fetch from this handle, not a later `sync`: a replacement
140	/// that lands between a range lookup and `track` would otherwise serve the old groups.
141	fn sync(&self, window: &segments::Producer) -> Arc<moq_mux::Binding> {
142		let Some((source, rel)) = &self.sibling else {
143			return self.handle.lock().expect("media lock poisoned").binding.clone();
144		};
145		let mut handle = self.handle.lock().expect("media lock poisoned");
146		match handle.binding.poll_broadcast(&kio::Waiter::noop()) {
147			Poll::Ready(Ok(broadcast)) if broadcast.is_closed() => {
148				// The bound sibling ended, and a broadcast end carries no cause, so a rival
149				// publisher looks the same as a clean VOD end. Rows listed for it must not
150				// be served from the replacement.
151				window.clear();
152				if let Ok(next) = source.bind(Some(rel)) {
153					handle.binding = Arc::new(next);
154				}
155				handle.waiting = true;
156			}
157			Poll::Ready(Ok(_)) => handle.waiting = false,
158			Poll::Ready(Err(_)) => {
159				// Binding never retries a failed request. Re-issue so a publisher that
160				// appears after this Unroutable can still recover the rendition. Do not
161				// set waiting: an initial Unroutable (announce still in flight) must
162				// still list the catalog's rows.
163				if let Ok(next) = source.bind(Some(rel)) {
164					handle.binding = Arc::new(next);
165				}
166			}
167			Poll::Pending => {}
168		}
169		handle.binding.clone()
170	}
171}
172
173/// A catalog `broadcast` reference that names a different path than the catalog itself.
174fn sibling(
175	upstream: &Upstream,
176	rel: Option<&moq_net::path::RelativeOwned>,
177) -> Option<(moq_mux::Source, moq_net::path::RelativeOwned)> {
178	let target = upstream.source.resolve_reference(rel)?;
179	if upstream.source.resolve_reference(None).as_ref() == Some(&target) {
180		return None;
181	}
182	Some((
183		upstream.source.clone(),
184		rel.cloned().unwrap_or_else(moq_net::path::Relative::empty),
185	))
186}
187
188/// Clear the video fields that don't change how the rendition decodes or is muxed, so a config
189/// the publisher only re-measured or re-labelled compares equal to the one already being served.
190///
191/// A denylist rather than an allowlist on purpose: a field the catalog gains later counts as
192/// decode-relevant until someone decides otherwise, so the failure mode is a needless rebuild
193/// rather than serving an init segment that no longer describes the media.
194fn normalize_video(config: &VideoConfig) -> VideoConfig {
195	let mut config = config.clone();
196	config.bitrate = None;
197	config.jitter = None;
198	config.delay = None;
199	config.label = None;
200	config.stalled = None;
201	// Publishers estimate it and republish once it settles. The rendition keeps the fMP4
202	// timescale it was built with, and the framerate is refreshed in place like the bitrate.
203	config.framerate = None;
204	config
205}
206
207/// The audio counterpart of [`normalize_video`].
208fn normalize_audio(config: &AudioConfig) -> AudioConfig {
209	let mut config = config.clone();
210	config.bitrate = None;
211	config.jitter = None;
212	config.delay = None;
213	config.label = None;
214	config
215}
216
217/// A single HLS rendition: master-playlist metadata, its view of the broadcast timeline (which
218/// renders its media playlist), and on-demand segment fetching.
219pub struct Rendition {
220	/// Rendition name (the catalog track name; also its URL path component).
221	pub name: String,
222	/// Whether this rendition is video or audio.
223	pub kind: Kind,
224	/// Advertised bitrate for the master playlist `BANDWIDTH` attribute; read through
225	/// [`bandwidth`](Self::bandwidth), refreshed in place by [`refresh`](Self::refresh).
226	bitrate: RwLock<Option<u64>>,
227	/// The latest catalog framerate (video only), refreshed in place by [`refresh`](Self::refresh):
228	/// the muxer's fallback sample cadence and the DASH `frameRate`.
229	framerate: RwLock<Option<f64>>,
230	/// Coded width, for the master playlist `RESOLUTION` (video only).
231	pub width: Option<u32>,
232	/// Coded height, for the master playlist `RESOLUTION` (video only).
233	pub height: Option<u32>,
234	/// RFC 6381 codec string for the master playlist `CODECS` attribute.
235	pub codec: String,
236
237	config: Config,
238	/// The catalog's root archive timeline: the timescale timings decode with.
239	section: Archive,
240	/// The catalog's root broadcast clock: the wall anchor timings map through, after
241	/// timescale conversion. Absent when the publisher exposes none.
242	clock: Option<Clock>,
243	/// This rendition's window over the broadcast timeline, fed by the catalog watcher's
244	/// fan-out.
245	live: Arc<segments::Producer>,
246	/// The broadcast serving this rendition's media. A sibling is bound when the rendition is
247	/// created and rebound if that publisher is replaced (see [`Upstream::bind`]).
248	media: Media,
249	/// The current publisher run: the embedder's label every segment URL carries (see
250	/// [`Broadcaster::set_generation`](super::Broadcaster::set_generation)) and the init, built
251	/// on first request. A catalog reconfigure rebuilds the whole rendition, while a new
252	/// generation drops the init, since a restarted inline-codec publisher may carry new
253	/// parameter sets under the same catalog.
254	run: Mutex<Run>,
255	/// Serializes init builds, so concurrent first requests fetch the keyframe group once.
256	building: tokio::sync::Mutex<()>,
257}
258
259impl Rendition {
260	/// Whether `config` describes the same media this rendition is already serving, i.e. whether
261	/// it decodes and muxes identically. Fields the publisher revises without touching the media
262	/// (its bitrate, jitter, and framerate estimates, a label) are ignored here, and the bitrate
263	/// and framerate are picked up by [`refresh`](Self::refresh) instead.
264	pub(crate) fn matches_video(&self, config: &VideoConfig) -> bool {
265		let Config::Video(current) = &self.config else {
266			return false;
267		};
268		normalize_video(current) == normalize_video(config)
269	}
270
271	/// The audio counterpart of [`matches_video`](Self::matches_video).
272	pub(crate) fn matches_audio(&self, config: &AudioConfig) -> bool {
273		let Config::Audio(current) = &self.config else {
274			return false;
275		};
276		normalize_audio(current) == normalize_audio(config)
277	}
278
279	/// The advertised bitrate in bits per second, for the master playlist `BANDWIDTH` attribute
280	/// and the DASH `bandwidth`. Falls back to a per-kind default when the catalog carries none.
281	pub fn bandwidth(&self) -> u64 {
282		self.bitrate().unwrap_or(default_bandwidth(self.kind))
283	}
284
285	/// Take the advertised bitrate and framerate from a catalog update that
286	/// [`matches`](Self::matches_video) this rendition (audio passes no framerate).
287	///
288	/// The publisher's estimator republishes the catalog whenever its measured bitrate moves, so
289	/// this is the common update by far: rebuilding the rendition for it would reset the playlist
290	/// window, the cached init segment, and `EXT-X-MEDIA-SEQUENCE` several times a minute.
291	pub(crate) fn refresh(&self, bitrate: Option<u64>, framerate: Option<f64>) {
292		*self.bitrate.write().expect("bitrate lock poisoned") = bitrate;
293		*self.framerate.write().expect("framerate lock poisoned") = framerate;
294	}
295
296	/// The latest catalog bitrate, preserving `None` for muxer-specific fallback behavior.
297	fn bitrate(&self) -> Option<u64> {
298		*self.bitrate.read().expect("bitrate lock poisoned")
299	}
300
301	/// The latest catalog framerate.
302	fn framerate(&self) -> Option<f64> {
303		*self.framerate.read().expect("framerate lock poisoned")
304	}
305
306	/// Build a video rendition over the broadcast's timeline `section`, timed by the
307	/// catalog root `clock`.
308	///
309	/// Fails when the config's `broadcast` reference escapes above the origin root: it names no
310	/// broadcast, so there would be no media to serve.
311	pub(crate) fn video(
312		name: String,
313		config: &VideoConfig,
314		upstream: &Upstream,
315		section: Archive,
316		clock: Option<Clock>,
317	) -> moq_mux::Result<Self> {
318		Ok(Self {
319			name,
320			kind: Kind::Video,
321			bitrate: RwLock::new(config.bitrate),
322			framerate: RwLock::new(config.framerate),
323			width: config.coded_width,
324			height: config.coded_height,
325			codec: config.codec.to_string(),
326			config: Config::Video(config.clone()),
327			section,
328			clock,
329			live: Arc::new(segments::Producer::new()),
330			media: Media::bind(upstream, config.broadcast.as_ref())?,
331			run: Mutex::default(),
332			building: tokio::sync::Mutex::new(()),
333		})
334	}
335
336	/// Build an audio rendition over the broadcast's timeline `section`, timed by the catalog
337	/// root `clock`, failing like [`video`](Self::video).
338	pub(crate) fn audio(
339		name: String,
340		config: &AudioConfig,
341		upstream: &Upstream,
342		section: Archive,
343		clock: Option<Clock>,
344	) -> moq_mux::Result<Self> {
345		Ok(Self {
346			name,
347			kind: Kind::Audio,
348			bitrate: RwLock::new(config.bitrate),
349			framerate: RwLock::new(None),
350			width: None,
351			height: None,
352			codec: config.codec.to_string(),
353			config: Config::Audio(config.clone()),
354			section,
355			clock,
356			live: Arc::new(segments::Producer::new()),
357			media: Media::bind(upstream, config.broadcast.as_ref())?,
358			run: Mutex::default(),
359			building: tokio::sync::Mutex::new(()),
360		})
361	}
362
363	/// Feed one timeline record into this rendition's window: its own ranges (empty when the
364	/// record carries none for it, a gap), timed by the record.
365	pub(crate) fn push(&self, index: u64, entry: &Entry, discontinuity: u64, window: Duration) {
366		if !self.media.admits(&self.live) {
367			return;
368		}
369		let row = segments::Row {
370			index,
371			segment: entry.segment,
372			ranges: entry.tracks.get(&self.name).cloned().unwrap_or_default(),
373			duration: entry.duration,
374			pts: entry.pts,
375			end: Duration::from(entry.pts) + entry.duration,
376			discontinuity,
377		};
378		self.live.push(row, window);
379	}
380
381	/// Remove source timeline records in `range` from this rendition's window.
382	pub(crate) fn pop(&self, range: std::ops::Range<u64>) {
383		self.live.pop(range);
384	}
385
386	/// Clear rows that can no longer be followed by a consecutive source timeline record.
387	pub(crate) fn clear(&self) {
388		self.live.clear();
389	}
390
391	/// List every segment URL under `generation` from now on, keeping the run's rows and init.
392	pub(crate) fn label(&self, generation: Option<Arc<str>>) {
393		self.run.lock().expect("run lock poisoned").generation = generation;
394	}
395
396	/// Start a new publisher run labeled `generation`, dropping the previous run's rows and init.
397	///
398	/// The rows go first: renders read the run before the rows, so one that sees the new label
399	/// never lists the old run's segments under it.
400	pub(crate) fn restart(&self, generation: Option<Arc<str>>) {
401		self.live.clear();
402		let mut run = self.run.lock().expect("run lock poisoned");
403		*run = Run {
404			generation,
405			init: None,
406			epoch: run.epoch + 1,
407		};
408	}
409
410	fn run(&self) -> Run {
411		self.run.lock().expect("run lock poisoned").clone()
412	}
413
414	/// The generation every segment URL currently carries.
415	#[cfg_attr(not(feature = "server"), allow(dead_code))]
416	pub(crate) fn generation(&self) -> Option<Arc<str>> {
417		self.run().generation
418	}
419
420	/// Mark this rendition's window ended (the timeline finished cleanly).
421	pub(crate) fn end(&self) {
422		self.live.end();
423	}
424
425	/// A cursor over this rendition's segments, with their media, in timeline order.
426	/// For a recorder mirroring the broadcast; see [`segments::Consumer`].
427	pub fn segments(self: &Arc<Self>) -> segments::Consumer {
428		self.live.subscribe(self.clone())
429	}
430
431	/// Retire this rendition: close its window so every [`segments::Consumer`] over it drains
432	/// what's left and then ends.
433	///
434	/// Called when the catalog drops or replaces the rendition; a cursor's `Arc<Self>` would
435	/// otherwise park forever on a window nothing feeds anymore.
436	pub(crate) fn close(&self) {
437		self.live.close();
438	}
439
440	/// Resolve once the playlist is renderable, i.e. once [`media_playlist`](Self::media_playlist)
441	/// would return `Some`. Bounding the wait is the caller's policy (the serve path wraps this
442	/// in its own timeout).
443	pub async fn playable(&self) {
444		kio::wait(|waiter| self.poll_playable(waiter)).await;
445	}
446
447	/// Poll until this rendition has a renderable media playlist.
448	pub(crate) fn poll_playable(&self, waiter: &kio::Waiter) -> Poll<()> {
449		self.media.sync(&self.live);
450		self.live.poll_playable(waiter)
451	}
452
453	/// Render this rendition's media playlist from the current timeline window, or `None` when
454	/// there is nothing to serve yet (no segment, and the broadcast has not ended) -- a
455	/// playlist with no segments confuses players, so a server should treat `None` as "not
456	/// ready" rather than serve it.
457	///
458	/// The playlist names its init segment by a hash of the bytes, so it is also `None` until
459	/// the init is known. An inline-parameter-set codec (no catalog `description`) only learns
460	/// it from media: await [`init`](Self::init) first.
461	///
462	/// `query` is an optional query string (without the leading `?`, e.g. `jwt=<token>`)
463	/// appended to every child URL (the init map and each segment), so a stock player that does
464	/// not replay request headers still carries a credential on its follow-up requests. It is an
465	/// argument rather than a [`Config`](super::Config) field because one broadcaster fans out
466	/// to viewers holding different tokens.
467	pub fn media_playlist(&self, query: Option<&str>) -> Option<String> {
468		// Read the run before the rows (see `restart`).
469		let run = self.run();
470		let init = self.built_init(&run)?;
471		self.is_playable()
472			.then(|| super::render_media(&self.snapshot_as(run.generation), &init.hash, query))
473	}
474
475	/// Wait until the media playlist is servable, then render it: at least one segment is
476	/// listed and the `EXT-X-MAP` init segment it names is built, so a player never loads a
477	/// map that 404s. `None` when the init cannot be built yet.
478	///
479	/// Waits without bound for the first segment; bounding it is the caller's policy. `query`
480	/// propagates exactly as in [`media_playlist`](Self::media_playlist).
481	pub async fn playlist(&self, query: Option<&str>) -> Result<Option<String>> {
482		self.playable().await;
483		// An inline-codec init needs a keyframe group fetched first. init() caches, so the
484		// player's follow-up GET of the init is free.
485		if self.init().await?.is_none() {
486			return Ok(None);
487		}
488		Ok(self.media_playlist(query))
489	}
490
491	/// Snapshot the media playlist from the current timeline window.
492	#[cfg(test)]
493	pub(crate) fn snapshot(&self) -> Snapshot {
494		self.snapshot_as(self.generation())
495	}
496
497	/// Snapshot the media playlist from the current timeline window, labeled `generation`.
498	fn snapshot_as(&self, generation: Option<Arc<str>>) -> Snapshot {
499		self.media.sync(&self.live);
500		let window = self.live.window();
501
502		// EXT-X-TARGETDURATION starts from the catalog's declared bound when the publisher
503		// offered one: it knows its segment duration up front, so the first playlist already
504		// carries the real value instead of a guess grown from observation. Rounded up, since
505		// HLS wants whole seconds and the advertised value must never understate the bound.
506		let declared = self
507			.section
508			.duration_max
509			.map(|max| max.div_ceil((self.section.timescale as u64).max(1)))
510			.unwrap_or(0);
511
512		// Most publishers can't promise a bound (a real-time GOP can be minutes long), and even
513		// a declared one is only a promise about content the publisher controls. HLS requires
514		// EXT-X-TARGETDURATION to be at least every EXTINF, so cover the window either way.
515		let observed = window
516			.segments
517			.iter()
518			.map(|s| s.duration.as_secs_f64().ceil() as u64)
519			.max()
520			.unwrap_or(0);
521		let target_duration = declared.max(observed).max(1);
522
523		let program_date_time = window.segments.first().and_then(|first| self.wall_clock(first.pts));
524
525		let mut previous: Option<u64> = None;
526		let segments = window
527			.segments
528			.into_iter()
529			.map(|s| {
530				let discontinuity = previous.replace(s.discontinuity).is_some_and(|p| p != s.discontinuity);
531				Segment {
532					segment: s.segment,
533					duration: s.duration,
534					gap: s.ranges.is_empty(),
535					discontinuity,
536				}
537			})
538			.collect();
539
540		Snapshot {
541			target_duration,
542			media_sequence: window.sequence,
543			segments,
544			finished: window.ended,
545			program_date_time,
546			generation,
547		}
548	}
549
550	/// Whether the playlist has anything to serve yet (at least one segment, or the broadcast
551	/// already ended).
552	pub(crate) fn is_playable(&self) -> bool {
553		self.media.sync(&self.live);
554		self.live.is_playable()
555	}
556
557	/// This rendition's DASH representation: its master-level metadata plus its slice of the
558	/// shared timeline as `(t, d)` pairs in the timeline's own timescale (the record values
559	/// verbatim, so `$Time$` addressing resolves exactly). `None` until the init is known, as
560	/// for [`media_playlist`](Self::media_playlist).
561	pub(crate) fn representation(&self) -> Option<mpd::Representation> {
562		// Read the run before the rows (see `restart`).
563		let run = self.run();
564		let init = self.built_init(&run)?;
565		self.media.sync(&self.live);
566		let window = self.live.window();
567		let timescale = self.timescale();
568		let segments = window
569			.segments
570			.iter()
571			.filter(|row| !row.ranges.is_empty())
572			.filter_map(|row| {
573				let t = row.pts.as_scale(timescale) as u64;
574				let span = row.end.saturating_sub(row.pts.into());
575				let d = (span.as_nanos() * timescale.as_u64() as u128 / 1_000_000_000) as u64;
576				// A zero-duration record (the terminal segment when the publisher couldn't
577				// bound its final sample) would sit exactly at the presentation end, so a
578				// conforming player would never request it; advertising it in a
579				// SegmentTimeline is contradictory timing, so leave it out. (HLS still lists
580				// it as EXTINF:0, which players fetch by URL rather than by timing.)
581				(d > 0).then_some((t, d))
582			})
583			.collect();
584		let (framerate, sample_rate, channel_count) = match &self.config {
585			Config::Video(_) => (self.framerate(), None, None),
586			Config::Audio(config) => (None, Some(config.sample_rate), Some(config.channel_count)),
587		};
588		Some(mpd::Representation {
589			name: self.name.clone(),
590			kind: self.kind,
591			bandwidth: self.bandwidth(),
592			codec: self.codec.clone(),
593			width: self.width,
594			height: self.height,
595			framerate,
596			sample_rate,
597			channel_count,
598			timescale: self.section.timescale.max(1),
599			segments,
600			ended: window.ended,
601			init: init.hash.clone(),
602			generation: run.generation,
603		})
604	}
605
606	/// Fetch and transmux the segment whose timeline `pts` is `time`, in the timeline's
607	/// timescale (DASH `$Time$` addressing of the same bytes [`segment`](Self::segment) serves
608	/// by aligned number).
609	#[cfg_attr(not(feature = "server"), allow(dead_code))]
610	pub(crate) async fn segment_at(&self, time: u64) -> Result<Option<Bytes>> {
611		self.media.sync(&self.live);
612		let Some(segment) = self.live.segment_number_at(time, self.timescale()) else {
613			return Ok(None);
614		};
615		self.segment(segment).await
616	}
617
618	/// The timeline's timescale; a declared 0 is clamped rather than trusted.
619	fn timescale(&self) -> moq_net::Timescale {
620		moq_net::Timescale::new((self.section.timescale as u64).max(1)).expect("clamped nonzero timescale")
621	}
622
623	/// The wall-clock time of a media timestamp, when the catalog advertises its broadcast
624	/// clock. Converts the timeline-timescale timestamp into the clock's timescale and applies
625	/// the root mapping; an unrepresentable result maps to no time rather than a truncated one.
626	pub(crate) fn wall_clock(&self, pts: moq_net::Timestamp) -> Option<SystemTime> {
627		let clock = self.clock.as_ref()?;
628		clock.wall_clock(pts).ok()
629	}
630
631	/// A muxer at the timescale this rendition's config chose, with the latest bitrate and
632	/// fallback framerate.
633	pub(crate) fn muxer(&self) -> Result<Muxer> {
634		let bitrate = self.bitrate();
635		Ok(match &self.config {
636			Config::Video(config) => {
637				let mut config = config.clone();
638				config.bitrate = bitrate;
639				Muxer::video(&config)?.with_framerate(self.framerate())
640			}
641			Config::Audio(config) => {
642				let mut config = config.clone();
643				config.bitrate = bitrate;
644				Muxer::audio(&config)?
645			}
646		})
647	}
648
649	/// The binding this rendition's media is fetched through now.
650	pub(crate) fn binding(&self) -> Arc<moq_mux::Binding> {
651		self.media.sync(&self.live)
652	}
653
654	/// Subscribe to the media track on `binding` once it resolves: `None` when it never will.
655	pub(crate) fn poll_subscribe(
656		&self,
657		binding: &moq_mux::Binding,
658		waiter: &kio::Waiter,
659	) -> Poll<Option<moq_net::track::Subscribing>> {
660		let Ok(broadcast) = std::task::ready!(binding.poll_broadcast(waiter)) else {
661			return Poll::Ready(None);
662		};
663		let track = broadcast.track(&self.name).ok();
664		let subscription = moq_net::track::Subscription::default().with_max_age(FEED_MAX_DELAY);
665		Poll::Ready(track.map(|track| track.subscribe(subscription).into_inner()))
666	}
667
668	/// The media track handle on `binding`, with that bound broadcast so a fetch can tell a
669	/// gone publisher from a live failure.
670	///
671	/// `binding` is the handle captured with the rows being served. Collecting the answer
672	/// never looks the path up ad hoc: a same-path republish installs a new broadcast whose
673	/// group numbering restarts, and those bytes must not be served under rows produced for
674	/// the publisher it replaced.
675	async fn track(
676		&self,
677		binding: &moq_mux::Binding,
678	) -> Option<(moq_net::broadcast::Consumer, moq_net::track::Consumer)> {
679		let broadcast = binding.broadcast().await.ok()?;
680		let track = broadcast.track(&self.name).ok()?;
681		Some((broadcast, track))
682	}
683
684	/// The rendition's CMAF init segment, built on first request and cached.
685	///
686	/// For inline-parameter-set codecs (no catalog `description`), the parameter sets are
687	/// resolved by fetching the newest keyframe group first.
688	pub async fn init(&self) -> Result<Option<Bytes>> {
689		Ok(self.load_init().await?.map(|init| init.bytes.clone()))
690	}
691
692	/// The init segment served at `init.{hash}.mp4`: `None` when `hash` names any other bytes,
693	/// so a URL never serves an init it does not describe.
694	#[cfg_attr(not(feature = "server"), allow(dead_code))]
695	pub(crate) async fn init_versioned(&self, hash: &str) -> Result<Option<Bytes>> {
696		let init = self.load_init().await?;
697		Ok(init.filter(|init| init.hash == hash).map(|init| init.bytes.clone()))
698	}
699
700	/// `run`'s init if it is already built, or can be without media: an out-of-band codec
701	/// builds its init straight from the catalog.
702	fn built_init(&self, run: &Run) -> Option<Arc<Init>> {
703		if let Some(init) = &run.init {
704			return Some(init.clone());
705		}
706		// A muxer error surfaces from `init()`, which the serve path awaits before rendering.
707		let bytes = self.muxer().ok()?.init().ok()??;
708		Some(self.cache_init(run.epoch, bytes))
709	}
710
711	/// Cache `bytes` as the init of the run numbered `epoch`, unless a restart has since
712	/// replaced that run.
713	fn cache_init(&self, epoch: u64, bytes: Bytes) -> Arc<Init> {
714		let mut run = self.run.lock().expect("run lock poisoned");
715		if run.epoch != epoch {
716			return Arc::new(Init::new(bytes));
717		}
718		run.init.get_or_insert_with(|| Arc::new(Init::new(bytes))).clone()
719	}
720
721	async fn load_init(&self) -> Result<Option<Arc<Init>>> {
722		let binding = self.media.sync(&self.live);
723		// A transmuxed segment can cache the init while another bootstrap holds `building`,
724		// blocked on an unfinished group.
725		if let Some(init) = self.run().init {
726			return Ok(Some(init));
727		}
728		let _building = self.building.lock().await;
729		let run = self.run();
730		if let Some(init) = run.init {
731			return Ok(Some(init));
732		}
733
734		let mut muxer = self.muxer()?;
735
736		// An out-of-band codec builds its init straight from the catalog; an inline one returns
737		// `None` until a keyframe group is read, so bootstrap it from the newest one indexed.
738		let bytes = match muxer.init()? {
739			Some(bytes) => bytes,
740			None => {
741				let Some(sequence) = self.live.latest_keyframe_group() else {
742					return Ok(None);
743				};
744				let Some((broadcast, track)) = self.track(&binding).await else {
745					return Ok(None);
746				};
747				let Ok(mut group) = fetch(&track, sequence, &broadcast).await? else {
748					return Ok(None);
749				};
750				// A cache eviction mid-read leaves the init unbuildable for now, not an error.
751				if read_group(&mut muxer, &mut group, &broadcast).await?.is_err() {
752					return Ok(None);
753				}
754				let Some(bytes) = muxer.init()? else {
755					return Ok(None);
756				};
757				bytes
758			}
759		};
760		Ok(Some(self.cache_init(run.epoch, bytes)))
761	}
762
763	/// Fetch and transmux the segment numbered `segment`.
764	///
765	/// Fetches every group the segment's ranges cover (an audio segment packs many short
766	/// groups) and encodes them as a single CMAF fragment. `None` when the segment isn't in
767	/// the playlist window, is a gap for this rendition, or its groups already left the relay
768	/// cache.
769	pub async fn segment(&self, segment: u64) -> Result<Option<Bytes>> {
770		let binding = self.media.sync(&self.live);
771		// Read the run before the rows (see `restart`), so an init decoded from them is never
772		// cached for a run that started while they were being fetched.
773		let run = self.run();
774		let Some(ranges) = self.live.segment_ranges(segment) else {
775			return Ok(None);
776		};
777		if ranges.is_empty() {
778			// A gap: the record says this rendition has no content for the span.
779			return Ok(None);
780		}
781
782		// A segment must be served whole. If its group span exceeds the safety cap (a
783		// pathologically coarse timeline), 404 rather than silently truncating it to a segment
784		// shorter than its advertised duration.
785		let total: u64 = ranges
786			.iter()
787			.map(|r| r.end.saturating_sub(r.start).saturating_add(1))
788			.sum();
789		if total > MAX_SEGMENT_GROUPS {
790			tracing::warn!(
791				total,
792				"segment spans more than MAX_SEGMENT_GROUPS; refusing to truncate"
793			);
794			return Ok(None);
795		}
796
797		let Some((broadcast, track)) = self.track(&binding).await else {
798			return Ok(None);
799		};
800
801		let mut muxer = self.muxer()?;
802		// Accumulate every group's frames into ONE fragment, so duration inference sees each
803		// frame's true successor. A per-group fragment would mis-time the trailing sample of every
804		// group (its real successor lives in the next group), audible on audio segments that span
805		// many groups.
806		let mut frames = Vec::new();
807		for range in &ranges {
808			for sequence in range.start..=range.end {
809				let miss = match fetch(&track, sequence, &broadcast).await? {
810					Ok(mut group) => match read_group(&mut muxer, &mut group, &broadcast).await? {
811						Ok(mut group_frames) => {
812							frames.append(&mut group_frames);
813							continue;
814						}
815						Err(err) => err,
816					},
817					Err(err) => err,
818				};
819				tracing::warn!(
820					segment,
821					group = sequence,
822					err = %miss,
823					"abandoning an advertised segment: a group is unavailable"
824				);
825				return Ok(None);
826			}
827		}
828
829		if frames.is_empty() {
830			return Ok(None);
831		}
832		let fragment = muxer.fragment(segment as u32, &frames)?;
833		// Inline codec metadata was already decoded from these completed groups.
834		// Reuse it instead of fetching a newer, possibly unfinished timeline tail.
835		if let Some(bytes) = muxer.init()? {
836			self.cache_init(run.epoch, bytes);
837		}
838		Ok(Some(fragment))
839	}
840}
841
842/// Fetch one group, mapping "no longer (or not yet) servable" to the error that says so
843/// rather than a bare `None`: a caller abandoning a segment can only explain itself with it.
844async fn fetch(
845	track: &moq_net::track::Consumer,
846	sequence: u64,
847	broadcast: &moq_net::broadcast::Consumer,
848) -> Result<std::result::Result<moq_net::group::Consumer, moq_net::Error>> {
849	match track.fetch_group(sequence, None).await {
850		Ok(group) => Ok(Ok(group)),
851		Err(err) if is_unservable(&err, broadcast) => Ok(Err(err)),
852		Err(err) => Err(err.into()),
853	}
854}
855
856/// Read a fetched group's frames, mapping a mid-read cache eviction (the group aged out of the
857/// relay while we were reading it) to `None` rather than an error. Without this the eviction would
858/// surface as a 500; it's really the same "group gone" 404 as a fetch-time miss.
859async fn read_group(
860	muxer: &mut Muxer,
861	group: &mut moq_net::group::Consumer,
862	broadcast: &moq_net::broadcast::Consumer,
863) -> Result<std::result::Result<Vec<moq_mux::container::Frame>, moq_net::Error>> {
864	match muxer.read(group).await {
865		Ok(frames) => Ok(Ok(frames)),
866		Err(err) => {
867			let miss = net_errors(&err).find(|err| is_unservable(err, broadcast)).cloned();
868			match miss {
869				Some(miss) => Ok(Err(miss)),
870				None => Err(err.into()),
871			}
872		}
873	}
874}
875
876/// True if a FETCH failure means the segment can never be served: a 404, not a 500.
877fn is_unservable(err: &moq_net::Error, broadcast: &moq_net::broadcast::Consumer) -> bool {
878	is_cache_miss(err) || is_gone_publisher(err, broadcast)
879}
880
881/// True if a moq-net error means the group left (or hasn't reached) the relay cache: a 404, not
882/// a 500.
883///
884/// Matches the named local variants and the stream-scoped codes a moq-lite reset
885/// decodes to after [`StreamError::from_code`](moq_net::StreamError::from_code). IETF has
886/// no stream-reset value for a miss (`ietf::error::to_stream_code` sends INTERNAL_ERROR),
887/// so one arrives as `Error::Stream(StreamError::Internal)` and answers 500. Inspect the
888/// variant, not a collapsed integer: session unauthorized and stream delivery-timeout are
889/// also 2 in their own registries.
890fn is_cache_miss(err: &moq_net::Error) -> bool {
891	matches!(
892		err,
893		moq_net::Error::NotFound
894			| moq_net::Error::Old
895			| moq_net::Error::Evicted
896			| moq_net::Error::Stream(
897				moq_net::StreamError::NotFound | moq_net::StreamError::Old | moq_net::StreamError::Evicted
898			)
899	)
900}
901
902/// True if the bound publisher is gone, so a listed segment can never be served.
903///
904/// A disconnect that crossed a session arrives as `Error::Session(SessionError::Cancel)`;
905/// locally it is [`moq_net::Error::Dropped`]. The bound broadcast being closed is the
906/// condition: matching a session cancel alone would 404 a clean close while the publisher
907/// is still live.
908fn is_gone_publisher(err: &moq_net::Error, broadcast: &moq_net::broadcast::Consumer) -> bool {
909	broadcast.is_closed() || matches!(err, moq_net::Error::Dropped | moq_net::Error::Cancel)
910}
911
912/// [`is_cache_miss`] for a group read, which reaches us wrapped in whichever container the track
913/// uses: plain transport for a raw read, `Hang` for the legacy container (what the JS and native
914/// encoders publish), `Cmaf` for fMP4.
915///
916/// Walks the source chain rather than matching those wrappings, since missing one turns that
917/// container's evictions back into server errors while the rest 404, and `moq_mux::Error` is
918/// `#[non_exhaustive]`: a container added later would silently reintroduce the bug.
919fn net_errors(err: &moq_mux::Error) -> impl Iterator<Item = &moq_net::Error> {
920	std::iter::successors(Some(err as &(dyn std::error::Error + 'static)), |err| err.source())
921		.filter_map(|err| err.downcast_ref::<moq_net::Error>())
922}
923#[cfg(test)]
924mod tests {
925	use super::*;
926
927	/// Decode the code `local` would send on a moq-lite stream reset.
928	fn over_lite(local: &moq_net::Error) -> moq_net::Error {
929		moq_net::Error::from(moq_net::StreamError::from_code(
930			moq_net::StreamError::from(local).to_code(),
931		))
932	}
933
934	/// Decode INTERNAL_ERROR, which is what `ietf::error::to_stream_code` sends for a miss.
935	fn over_ietf_miss() -> moq_net::Error {
936		moq_net::Error::from(moq_net::StreamError::from_code(0))
937	}
938
939	fn produce_origin() -> moq_net::origin::Producer {
940		let (producer, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::default());
941		std::mem::forget(driver);
942		producer
943	}
944
945	#[test]
946	fn a_cache_miss_that_crossed_a_session_is_still_a_cache_miss() {
947		for local in [moq_net::Error::NotFound, moq_net::Error::Old, moq_net::Error::Evicted] {
948			assert!(is_cache_miss(&local), "{local:?} locally");
949			let lite = over_lite(&local);
950			assert!(is_cache_miss(&lite), "{local:?} over lite ({lite:?})");
951			// IETF has no stream-reset value for a miss, so one stays 500.
952			assert!(!is_cache_miss(&over_ietf_miss()), "{local:?} over ietf must not 404");
953		}
954	}
955
956	#[test]
957	fn a_real_failure_is_not_a_cache_miss() {
958		for err in [
959			moq_net::Error::Unauthorized,
960			moq_net::Error::ProtocolViolation,
961			moq_net::Error::Timeout,
962			moq_net::Error::from(moq_net::SessionError::Unauthorized),
963			moq_net::Error::from(moq_net::StreamError::DeliveryTimeout),
964		] {
965			assert!(!is_cache_miss(&err), "{err:?} locally");
966			assert!(!is_cache_miss(&over_lite(&err)), "{err:?} over lite");
967		}
968	}
969
970	#[test]
971	fn a_cache_miss_is_recognised_through_the_mux_error_layers() {
972		let lite = over_lite(&moq_net::Error::NotFound);
973		assert!(net_errors(&moq_mux::Error::Moq(lite.clone())).any(is_cache_miss));
974		assert!(net_errors(&moq_mux::Error::Hang(hang::Error::Moq(lite.clone()))).any(is_cache_miss));
975		assert!(net_errors(&moq_mux::Error::Cmaf(moq_mux::container::fmp4::Error::Moq(lite))).any(is_cache_miss));
976
977		let denied = moq_net::Error::Unauthorized;
978		assert!(!net_errors(&moq_mux::Error::Moq(denied.clone())).any(is_cache_miss));
979		assert!(!net_errors(&moq_mux::Error::Hang(hang::Error::Moq(denied))).any(is_cache_miss));
980		assert!(!net_errors(&moq_mux::Error::UnknownFormat("nope".to_string())).any(is_cache_miss));
981	}
982
983	#[test]
984	fn a_gone_publisher_is_unservable_and_an_ietf_miss_is_not() {
985		let origin = produce_origin();
986		let producer = origin.create_broadcast("live").expect("publish allowed");
987		let live = producer.consume();
988		let ietf_miss = over_ietf_miss();
989		assert!(
990			!is_gone_publisher(&ietf_miss, &live),
991			"an IETF stream-reset miss on a live publisher must stay 500"
992		);
993		assert!(!is_unservable(&ietf_miss, &live));
994
995		drop(producer);
996		let gone = live;
997		assert!(is_gone_publisher(&ietf_miss, &gone));
998		assert!(is_unservable(&ietf_miss, &gone));
999		assert!(is_gone_publisher(&moq_net::Error::Dropped, &gone));
1000		assert!(is_gone_publisher(&moq_net::Error::Cancel, &gone));
1001		assert!(!is_cache_miss(&moq_net::Error::Dropped));
1002		assert!(!is_cache_miss(&moq_net::Error::Remote(0)));
1003	}
1004}