multimux 0.10.0

Multi-input (RTSP/RTP/TS-UDP/TS-HTTP/SRT/HLS-pull/DASH-pull/Smooth-pull/RTMP/file), multi-output (LL-HLS/DASH/LL-DASH/Smooth/TS-HLS/catch-up + SRT/RTMP/RTSP push) just-in-time repackaging HTTP origin (library: tokio + axum), with shared output auth and an external scheme plugin registry.
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
//! `TsHlsOutput`: the classic MPEG-TS HLS [`crate::output::Output`]
//! implementation (issue #887) — a thin tokio+axum adapter over the same
//! sans-IO origin engine [`llhls`](crate::output::llhls) uses
//! ([`hls_runtime::server::HlsOrigin`]), except this route's `HlsOrigin` is
//! built with `.container(Container::MpegTs)` (see
//! `crate::route::ProgramServing::new`) rather than the default
//! `Container::Fmp4`: master/media playlists here reference whole `.ts`
//! media segments instead of fMP4 `.m4s` fragments, and the media playlist
//! carries no `#EXT-X-MAP` (RFC 8216bis §3.1.1's PAT/PMT-or-`EXT-X-MAP`
//! disjunction — a self-initialising `.ts` segment needs neither).
//!
//! The init/segment byte ranges these playlists reference are served by the
//! origin's *shared* resource route (`crate::origin::resource`), exactly like
//! [`llhls`](crate::output::llhls) — see that module's own docs and
//! `crate::output`'s module doc for why one shared route serves both
//! containers.
//!
//! Which container a route is built with is decided once, at
//! `crate::origin::serve_with_registry`'s route-construction time, from
//! whether [`crate::config::Route::outputs`] names
//! [`crate::output::OutputKind::TsHls`] — mutually exclusive with
//! `llhls`/`dash`/`ll_dash` on the same route
//! (`crate::config::Route::validate_standalone`).
//!
//! Master/media playlist tags are RFC 8216 §4.3.4 (`#EXT-X-STREAM-INF`) and
//! §4.3.3 (`#EXTM3U`/`#EXT-X-VERSION`/`#EXTINF`/`#EXT-X-ENDLIST`, rendered by
//! [`hls_runtime::server::master_playlist_m3u8`] for the master playlist and
//! `HlsOrigin`'s own renderer for the media playlist). No LL-HLS blocking-
//! reload query parameters are meaningful for classic HLS (there is no
//! `part_target_ms`/`.low_latency(..)` on a `ts_hls` route's `HlsOrigin` at
//! all — see `crate::route::ProgramServing::new`), but this route reuses the
//! same [`crate::output::llhls::BlockingReloadQuery`]/`resolve_blocking` wait
//! machinery as LL-HLS anyway: it degrades harmlessly to "no query params ->
//! render immediately" for a classic client, and reusing it (rather than a
//! parallel bespoke wait loop) is exactly the "one adapter, not one per
//! output" discipline `crate::http`'s own module doc requires.

use std::sync::Arc;

