Skip to main content

sheathe_package/
descriptor.rs

1//! Shaka-style stream descriptors (`in=file,stream=audio,language=eng,…`).
2//!
3//! A bare filesystem path is accepted as “all tracks in this file”.
4
5use anyhow::{Context, Result, bail};
6use sheathe_core::MediaKind;
7use std::path::{Path, PathBuf};
8
9/// Which tracks of an input to package.
10#[derive(Debug, Clone, PartialEq, Eq, Default)]
11pub enum StreamSelector {
12    /// Every audio, video, and text track (sheathe default).
13    #[default]
14    All,
15    /// First track of this kind.
16    Kind(MediaKind),
17    /// Zero-based stream index in demuxer order.
18    Index(usize),
19}
20
21/// Per-stream overrides mirroring Shaka Packager stream_descriptor fields.
22#[derive(Debug, Clone, Default)]
23pub struct StreamDescriptor {
24    /// Input path (`input=` / `in=`).
25    pub input: PathBuf,
26    /// Track selector (`stream=`).
27    pub selector: StreamSelector,
28    /// Optional output / init name (`output=` / `init_segment=`).
29    pub output: Option<String>,
30    /// Optional segment name template (`segment_template=`). `$Number$` is replaced.
31    pub segment_template: Option<String>,
32    /// Manifest bandwidth override, bits/sec (`bandwidth=` / `bw=`).
33    pub bandwidth: Option<u32>,
34    /// Language tag override (`language=` / `lang=`).
35    pub language: Option<String>,
36    /// Skip CENC for this stream (`skip_encryption=1`).
37    pub skip_encryption: bool,
38    /// DRM key label (`drm_label=`): AUDIO, SD, HD, UHD1, UHD2, or a `--keys` label.
39    pub drm_label: Option<String>,
40    /// Trick-play factor: keep every Nth keyframe (`trick_play_factor=` / `tpf=`).
41    pub trick_play_factor: Option<u32>,
42    /// HLS `#EXT-X-MEDIA` NAME (`hls_name=`).
43    pub hls_name: Option<String>,
44    /// HLS `#EXT-X-MEDIA` GROUP-ID (`hls_group_id=`).
45    pub hls_group_id: Option<String>,
46    /// HLS media playlist filename (`playlist_name=`).
47    pub playlist_name: Option<String>,
48    /// HLS I-frame playlist filename (`iframe_playlist_name=`).
49    pub iframe_playlist_name: Option<String>,
50    /// HLS CHARACTERISTICS (`hls_characteristics=` / `charcs=`), colon/semicolon split.
51    pub hls_characteristics: Vec<String>,
52    /// DASH Accessibility `schemeIdUri=value` (`dash_accessibilities=`).
53    pub dash_accessibilities: Vec<(String, String)>,
54    /// DASH Role values (`dash_roles=`).
55    pub dash_roles: Vec<String>,
56    /// Forced narrative subtitle (`forced_subtitle=1`).
57    pub forced_subtitle: bool,
58}
59
60impl StreamDescriptor {
61    /// Package every track in `path`.
62    pub fn from_path(path: impl Into<PathBuf>) -> Self {
63        Self { input: path.into(), ..Self::default() }
64    }
65
66    /// True when this descriptor selects `kind` at demuxer index `index`.
67    pub fn matches(&self, index: usize, kind: MediaKind) -> bool {
68        match self.selector {
69            StreamSelector::All => true,
70            StreamSelector::Kind(k) => k == kind,
71            StreamSelector::Index(i) => i == index,
72        }
73    }
74}
75
76/// Parse a CLI argument: a filesystem path, or a Shaka `key=value,key=value` descriptor.
77pub fn parse_input_arg(arg: &str) -> Result<StreamDescriptor> {
78    if Path::new(arg).exists() || !arg.contains('=') {
79        return Ok(StreamDescriptor::from_path(arg));
80    }
81    parse_descriptor(arg)
82}
83
84/// Parse a Shaka stream_descriptor string.
85pub fn parse_descriptor(spec: &str) -> Result<StreamDescriptor> {
86    let mut d = StreamDescriptor::default();
87    for part in spec.split(',') {
88        let part = part.trim();
89        if part.is_empty() {
90            continue;
91        }
92        let (key, value) = part
93            .split_once('=')
94            .with_context(|| format!("stream descriptor field '{part}' is not key=value"))?;
95        let key = key.trim();
96        let value = value.trim();
97        match key {
98            "input" | "in" => d.input = PathBuf::from(value),
99            "stream" | "stream_selector" => d.selector = parse_selector(value)?,
100            "output" | "out" | "init_segment" => d.output = Some(value.to_string()),
101            "segment_template" | "segment" => d.segment_template = Some(value.to_string()),
102            "bandwidth" | "bw" => {
103                d.bandwidth = Some(value.parse().context("bandwidth must be an integer")?);
104            }
105            "language" | "lang" => d.language = Some(value.to_string()),
106            "skip_encryption" => d.skip_encryption = value != "0" && value != "false",
107            "drm_label" => d.drm_label = Some(value.to_string()),
108            "trick_play_factor" | "tpf" => {
109                d.trick_play_factor = Some(value.parse().context("trick_play_factor")?);
110            }
111            "hls_name" => d.hls_name = Some(value.to_string()),
112            "hls_group_id" => d.hls_group_id = Some(value.to_string()),
113            "playlist_name" => d.playlist_name = Some(value.to_string()),
114            "iframe_playlist_name" => d.iframe_playlist_name = Some(value.to_string()),
115            "hls_characteristics" | "charcs" => {
116                d.hls_characteristics =
117                    value.split([':', ';']).filter(|s| !s.is_empty()).map(str::to_string).collect();
118            }
119            "dash_accessibilities" | "accessibilities" => {
120                for item in value.split(';').filter(|s| !s.is_empty()) {
121                    let (scheme, v) = item
122                        .split_once('=')
123                        .context("dash_accessibilities entries must be scheme_id_uri=value")?;
124                    d.dash_accessibilities.push((scheme.to_string(), v.to_string()));
125                }
126            }
127            "dash_roles" | "roles" => {
128                d.dash_roles =
129                    value.split(';').filter(|s| !s.is_empty()).map(str::to_string).collect();
130            }
131            "forced_subtitle" => d.forced_subtitle = value != "0" && value != "false",
132            "output_format" | "input_format" | "format" => { /* accepted, ignored */ }
133            other => bail!("unknown stream descriptor field '{other}'"),
134        }
135    }
136    anyhow::ensure!(!d.input.as_os_str().is_empty(), "stream descriptor missing input=/in=");
137    Ok(d)
138}
139
140fn parse_selector(value: &str) -> Result<StreamSelector> {
141    match value.to_ascii_lowercase().as_str() {
142        "audio" => Ok(StreamSelector::Kind(MediaKind::Audio)),
143        "video" => Ok(StreamSelector::Kind(MediaKind::Video)),
144        "text" => Ok(StreamSelector::Kind(MediaKind::Text)),
145        n => {
146            let i: usize = n.parse().with_context(|| {
147                format!("stream selector '{value}' (expected audio, video, text, or an index)")
148            })?;
149            Ok(StreamSelector::Index(i))
150        }
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn bare_path() {
160        let d = parse_input_arg("movie.mp4").unwrap();
161        assert_eq!(d.input, PathBuf::from("movie.mp4"));
162        assert_eq!(d.selector, StreamSelector::All);
163    }
164
165    #[test]
166    fn shaka_descriptor() {
167        let d = parse_descriptor(
168            "in=movie.mp4,stream=audio,language=eng,hls_name=English,drm_label=AUDIO,skip_encryption=1",
169        )
170        .unwrap();
171        assert_eq!(d.input, PathBuf::from("movie.mp4"));
172        assert_eq!(d.selector, StreamSelector::Kind(MediaKind::Audio));
173        assert_eq!(d.language.as_deref(), Some("eng"));
174        assert_eq!(d.hls_name.as_deref(), Some("English"));
175        assert_eq!(d.drm_label.as_deref(), Some("AUDIO"));
176        assert!(d.skip_encryption);
177    }
178
179    #[test]
180    fn roles_and_accessibilities() {
181        let d = parse_descriptor(
182            "in=a.mp4,stream=text,dash_roles=subtitle;forced-subtitle,dash_accessibilities=urn:foo=bar,forced_subtitle=1",
183        )
184        .unwrap();
185        assert_eq!(d.dash_roles, vec!["subtitle", "forced-subtitle"]);
186        assert_eq!(d.dash_accessibilities, vec![("urn:foo".into(), "bar".into())]);
187        assert!(d.forced_subtitle);
188    }
189}