Skip to main content

container/
hls.rs

1//! HLS (HTTP Live Streaming) playlist generation for CMAF VOD output.
2//!
3//! Produces:
4//!   - `master.m3u8` — the top-level multivariant playlist with one
5//!     `#EXT-X-STREAM-INF` per video rendition and one
6//!     `#EXT-X-MEDIA:TYPE=AUDIO` rendition group entry pointing at the
7//!     shared audio playlist.
8//!   - `<rendition>/playlist.m3u8` per video rendition — VOD media
9//!     playlist with `#EXT-X-MAP` referring to the rendition's
10//!     `init.mp4` and `#EXTINF` lines pointing at the
11//!     `seg-NNNNN.m4s` files (relative URIs).
12//!   - `<audio_dir>/audio.m3u8` — the shared audio media playlist.
13//!
14//! Spec: RFC 8216 (HLS) + Apple's HLS Authoring Spec for VOD content,
15//! plus AV1-CMAF-HLS interoperability notes from hls.js's test suite.
16//! We target HLS protocol version 7 — the minimum that supports
17//! `EXT-X-MAP` (fMP4 init segment) and `EXT-X-INDEPENDENT-SEGMENTS`.
18//!
19//! Codec strings (the load-bearing `CODECS` attribute) are passed in
20//! by the caller — they MUST be parsed from the actual encoded
21//! bitstream by [`codec::codec_strings::av1_codec_string`], not
22//! composed from a config file. A wrong string causes hls.js / Safari
23//! to silently skip the variant.
24
25use anyhow::{Context, Result};
26use std::fs::{self, File};
27use std::io::{BufWriter, Write};
28use std::path::{Path, PathBuf};
29
30use crate::cmaf::CmafTrackManifest;
31
32/// Description of one video rendition for the master playlist.
33#[derive(Debug, Clone)]
34pub struct VideoVariantSpec {
35    /// Frame width in pixels (post-scaling, what `RESOLUTION=` reports).
36    pub width: u32,
37    /// Frame height in pixels.
38    pub height: u32,
39    /// Source frame rate. `FRAME-RATE=` is formatted to 3 decimal
40    /// places per Apple's authoring spec (e.g. 29.970, 60.000).
41    pub frame_rate: f64,
42    /// Average bitrate in bits per second. Goes in the
43    /// `AVERAGE-BANDWIDTH=` attribute.
44    pub average_bandwidth_bps: u32,
45    /// Peak bitrate in bits per second — `BANDWIDTH=`. Per RFC 8216
46    /// §4.3.4.2 this is the largest single-segment bitrate observed
47    /// (or, for VBR encoders without per-segment metering, the
48    /// rendition's nominal `max_bitrate`). Players use this for ABR
49    /// switching headroom decisions.
50    pub bandwidth_bps: u32,
51    /// AV1 codec string for the video track. Parse from the encoded
52    /// bitstream via `codec::codec_strings::av1_codec_string`. Joined
53    /// with the audio codec string in `CODECS="..."`.
54    pub codec_string: String,
55    /// Optional SUPPLEMENTAL-CODECS attribute string. Per HLS-Authoring
56    /// Spec §"Supplemental Codecs", this carries an enhanced codec
57    /// signalling that AUGMENTS the `CODECS` attribute — e.g. the
58    /// `dvh1.08.07/db4h` form for Dolby Vision Profile 8 over an HEVC
59    /// base layer.
60    ///
61    /// For pure AV1 HDR there's no base+enhancement model (the
62    /// bitstream IS the HDR content), so the canonical pattern when
63    /// HDR encode lands (Squad-22 dep) will be parallel SDR + HDR
64    /// renditions in the master rather than supplemental signalling
65    /// on a single variant. This field exists so that future
66    /// supplemental-codec patterns (Dolby Vision over AV1 if/when
67    /// that becomes a thing, AV2, etc.) can be wired in without a
68    /// schema change.
69    ///
70    /// Format when set: `"<codec>/<compat>[/<compat>...]"`. None
71    /// today; field is plumbed for forward compat.
72    pub supplemental_codecs: Option<String>,
73    /// VIDEO-RANGE attribute on the STREAM-INF. Per HLS spec, allowed
74    /// values: "SDR" (default, omitted when at SDR), "HLG", "PQ".
75    /// Set to `Some("PQ")` for HDR10 sources, `Some("HLG")` for HLG.
76    /// None for SDR (omitted from output — HLS authors recommend
77    /// omitting the attribute when at SDR rather than emitting
78    /// `VIDEO-RANGE=SDR` explicitly).
79    pub video_range: Option<&'static str>,
80    /// Relative directory under the asset root, e.g. `"video/1080p"`.
81    /// The variant's `playlist.m3u8` URI in the master is
82    /// `<relative_dir>/playlist.m3u8`.
83    pub relative_dir: String,
84    /// CMAF track manifest produced by the segmenter. Source for the
85    /// `EXT-X-MAP` URI + per-segment `EXTINF` durations.
86    pub manifest: CmafTrackManifest,
87}
88
89/// Description of one audio rendition. CMAF-HLS uses a separate
90/// rendition group so video variants can switch bitrate without
91/// touching the audio track. We currently emit exactly one audio
92/// rendition (default English / undetermined-language); multi-track
93/// audio is a future task.
94#[derive(Debug, Clone)]
95pub struct AudioVariantSpec {
96    /// Codec string for the audio track — typically
97    /// `AAC_LC_CODEC_STRING` (`mp4a.40.2`).
98    pub codec_string: String,
99    /// Channel count. Goes in `CHANNELS="<n>"` per RFC 8216 §4.3.4.2.
100    pub channels: u16,
101    /// Sample rate in Hz. Informational; not surfaced in the playlist
102    /// directly but kept on the spec so the validator can verify the
103    /// init segment matches.
104    #[allow(dead_code)]
105    pub sample_rate: u32,
106    /// Relative directory under the asset root, e.g. `"audio"`.
107    pub relative_dir: String,
108    /// BCP-47 language tag — `"en"`, `"es"`, `"und"` for undetermined.
109    pub language: String,
110    /// Human-readable rendition name. Players use this in their UI.
111    pub name: String,
112    pub manifest: CmafTrackManifest,
113}
114
115/// Paths produced by [`write_hls_package`]. Useful for the integration
116/// test + the wire-contract reporter that surfaces a manifest URL to
117/// lewd.net.
118#[derive(Debug, Clone)]
119pub struct HlsManifestPaths {
120    pub master_path: PathBuf,
121    pub video_playlist_paths: Vec<PathBuf>,
122    /// `None` when the source has no audio (video-only HLS package).
123    /// Master playlist + video playlists exist; no audio rendition
124    /// group in master, no `audio/audio.m3u8` on disk.
125    pub audio_playlist_path: Option<PathBuf>,
126}
127
128/// Emit a complete CMAF-HLS playlist tree under `output_dir`.
129///
130/// `output_dir` is the asset's root (e.g. `output/<asset_id>`). The
131/// CMAF segments referenced by `manifest` fields are NOT moved — they
132/// stay where the segmenters wrote them (the manifest paths must
133/// already be under `output_dir`).
134///
135/// `target_duration_seconds` is the value emitted in
136/// `#EXT-X-TARGETDURATION` for every media playlist. Per RFC 8216
137/// §4.3.3.1 it's an upper bound on `EXTINF` and must be rounded UP
138/// to the nearest integer; pass the configured CMAF segment duration.
139pub fn write_hls_package(
140    output_dir: &Path,
141    video_variants: &[VideoVariantSpec],
142    audio: Option<&AudioVariantSpec>,
143    target_duration_seconds: u32,
144) -> Result<HlsManifestPaths> {
145    fs::create_dir_all(output_dir)
146        .with_context(|| format!("creating HLS output dir: {}", output_dir.display()))?;
147
148    // Per-variant video playlists.
149    let mut video_playlist_paths = Vec::with_capacity(video_variants.len());
150    for v in video_variants {
151        let dir = output_dir.join(&v.relative_dir);
152        fs::create_dir_all(&dir)
153            .with_context(|| format!("creating video variant dir: {}", dir.display()))?;
154        let path = dir.join("playlist.m3u8");
155        write_media_playlist(&path, &v.manifest, target_duration_seconds)
156            .with_context(|| format!("writing video media playlist: {}", path.display()))?;
157        video_playlist_paths.push(path);
158    }
159
160    // Audio playlist (optional — None for video-only sources).
161    let audio_playlist_path = if let Some(audio) = audio {
162        let audio_dir = output_dir.join(&audio.relative_dir);
163        fs::create_dir_all(&audio_dir)
164            .with_context(|| format!("creating audio variant dir: {}", audio_dir.display()))?;
165        let path = audio_dir.join("audio.m3u8");
166        write_media_playlist(&path, &audio.manifest, target_duration_seconds)
167            .with_context(|| format!("writing audio media playlist: {}", path.display()))?;
168        Some(path)
169    } else {
170        None
171    };
172
173    // Master playlist last so its existence is the "all done" signal
174    // for any external watcher polling for the asset to appear.
175    let master_path = output_dir.join("master.m3u8");
176    write_master_playlist(&master_path, video_variants, audio)
177        .with_context(|| format!("writing master playlist: {}", master_path.display()))?;
178
179    Ok(HlsManifestPaths {
180        master_path,
181        video_playlist_paths,
182        audio_playlist_path,
183    })
184}
185
186/// Write a single media playlist file.
187///
188/// Format per RFC 8216 §4.3:
189///   #EXTM3U
190///   #EXT-X-VERSION:7
191///   #EXT-X-TARGETDURATION:<rounded-up>
192///   #EXT-X-PLAYLIST-TYPE:VOD
193///   #EXT-X-MAP:URI="init.mp4"
194///   #EXTINF:<exact_duration>,
195///   seg-NNNNN.m4s
196///   ...
197///   #EXT-X-ENDLIST
198///
199/// The init/segment URIs are RELATIVE — same directory as the playlist
200/// itself. CMAF muxers write into the variant's directory by
201/// construction so this resolves cleanly without any path computation.
202fn write_media_playlist(
203    path: &Path,
204    manifest: &CmafTrackManifest,
205    target_duration_seconds: u32,
206) -> Result<()> {
207    let file = File::create(path)?;
208    let mut w = BufWriter::new(file);
209
210    writeln!(w, "#EXTM3U")?;
211    writeln!(w, "#EXT-X-VERSION:7")?;
212    writeln!(w, "#EXT-X-TARGETDURATION:{}", target_duration_seconds)?;
213    writeln!(w, "#EXT-X-PLAYLIST-TYPE:VOD")?;
214    writeln!(
215        w,
216        "#EXT-X-MAP:URI=\"{}\"",
217        manifest
218            .init_path
219            .file_name()
220            .and_then(|s| s.to_str())
221            .unwrap_or("init.mp4")
222    )?;
223
224    for seg in &manifest.segments {
225        let dur = seg.duration_ticks as f64 / manifest.timescale as f64;
226        // Apple HLS authoring requires 6 decimal places minimum for
227        // EXTINF on VOD content so the cumulative duration matches
228        // what playback computes. Trailing comma per RFC 8216 §4.3.2.1.
229        writeln!(w, "#EXTINF:{:.6},", dur)?;
230        let name = seg
231            .path
232            .file_name()
233            .and_then(|s| s.to_str())
234            .ok_or_else(|| anyhow::anyhow!("segment path has no filename"))?;
235        writeln!(w, "{name}")?;
236    }
237
238    writeln!(w, "#EXT-X-ENDLIST")?;
239    w.flush()?;
240    Ok(())
241}
242
243/// Write the master (multivariant) playlist.
244///
245/// Format per RFC 8216 §4.3.4 + Apple HLS Authoring Spec:
246///   #EXTM3U
247///   #EXT-X-VERSION:7
248///   #EXT-X-INDEPENDENT-SEGMENTS
249///   #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",...,URI="audio/audio.m3u8"
250///   #EXT-X-STREAM-INF:BANDWIDTH=...,RESOLUTION=...x...,CODECS="av01,...,mp4a.40.2",AUDIO="aac"
251///   video/1080p/playlist.m3u8
252///   ...
253///
254/// Variants are emitted in ascending bandwidth order — players with
255/// limited ABR heuristics (older hls.js, Safari < 14) walk the list
256/// linearly and pick the first variant that fits, so the order
257/// matters in practice.
258fn write_master_playlist(
259    path: &Path,
260    video_variants: &[VideoVariantSpec],
261    audio: Option<&AudioVariantSpec>,
262) -> Result<()> {
263    let body = render_master_playlist_to_string(video_variants, audio);
264    let file = File::create(path)?;
265    let mut w = BufWriter::new(file);
266    w.write_all(body.as_bytes())?;
267    w.flush()?;
268    Ok(())
269}
270
271/// Render the master playlist as an in-memory string. Internal helper
272/// for [`write_master_playlist`]. The transcoder no longer publishes a
273/// `master.m3u8` to S3 (see commit 7197885 reverted by the follow-up
274/// 2026-05-08 change): the backend builds the master document on
275/// every viewer request so signed URLs + per-viewer permissions
276/// (subscription tier, follower-only, paid-content bucket selection)
277/// can be applied correctly. This function stays in tree because
278/// `write_master_playlist` still produces an on-disk `master.m3u8`
279/// that the orchestrator's pipeline tests rely on; it does NOT
280/// participate in the production wire contract anymore.
281fn render_master_playlist_to_string(
282    video_variants: &[VideoVariantSpec],
283    audio: Option<&AudioVariantSpec>,
284) -> String {
285    use std::fmt::Write;
286
287    let mut out = String::with_capacity(256 + video_variants.len() * 192);
288    let _ = writeln!(out, "#EXTM3U");
289    let _ = writeln!(out, "#EXT-X-VERSION:7");
290    let _ = writeln!(out, "#EXT-X-INDEPENDENT-SEGMENTS");
291    let _ = writeln!(out);
292
293    // Audio rendition group — only when source has an audio track.
294    // For video-only sources we skip the EXT-X-MEDIA block AND drop
295    // the AUDIO= attribute on each STREAM-INF. hls.js + native HLS
296    // both handle the audio-less master cleanly.
297    if let Some(audio) = audio {
298        let _ = write!(out, "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aac\"");
299        let _ = write!(out, ",NAME=\"{}\"", escape_attr(&audio.name));
300        let _ = write!(out, ",DEFAULT=YES,AUTOSELECT=YES");
301        let _ = write!(out, ",LANGUAGE=\"{}\"", escape_attr(&audio.language));
302        let _ = write!(out, ",CHANNELS=\"{}\"", audio.channels);
303        let _ = writeln!(out, ",URI=\"{}/audio.m3u8\"", audio.relative_dir);
304        let _ = writeln!(out);
305    }
306
307    // Video variants ordered by ascending bandwidth.
308    let mut sorted: Vec<&VideoVariantSpec> = video_variants.iter().collect();
309    sorted.sort_by_key(|v| v.bandwidth_bps);
310
311    for v in sorted {
312        let _ = write!(out, "#EXT-X-STREAM-INF");
313        let _ = write!(out, ":BANDWIDTH={}", v.bandwidth_bps);
314        let _ = write!(out, ",AVERAGE-BANDWIDTH={}", v.average_bandwidth_bps);
315        // CODECS is the failure mode if it's wrong — players silently
316        // skip variants whose CODECS string they can't decode. The
317        // string MUST come from bitstream parsing, never from config.
318        // Audio-less sources drop the trailing `,mp4a.40.2` component.
319        match audio {
320            Some(audio) => {
321                let _ = write!(out, ",CODECS=\"{},{}\"", v.codec_string, audio.codec_string);
322            }
323            None => {
324                let _ = write!(out, ",CODECS=\"{}\"", v.codec_string);
325            }
326        }
327        if let Some(supp) = v.supplemental_codecs.as_ref() {
328            let _ = write!(out, ",SUPPLEMENTAL-CODECS=\"{}\"", supp);
329        }
330        if let Some(vr) = v.video_range {
331            let _ = write!(out, ",VIDEO-RANGE={}", vr);
332        }
333        let _ = write!(out, ",RESOLUTION={}x{}", v.width, v.height);
334        let _ = write!(out, ",FRAME-RATE={:.3}", v.frame_rate);
335        if audio.is_some() {
336            let _ = writeln!(out, ",AUDIO=\"aac\"");
337        } else {
338            let _ = writeln!(out);
339        }
340        let _ = writeln!(out, "{}/playlist.m3u8", v.relative_dir);
341    }
342
343    out
344}
345
346/// Escape characters that aren't legal inside an HLS attribute-value
347/// quoted string. Per RFC 8216 §4.2 the quoted string MUST NOT
348/// contain a literal `"`, line feed, or carriage return. We strip
349/// rather than escape (HLS has no escape syntax for these).
350fn escape_attr(s: &str) -> String {
351    s.chars()
352        .filter(|c| *c != '"' && *c != '\n' && *c != '\r')
353        .collect()
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use crate::cmaf::SegmentInfo;
360
361    fn synth_manifest(timescale: u32, durations_ticks: &[u64]) -> CmafTrackManifest {
362        let segments: Vec<SegmentInfo> = durations_ticks
363            .iter()
364            .enumerate()
365            .map(|(i, &d)| SegmentInfo {
366                sequence_number: (i + 1) as u32,
367                path: PathBuf::from(format!("seg-{:05}.m4s", i + 1)),
368                byte_size: 1024,
369                duration_ticks: d,
370            })
371            .collect();
372        CmafTrackManifest {
373            init_path: PathBuf::from("init.mp4"),
374            segments,
375            timescale,
376        }
377    }
378
379    #[test]
380    fn media_playlist_includes_all_required_v7_tags() {
381        let dir = tempfile::tempdir().unwrap();
382        let path = dir.path().join("playlist.m3u8");
383        let manifest = synth_manifest(30000, &[120_000, 120_000, 120_000]);
384        write_media_playlist(&path, &manifest, 4).unwrap();
385        let body = fs::read_to_string(&path).unwrap();
386        assert!(body.starts_with("#EXTM3U\n"));
387        assert!(body.contains("#EXT-X-VERSION:7\n"));
388        assert!(body.contains("#EXT-X-TARGETDURATION:4\n"));
389        assert!(body.contains("#EXT-X-PLAYLIST-TYPE:VOD\n"));
390        assert!(body.contains("#EXT-X-MAP:URI=\"init.mp4\""));
391        assert!(body.contains("#EXTINF:4.000000,"));
392        assert!(body.contains("seg-00001.m4s\n"));
393        assert!(body.contains("seg-00003.m4s\n"));
394        assert!(body.trim_end().ends_with("#EXT-X-ENDLIST"));
395    }
396
397    #[test]
398    fn media_playlist_uses_real_segment_durations_not_nominal() {
399        let dir = tempfile::tempdir().unwrap();
400        let path = dir.path().join("playlist.m3u8");
401        // Three segments of slightly different durations (last one short).
402        let manifest = synth_manifest(30000, &[120_000, 120_000, 87_500]);
403        write_media_playlist(&path, &manifest, 4).unwrap();
404        let body = fs::read_to_string(&path).unwrap();
405        // 87500 / 30000 = 2.9166666...
406        assert!(body.contains("#EXTINF:2.916667,"), "got: {body}");
407    }
408
409    #[test]
410    fn master_playlist_orders_variants_by_ascending_bandwidth() {
411        let dir = tempfile::tempdir().unwrap();
412        let path = dir.path().join("master.m3u8");
413        let video_manifest = synth_manifest(30000, &[120_000]);
414
415        let v1080 = VideoVariantSpec {
416            width: 1920,
417            height: 1080,
418            frame_rate: 30.0,
419            average_bandwidth_bps: 3_000_000,
420            bandwidth_bps: 4_500_000,
421            codec_string: "av01.0.08M.08.0.001.001.001.0".into(),
422            supplemental_codecs: None,
423            video_range: None,
424            relative_dir: "video/1080p".into(),
425            manifest: video_manifest.clone(),
426        };
427        let v720 = VideoVariantSpec {
428            width: 1280,
429            height: 720,
430            frame_rate: 30.0,
431            average_bandwidth_bps: 1_600_000,
432            bandwidth_bps: 2_400_000,
433            codec_string: "av01.0.06M.08.0.001.001.001.0".into(),
434            supplemental_codecs: None,
435            video_range: None,
436            relative_dir: "video/720p".into(),
437            manifest: video_manifest.clone(),
438        };
439        let v480 = VideoVariantSpec {
440            width: 854,
441            height: 480,
442            frame_rate: 30.0,
443            average_bandwidth_bps: 800_000,
444            bandwidth_bps: 1_200_000,
445            codec_string: "av01.0.04M.08.0.001.001.001.0".into(),
446            supplemental_codecs: None,
447            video_range: None,
448            relative_dir: "video/480p".into(),
449            manifest: video_manifest.clone(),
450        };
451
452        let audio = AudioVariantSpec {
453            codec_string: "mp4a.40.2".into(),
454            channels: 2,
455            sample_rate: 48000,
456            relative_dir: "audio".into(),
457            language: "und".into(),
458            name: "Default".into(),
459            manifest: synth_manifest(48000, &[192_000]),
460        };
461
462        // Pass them in REVERSE bandwidth order to verify sorting.
463        write_master_playlist(&path, &[v1080, v720, v480], Some(&audio)).unwrap();
464        let body = fs::read_to_string(&path).unwrap();
465
466        // Find 480p, 720p, 1080p positions; assert ascending order.
467        let p480 = body
468            .find("video/480p/playlist.m3u8")
469            .expect("480p variant present");
470        let p720 = body
471            .find("video/720p/playlist.m3u8")
472            .expect("720p variant present");
473        let p1080 = body
474            .find("video/1080p/playlist.m3u8")
475            .expect("1080p variant present");
476        assert!(p480 < p720, "480p must come before 720p");
477        assert!(p720 < p1080, "720p must come before 1080p");
478    }
479
480    #[test]
481    fn master_playlist_emits_required_top_level_tags() {
482        let dir = tempfile::tempdir().unwrap();
483        let path = dir.path().join("master.m3u8");
484        let video_manifest = synth_manifest(30000, &[120_000]);
485        let v = VideoVariantSpec {
486            width: 1920,
487            height: 1080,
488            frame_rate: 30.0,
489            average_bandwidth_bps: 3_000_000,
490            bandwidth_bps: 4_500_000,
491            codec_string: "av01.0.08M.08.0.001.001.001.0".into(),
492            supplemental_codecs: None,
493            video_range: None,
494            relative_dir: "video/1080p".into(),
495            manifest: video_manifest,
496        };
497        let audio = AudioVariantSpec {
498            codec_string: "mp4a.40.2".into(),
499            channels: 2,
500            sample_rate: 48000,
501            relative_dir: "audio".into(),
502            language: "und".into(),
503            name: "Default".into(),
504            manifest: synth_manifest(48000, &[192_000]),
505        };
506        write_master_playlist(&path, &[v], Some(&audio)).unwrap();
507        let body = fs::read_to_string(&path).unwrap();
508
509        assert!(body.starts_with("#EXTM3U"));
510        assert!(body.contains("#EXT-X-VERSION:7"));
511        assert!(body.contains("#EXT-X-INDEPENDENT-SEGMENTS"));
512        assert!(body.contains("#EXT-X-MEDIA:TYPE=AUDIO"));
513        assert!(body.contains("GROUP-ID=\"aac\""));
514        assert!(body.contains("DEFAULT=YES"));
515        assert!(body.contains("URI=\"audio/audio.m3u8\""));
516        assert!(body.contains("#EXT-X-STREAM-INF"));
517        assert!(body.contains("BANDWIDTH=4500000"));
518        assert!(body.contains("AVERAGE-BANDWIDTH=3000000"));
519        assert!(body.contains("CODECS=\"av01.0.08M.08.0.001.001.001.0,mp4a.40.2\""));
520        assert!(body.contains("RESOLUTION=1920x1080"));
521        assert!(body.contains("FRAME-RATE=30.000"));
522        assert!(body.contains("AUDIO=\"aac\""));
523    }
524
525    #[test]
526    fn write_hls_package_emits_full_directory_tree() {
527        let dir = tempfile::tempdir().unwrap();
528        let video_manifest = CmafTrackManifest {
529            init_path: dir.path().join("video/1080p/init.mp4"),
530            segments: vec![SegmentInfo {
531                sequence_number: 1,
532                path: dir.path().join("video/1080p/seg-00001.m4s"),
533                byte_size: 1024,
534                duration_ticks: 120_000,
535            }],
536            timescale: 30000,
537        };
538        let audio_manifest = CmafTrackManifest {
539            init_path: dir.path().join("audio/init.mp4"),
540            segments: vec![SegmentInfo {
541                sequence_number: 1,
542                path: dir.path().join("audio/seg-00001.m4s"),
543                byte_size: 256,
544                duration_ticks: 192_000,
545            }],
546            timescale: 48000,
547        };
548
549        let v = VideoVariantSpec {
550            width: 1920,
551            height: 1080,
552            frame_rate: 30.0,
553            average_bandwidth_bps: 3_000_000,
554            bandwidth_bps: 4_500_000,
555            codec_string: "av01.0.08M.08.0.001.001.001.0".into(),
556            supplemental_codecs: None,
557            video_range: None,
558            relative_dir: "video/1080p".into(),
559            manifest: video_manifest,
560        };
561        let a = AudioVariantSpec {
562            codec_string: "mp4a.40.2".into(),
563            channels: 2,
564            sample_rate: 48000,
565            relative_dir: "audio".into(),
566            language: "und".into(),
567            name: "Default".into(),
568            manifest: audio_manifest,
569        };
570
571        let paths = write_hls_package(dir.path(), &[v], Some(&a), 4).unwrap();
572
573        assert!(paths.master_path.exists());
574        assert_eq!(paths.video_playlist_paths.len(), 1);
575        assert!(paths.video_playlist_paths[0].exists());
576        let audio_pl_path = paths.audio_playlist_path.expect("audio playlist set");
577        assert!(audio_pl_path.exists());
578
579        // Spot-check the audio playlist.
580        let audio_pl = fs::read_to_string(&audio_pl_path).unwrap();
581        assert!(audio_pl.contains("#EXT-X-MAP:URI=\"init.mp4\""));
582        assert!(audio_pl.contains("#EXTINF:4.000000,"));
583        assert!(audio_pl.contains("seg-00001.m4s"));
584    }
585
586    #[test]
587    fn master_playlist_omits_audio_when_video_only() {
588        let dir = tempfile::tempdir().unwrap();
589        let path = dir.path().join("master.m3u8");
590        let video_manifest = synth_manifest(30000, &[120_000]);
591        let v = VideoVariantSpec {
592            width: 1920,
593            height: 1080,
594            frame_rate: 30.0,
595            average_bandwidth_bps: 3_000_000,
596            bandwidth_bps: 4_500_000,
597            codec_string: "av01.0.08M.08".into(),
598            supplemental_codecs: None,
599            video_range: None,
600            relative_dir: "video/1080p".into(),
601            manifest: video_manifest,
602        };
603        write_master_playlist(&path, &[v], None).unwrap();
604        let body = fs::read_to_string(&path).unwrap();
605
606        assert!(body.starts_with("#EXTM3U"));
607        assert!(body.contains("#EXT-X-VERSION:7"));
608        assert!(body.contains("#EXT-X-INDEPENDENT-SEGMENTS"));
609        // No audio rendition group.
610        assert!(!body.contains("#EXT-X-MEDIA:TYPE=AUDIO"), "got: {body}");
611        // CODECS attr should NOT include the AAC component.
612        assert!(body.contains("CODECS=\"av01.0.08M.08\""), "got: {body}");
613        assert!(!body.contains("mp4a.40.2"), "got: {body}");
614        // STREAM-INF should NOT have the AUDIO= attribute.
615        assert!(!body.contains("AUDIO=\"aac\""), "got: {body}");
616    }
617
618    #[test]
619    fn write_hls_package_video_only_emits_no_audio_dir() {
620        let dir = tempfile::tempdir().unwrap();
621        let video_manifest = CmafTrackManifest {
622            init_path: dir.path().join("video/720p/init.mp4"),
623            segments: vec![SegmentInfo {
624                sequence_number: 1,
625                path: dir.path().join("video/720p/seg-00001.m4s"),
626                byte_size: 1024,
627                duration_ticks: 120_000,
628            }],
629            timescale: 30000,
630        };
631        let v = VideoVariantSpec {
632            width: 1280,
633            height: 720,
634            frame_rate: 30.0,
635            average_bandwidth_bps: 1_600_000,
636            bandwidth_bps: 2_400_000,
637            codec_string: "av01.0.05M.08".into(),
638            supplemental_codecs: None,
639            video_range: None,
640            relative_dir: "video/720p".into(),
641            manifest: video_manifest,
642        };
643        let paths = write_hls_package(dir.path(), &[v], None, 4).unwrap();
644        assert!(paths.master_path.exists());
645        assert_eq!(paths.video_playlist_paths.len(), 1);
646        assert!(paths.audio_playlist_path.is_none());
647        assert!(
648            !dir.path().join("audio").exists(),
649            "no audio dir should be created"
650        );
651    }
652
653    #[test]
654    fn escape_attr_strips_disallowed_characters() {
655        assert_eq!(escape_attr(r#"hello"world"#), "helloworld");
656        assert_eq!(escape_attr("with\nnewline"), "withnewline");
657        assert_eq!(escape_attr("with\rcarriage"), "withcarriage");
658        assert_eq!(escape_attr("normal text"), "normal text");
659    }
660}