use axum::Router;
use axum::extract::{Query, State};
use axum::http::{StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use hls_runtime::server::{DEFAULT_TRACK_ID, HlsBody, HlsRequest, master_playlist_m3u8};

use crate::http::{self, BLOCKING_RELOAD_TIMEOUT};
use crate::origin::resource::{BlockingRequestGuard, cors_preflight};
use crate::output::llhls::BlockingReloadQuery;
use crate::output::{Output, OutputKind};
use crate::route::RouteHandle;

const MEDIA_PLAYLIST_CONTENT_TYPE: &str = "application/vnd.apple.mpegurl";

/// Default media-playlist filename — mirrors
/// [`crate::output::llhls::DEFAULT_PLAYLIST_NAME`] (both `"media.m3u8"`);
/// kept as this module's own constant (rather than re-exporting the LL-HLS
/// one) since the two outputs are configured independently
/// (`crate::config::Config::playlist_name` applies to whichever single
/// fMP4-or-TS output is actually configured on a route — the two are
/// mutually exclusive, so there is never a name clash to resolve).
pub const DEFAULT_PLAYLIST_NAME: &str = "media.m3u8";

/// The classic TS-HLS [`Output`]: master/media playlists over a shared
/// [`RouteHandle`] whose `HlsOrigin` is configured for
/// [`hls_runtime::server::Container::MpegTs`] (see this module's own doc).
/// Init/segment byte ranges are the origin's shared resource route, not this
/// one — see the module docs.
pub struct TsHlsOutput {
    playlist_name: String,
}

impl Default for TsHlsOutput {
    fn default() -> Self {
        TsHlsOutput::new(DEFAULT_PLAYLIST_NAME)
    }
}

impl TsHlsOutput {
    /// Serves the media playlist at `/{playlist_name}` instead of the
    /// default `/media.m3u8`.
    pub fn new(playlist_name: impl Into<String>) -> Self {
        TsHlsOutput {
            playlist_name: playlist_name.into(),
        }
    }
}

/// The axum state for [`TsHlsOutput`]'s manifest routes — mirrors
/// [`crate::output::llhls::LlHlsState`]'s shape exactly.
#[derive(Clone)]
pub(crate) struct TsHlsState {
    route: Arc<RouteHandle>,
    playlist_name: String,
}

impl Output for TsHlsOutput {
    fn kind(&self) -> OutputKind {
        OutputKind::TsHls
    }

    /// Routes (relative — mounted by the origin under `/{stream}/`):
    /// - `GET /master.m3u8` — minimal single-variant master playlist.
    /// - `GET /{playlist_name}` — classic media playlist (`/media.m3u8`
    ///   unless [`TsHlsOutput::new`] configured a different name).
    fn manifest_routes(&self, route: Arc<RouteHandle>) -> Router {
        let state = TsHlsState {
            route,
            playlist_name: self.playlist_name.clone(),
        };
        Router::new()
            .route("/master.m3u8", get(master_playlist).options(cors_preflight))
            .route(
                &format!("/{}", self.playlist_name),
                get(media_playlist).options(cors_preflight),
            )
            .with_state(state)
    }
}

/// `GET /master.m3u8` — a minimal single-variant master playlist pointing at
/// this route's configured media-playlist filename.
pub(crate) async fn master_playlist(State(state): State<TsHlsState>) -> Response {
    (
        [(header::CONTENT_TYPE, MEDIA_PLAYLIST_CONTENT_TYPE)],
        master_playlist_m3u8(&state.playlist_name),
    )
        .into_response()
}

/// `GET /media.m3u8` — the classic media playlist for [`DEFAULT_TRACK_ID`].
/// See this module's own doc for why this reuses
/// [`crate::output::llhls::BlockingReloadQuery`]/`resolve_blocking` even
/// though a `ts_hls` route's `HlsOrigin` never enables LL-HLS.
pub(crate) async fn media_playlist(
    State(state): State<TsHlsState>,
    Query(q): Query<BlockingReloadQuery>,
) -> Response {
    let serving = match http::resolve_route_program(&state.route) {
        Ok(serving) => serving,
        Err(resp) => return *resp,
    };
    let trunk = serving.trunk();
    let ll_hls = serving.ll_hls();
    let request = HlsRequest::Playlist {
        track_id: DEFAULT_TRACK_ID,
        query: q.into(),
    };
    let resp = http::resolve_blocking(
        &trunk,
        ll_hls.as_ref(),
        request,
        BLOCKING_RELOAD_TIMEOUT,
        BlockingRequestGuard::new,
    )
    .await;
    http::into_response(resp, StatusCode::NOT_FOUND, |body| match body {
        HlsBody::Playlist(m) => {
            ([(header::CONTENT_TYPE, MEDIA_PLAYLIST_CONTENT_TYPE)], m).into_response()
        }
        HlsBody::Resource(_) => StatusCode::NOT_FOUND.into_response(),
        // `HlsBody` is `#[non_exhaustive]`; a future body variant this
        // playlist route doesn't understand is treated the same as a
        // resource body -- not found here.
        _ => StatusCode::NOT_FOUND.into_response(),
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::route::RouteHandle;
    use hls_runtime::server::Container;
    use transmux::ll_hls::SegmentInfo;

    /// A non-whole-second duration (4.2s, not 4.0s): `broadcast_hls`'s own
    /// `#EXT-X-VERSION` derivation only bumps the version for a duration that
    /// actually *renders* with a decimal point (RFC 8216 §8 row 3) — a whole
    /// `4.0` renders as bare `4` and triggers nothing. Needed so
    /// `media_playlist_version_is_broadcast_hls_derived_not_hardcoded` below
    /// has a real, non-trivial version to compare against.
    fn seg(seq: u32, byte: u8) -> SegmentInfo {
        SegmentInfo {
            bytes: vec![byte; 8],
            duration: 4.2,
            segment_seq: seq,
            part_count: 0,
        }
    }

    /// A populated classic-TS route: two closed segments. Publishes
    /// `SPTS_PROGRAM_ID` into the registry first (`publish_new_program`), so
    /// `media_playlist`'s `resolve_route_program` lookup sees `Found` — see
    /// `crate::output::llhls`'s identical test fixture for the same shape.
    fn make_route() -> Arc<RouteHandle> {
        let route = Arc::new(RouteHandle::new(4.0, 500, 4).with_container(Container::MpegTs));
        route.publish_new_program(crate::route::SPTS_PROGRAM_ID);
        route.add_segment(crate::route::SPTS_PROGRAM_ID, seg(1, 0x21));
        route.add_segment(crate::route::SPTS_PROGRAM_ID, seg(2, 0x22));
        route
    }

    async fn body_string(resp: Response) -> String {
        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
            .await
            .unwrap();
        String::from_utf8(bytes.to_vec()).unwrap()
    }

    fn state(route: Arc<RouteHandle>) -> TsHlsState {
        TsHlsState {
            route,
            playlist_name: DEFAULT_PLAYLIST_NAME.to_string(),
        }
    }

    #[tokio::test]
    async fn master_playlist_ok() {
        let route = make_route();
        let resp = master_playlist(State(state(route))).await;
        assert_eq!(resp.status(), StatusCode::OK);
        let body = body_string(resp).await;
        assert!(body.contains("#EXTM3U"));
        assert!(body.contains("#EXT-X-STREAM-INF"));
        assert!(body.contains("media.m3u8"));
    }

    /// The defining behaviour of this output (issue #887): served segments
    /// are `.ts`, and the media playlist carries no `#EXT-X-MAP` at all —
    /// `Container::MpegTs`'s segments are self-initialising (in-band
    /// PAT/PMT), so there is no init segment to reference.
    ///
    /// MUTATION VERIFIED: forcing `crate::route::ProgramServing::new` to
    /// always call `.container(Container::Fmp4)` regardless of the route's
    /// configured container (simulating the pre-#887 fMP4-only wiring) makes
    /// this test's `assert!(!body.contains("#EXT-X-MAP"))` fail: the rendered
    /// body contains `#EXT-X-MAP:URI="init-1.mp4"` (actual) where none was
    /// expected, and the segment URIs render as `seg-1-1.m4s`/`seg-1-2.m4s`
    /// (`.m4s`) instead of `.ts`, failing
    /// `assert!(body.contains("seg-1-1.ts"))` too. Recompiled and re-run to
    /// confirm both failures, then reverted.
    #[tokio::test]
    async fn media_playlist_serves_ts_segments_with_no_ext_x_map() {
        let route = make_route();
        let resp = media_playlist(State(state(route)), Query(BlockingReloadQuery::default())).await;
        assert_eq!(resp.status(), StatusCode::OK);
        let body = body_string(resp).await;
        assert!(body.contains("#EXTINF:"), "body: {body}");
        assert!(body.contains("seg-1-1.ts"), "body: {body}");
        assert!(body.contains("seg-1-2.ts"), "body: {body}");
        assert!(
            !body.contains("#EXT-X-MAP"),
            "a classic TS media playlist must never advertise an init segment: {body}"
        );
        assert!(
            !body.contains(".m4s"),
            "a classic TS route must never reference fMP4 segment filenames: {body}"
        );
    }

    /// The rendered `#EXT-X-VERSION` must be `broadcast-hls`'s own
    /// content-derived value (RFC 8216bis §8), never a value this crate
    /// chose ahead of time — proven by constructing an equivalent
    /// `broadcast_hls::MediaPlaylist` from the same segment data fed into
    /// `make_route` and asserting the two agree, rather than asserting a
    /// bare integer literal.
    #[tokio::test]
    async fn media_playlist_version_is_broadcast_hls_derived_not_hardcoded() {
        let route = make_route();
        let resp = media_playlist(State(state(route)), Query(BlockingReloadQuery::default())).await;
        let body = body_string(resp).await;
        let rendered_version: u8 = body
            .lines()
            .find_map(|l| l.strip_prefix("#EXT-X-VERSION:"))
            .expect("a rendered media playlist always carries #EXT-X-VERSION here")
            .parse()
            .expect("#EXT-X-VERSION value must be a valid integer");

        // Same shape `HlsOrigin::render_playlist` builds for this route's two
        // segments (floating-point `#EXTINF` durations, no low-latency/i-frame
        // tags) -- `broadcast_hls::MediaPlaylist::computed_version` is the one
        // and only source either side derives its version from.
        let equivalent = broadcast_hls::MediaPlaylist {
            target_duration: 4,
            media_sequence: 1,
            segments: vec![
                broadcast_hls::MediaSegment {
                    uri: "seg-1-1.ts".to_string(),
                    duration: 4.2,
                    ..Default::default()
                },
                broadcast_hls::MediaSegment {
                    uri: "seg-1-2.ts".to_string(),
                    duration: 4.2,
                    ..Default::default()
                },
            ],
            ..Default::default()
        };
        let expected_version = equivalent
            .computed_version()
            .expect("floating-point EXTINF durations trigger a real version floor");
        assert_eq!(
            rendered_version, expected_version,
            "the served playlist's #EXT-X-VERSION must equal broadcast-hls's own \
             content-derived value for equivalent segment data, not a value multimux chose"
        );
    }

    /// **Advertised == servable** (issue #887). Drives the SAME real axum
    /// router shape `crate::origin::router` assembles for a stream (the
    /// shared resource route merged with this output's manifest routes) —
    /// renders the playlist through it, extracts every `.ts` URI **from the
    /// rendered text** (not from test-internal knowledge of what was
    /// published), requests each one back through that same router, and
    /// asserts the served bytes are byte-identical to what was published via
    /// `RouteHandle::add_segment`. Proves the whole HTTP path end to end,
    /// not just that `HlsOrigin::resolve` agrees with itself.
    #[tokio::test]
    async fn advertised_ts_segment_is_exactly_what_was_published() {
        let route = make_route();
        let router = crate::origin::resource::router(route.clone())
            .merge(TsHlsOutput::default().manifest_routes(route));

        let playlist_req = axum::http::Request::builder()
            .method("GET")
            .uri("/media.m3u8")
            .body(axum::body::Body::empty())
            .unwrap();
        let playlist_resp = tower::ServiceExt::oneshot(router.clone(), playlist_req)
            .await
            .unwrap();
        assert_eq!(playlist_resp.status(), StatusCode::OK);
        let playlist = body_string(playlist_resp).await;

        let uris: Vec<&str> = playlist
            .lines()
            .filter(|l| !l.starts_with('#') && l.ends_with(".ts"))
            .collect();
        assert_eq!(
            uris.len(),
            2,
            "make_route() published exactly 2 segments: {playlist}"
        );

        for uri in uris {
            let req = axum::http::Request::builder()
                .method("GET")
                .uri(format!("/{uri}"))
                .body(axum::body::Body::empty())
                .unwrap();
            let resp = tower::ServiceExt::oneshot(router.clone(), req)
                .await
                .unwrap();
            assert_eq!(
                resp.status(),
                StatusCode::OK,
                "requesting advertised uri {uri:?}"
            );
            let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
                .await
                .unwrap();
            // `make_route()` published segment 1 as all-`0x21` bytes and
            // segment 2 as all-`0x22` -- matching which one this URI names
            // proves the served bytes are exactly what was published, not
            // merely "some 8 bytes".
            let expected_byte = if uri.contains("-1.ts") {
                0x21u8
            } else {
                0x22u8
            };
            assert_eq!(
                bytes.to_vec(),
                vec![expected_byte; 8],
                "served bytes for advertised uri {uri:?} must match what was published"
            );
        }
    }

    /// `manifest_routes`' own `OPTIONS` preflight — mirrors
    /// `crate::output::llhls`'s identical test.
    #[tokio::test]
    async fn options_preflight_returns_no_content() {
        let route = make_route();
        let router = TsHlsOutput::default().manifest_routes(route);
        let req = axum::http::Request::builder()
            .method("OPTIONS")
            .uri("/media.m3u8")
            .body(axum::body::Body::empty())
            .unwrap();
        let resp = tower::ServiceExt::oneshot(router, req).await.unwrap();
        assert_eq!(resp.status(), StatusCode::NO_CONTENT);
    }

    /// MUTATION VERIFIED (mirrors `crate::output::llhls`'s identical test): a
    /// route with no program announced yet must be `503`, not `404`.
    #[tokio::test]
    async fn media_playlist_not_yet_announced_is_503_not_404() {
        let route = Arc::new(RouteHandle::new(4.0, 500, 4).with_container(Container::MpegTs));
        let resp = media_playlist(State(state(route)), Query(BlockingReloadQuery::default())).await;
        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
    }
}