use anyhow::{Context, Result, bail};
use sheathe_core::MediaKind;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum StreamSelector {
#[default]
All,
Kind(MediaKind),
Index(usize),
}
#[derive(Debug, Clone, Default)]
pub struct StreamDescriptor {
pub input: PathBuf,
pub selector: StreamSelector,
pub output: Option<String>,
pub segment_template: Option<String>,
pub bandwidth: Option<u32>,
pub language: Option<String>,
pub skip_encryption: bool,
pub drm_label: Option<String>,
pub trick_play_factor: Option<u32>,
pub hls_name: Option<String>,
pub hls_group_id: Option<String>,
pub playlist_name: Option<String>,
pub iframe_playlist_name: Option<String>,
pub hls_characteristics: Vec<String>,
pub dash_accessibilities: Vec<(String, String)>,
pub dash_roles: Vec<String>,
pub forced_subtitle: bool,
}
impl StreamDescriptor {
pub fn from_path(path: impl Into<PathBuf>) -> Self {
Self { input: path.into(), ..Self::default() }
}
pub fn matches(&self, index: usize, kind: MediaKind) -> bool {
match self.selector {
StreamSelector::All => true,
StreamSelector::Kind(k) => k == kind,
StreamSelector::Index(i) => i == index,
}
}
}
pub fn parse_input_arg(arg: &str) -> Result<StreamDescriptor> {
if Path::new(arg).exists() || !arg.contains('=') {
return Ok(StreamDescriptor::from_path(arg));
}
parse_descriptor(arg)
}
pub fn parse_descriptor(spec: &str) -> Result<StreamDescriptor> {
let mut d = StreamDescriptor::default();
for part in spec.split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
let (key, value) = part
.split_once('=')
.with_context(|| format!("stream descriptor field '{part}' is not key=value"))?;
let key = key.trim();
let value = value.trim();
match key {
"input" | "in" => d.input = PathBuf::from(value),
"stream" | "stream_selector" => d.selector = parse_selector(value)?,
"output" | "out" | "init_segment" => d.output = Some(value.to_string()),
"segment_template" | "segment" => d.segment_template = Some(value.to_string()),
"bandwidth" | "bw" => {
d.bandwidth = Some(value.parse().context("bandwidth must be an integer")?);
}
"language" | "lang" => d.language = Some(value.to_string()),
"skip_encryption" => d.skip_encryption = value != "0" && value != "false",
"drm_label" => d.drm_label = Some(value.to_string()),
"trick_play_factor" | "tpf" => {
d.trick_play_factor = Some(value.parse().context("trick_play_factor")?);
}
"hls_name" => d.hls_name = Some(value.to_string()),
"hls_group_id" => d.hls_group_id = Some(value.to_string()),
"playlist_name" => d.playlist_name = Some(value.to_string()),
"iframe_playlist_name" => d.iframe_playlist_name = Some(value.to_string()),
"hls_characteristics" | "charcs" => {
d.hls_characteristics =
value.split([':', ';']).filter(|s| !s.is_empty()).map(str::to_string).collect();
}
"dash_accessibilities" | "accessibilities" => {
for item in value.split(';').filter(|s| !s.is_empty()) {
let (scheme, v) = item
.split_once('=')
.context("dash_accessibilities entries must be scheme_id_uri=value")?;
d.dash_accessibilities.push((scheme.to_string(), v.to_string()));
}
}
"dash_roles" | "roles" => {
d.dash_roles =
value.split(';').filter(|s| !s.is_empty()).map(str::to_string).collect();
}
"forced_subtitle" => d.forced_subtitle = value != "0" && value != "false",
"output_format" | "input_format" | "format" => { }
other => bail!("unknown stream descriptor field '{other}'"),
}
}
anyhow::ensure!(!d.input.as_os_str().is_empty(), "stream descriptor missing input=/in=");
Ok(d)
}
fn parse_selector(value: &str) -> Result<StreamSelector> {
match value.to_ascii_lowercase().as_str() {
"audio" => Ok(StreamSelector::Kind(MediaKind::Audio)),
"video" => Ok(StreamSelector::Kind(MediaKind::Video)),
"text" => Ok(StreamSelector::Kind(MediaKind::Text)),
n => {
let i: usize = n.parse().with_context(|| {
format!("stream selector '{value}' (expected audio, video, text, or an index)")
})?;
Ok(StreamSelector::Index(i))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bare_path() {
let d = parse_input_arg("movie.mp4").unwrap();
assert_eq!(d.input, PathBuf::from("movie.mp4"));
assert_eq!(d.selector, StreamSelector::All);
}
#[test]
fn shaka_descriptor() {
let d = parse_descriptor(
"in=movie.mp4,stream=audio,language=eng,hls_name=English,drm_label=AUDIO,skip_encryption=1",
)
.unwrap();
assert_eq!(d.input, PathBuf::from("movie.mp4"));
assert_eq!(d.selector, StreamSelector::Kind(MediaKind::Audio));
assert_eq!(d.language.as_deref(), Some("eng"));
assert_eq!(d.hls_name.as_deref(), Some("English"));
assert_eq!(d.drm_label.as_deref(), Some("AUDIO"));
assert!(d.skip_encryption);
}
#[test]
fn roles_and_accessibilities() {
let d = parse_descriptor(
"in=a.mp4,stream=text,dash_roles=subtitle;forced-subtitle,dash_accessibilities=urn:foo=bar,forced_subtitle=1",
)
.unwrap();
assert_eq!(d.dash_roles, vec!["subtitle", "forced-subtitle"]);
assert_eq!(d.dash_accessibilities, vec![("urn:foo".into(), "bar".into())]);
assert!(d.forced_subtitle);
}
}