use std::ffi::{OsStr, OsString};
use std::time::Duration;
use clap::{ArgGroup, Args, CommandFactory, Parser, Subcommand};
use hang::moq_net;
use crate::publish::PublishFormat;
use crate::subscribe::{CatalogFormatArg, SubscribeFormat};
#[derive(Parser, Clone)]
#[command(name = "moq", version = env!("VERSION"))]
#[command(after_help = "Separate additional import/export stages with `--`; they share one \
connection and one Origin. Every `--` starts a stage, so it is not an \
end-of-options marker: write a path starting with `-` as `./-name`.")]
pub struct Cli {
#[command(flatten)]
pub log: moq_native::Log,
#[command(flatten)]
pub moq: MoqSide,
#[command(subcommand)]
pub command: Command,
}
#[derive(Parser, Clone)]
#[command(name = "moq", no_binary_name = true)]
pub struct Stage {
#[command(subcommand)]
pub command: Command,
}
pub struct Invocation {
pub log: moq_native::Log,
pub moq: MoqSide,
pub stages: Vec<Command>,
}
impl Invocation {
pub fn parse() -> Self {
match Self::try_parse_from(std::env::args_os()) {
Ok(parsed) => parsed,
Err(err) => err.exit(),
}
}
pub fn try_parse_from<I, T>(argv: I) -> Result<Self, clap::Error>
where
I: IntoIterator<Item = T>,
T: Into<OsString>,
{
let argv: Vec<OsString> = argv.into_iter().map(Into::into).collect();
let mut chunks = argv.split(|arg| arg == OsStr::new("--"));
let cli = Cli::try_parse_from(chunks.next().unwrap_or_default())?;
let mut stages = vec![cli.command];
for chunk in chunks {
if chunk.is_empty() {
return Err(Stage::command().error(
clap::error::ErrorKind::MissingSubcommand,
"`--` starts another stage, so it must be followed by `import` or `export`",
));
}
stages.push(Stage::try_parse_from(chunk)?.command);
}
Ok(Self {
log: cli.log,
moq: cli.moq,
stages,
})
}
pub fn validate(&self) -> anyhow::Result<()> {
if self.stages.len() == 1 {
return Ok(());
}
if let Some(command) = self.stages.iter().find(|command| !command.is_stageable()) {
anyhow::bail!(
"`{}` must be the only verb; it can't share a process with another `--` stage",
command.name()
);
}
let imports = self
.stages
.iter()
.filter(|stage| matches!(stage, Command::Import(_)))
.count();
let adaptive = self
.stages
.iter()
.any(|stage| matches!(stage, Command::Import(import) if import.source.uses_bandwidth()));
anyhow::ensure!(
self.moq.client.connect.is_none() || !adaptive || imports == 1,
"a stage that encodes to fit the connection's bandwidth estimate assumes it's the only \
publisher on that connection, but this runs {imports} import stages; run them as separate \
processes, or publish over --server-bind, which has no estimate"
);
Ok(())
}
}
#[derive(Args, Clone)]
#[command(group = ArgGroup::new("moq").multiple(true).args(["client-connect", "server-bind"]))]
pub struct MoqSide {
#[arg(long, alias = "name", help_heading = "MoQ")]
pub broadcast: Option<String>,
#[arg(long, env = "MOQ_ORIGIN", help_heading = "MoQ")]
pub origin: Option<u64>,
#[command(flatten)]
pub client: moq_native::ClientConfig,
#[command(flatten)]
pub server: moq_native::ServerConfig,
#[cfg(feature = "iroh")]
#[command(flatten)]
pub iroh: moq_native::iroh::EndpointConfig,
}
impl MoqSide {
pub fn origin(&self) -> anyhow::Result<moq_net::origin::Producer> {
use anyhow::Context;
Ok(match self.origin {
Some(id) => moq_net::Origin::new(id).with_context(|| format!("invalid --origin {id}"))?,
None => moq_net::Origin::random(),
}
.produce())
}
pub fn validate(&self) -> anyhow::Result<()> {
anyhow::ensure!(
self.client.connect.is_some() || self.server.bind.is_some(),
"a MoQ side is required: pass --client-connect <url> to dial a relay, or --server-bind <addr> to self-host"
);
Ok(())
}
pub fn reject(&self, command: &str) -> anyhow::Result<()> {
let ignored = [
("--client-connect", self.client.connect.is_some()),
("--server-bind", self.server.bind.is_some()),
("--broadcast", self.broadcast.is_some()),
];
if let Some((flag, _)) = ignored.into_iter().find(|(_, given)| *given) {
anyhow::bail!("`{command}` runs locally and takes no MoQ side; drop {flag}");
}
Ok(())
}
}
#[derive(Subcommand, Clone)]
pub enum Command {
#[command(alias = "publish")]
Import(Import),
#[command(alias = "subscribe")]
Export(Export),
#[cfg(feature = "play")]
Play(crate::play::Args),
#[cfg(feature = "transcode")]
Transcode(crate::transcode::Args),
Token(moq_token_cli::Args),
#[cfg(feature = "capture")]
Devices,
}
impl Command {
pub fn name(&self) -> &'static str {
match self {
Self::Import(_) => "import",
Self::Export(_) => "export",
#[cfg(feature = "play")]
Self::Play(_) => "play",
#[cfg(feature = "transcode")]
Self::Transcode(_) => "transcode",
Self::Token(_) => "token",
#[cfg(feature = "capture")]
Self::Devices => "devices",
}
}
pub fn is_stageable(&self) -> bool {
matches!(self, Self::Import(_) | Self::Export(_))
}
pub fn broadcast(&self, moq: &MoqSide) -> String {
let stage = match self {
Self::Import(import) => import.broadcast.as_deref(),
Self::Export(export) => export.broadcast.as_deref(),
_ => None,
};
stage.or(moq.broadcast.as_deref()).unwrap_or_default().to_string()
}
}
#[derive(Args, Clone)]
pub struct Import {
#[arg(long, alias = "name")]
pub broadcast: Option<String>,
#[arg(long, value_parser = humantime::parse_duration)]
pub latency_max: Option<std::time::Duration>,
#[command(subcommand)]
pub source: ImportSource,
}
#[derive(Subcommand, Clone)]
pub enum ImportSource {
Avc3,
Fmp4,
Ts,
Flv,
Hls(crate::hls::ImportArgs),
Rtmp(crate::rtmp::Args),
Srt(crate::srt::Args),
Rtc(crate::rtc::Args),
#[cfg(feature = "capture")]
Capture(crate::publish::CaptureArgs),
}
impl ImportSource {
pub fn stdin_format(&self) -> Option<PublishFormat> {
Some(match self {
Self::Avc3 => PublishFormat::Avc3,
Self::Fmp4 => PublishFormat::Fmp4,
Self::Ts => PublishFormat::Ts,
Self::Flv => PublishFormat::Flv,
_ => return None,
})
}
pub fn uses_bandwidth(&self) -> bool {
match self {
#[cfg(feature = "capture")]
Self::Capture(capture) => !capture.no_video,
_ => false,
}
}
pub fn honors_latency_max(&self) -> bool {
if self.stdin_format().is_some() {
return true;
}
match self {
Self::Hls(_) => true,
#[cfg(feature = "capture")]
Self::Capture(_) => true,
_ => false,
}
}
}
#[derive(Args, Clone)]
pub struct Export {
#[arg(long, alias = "name")]
pub broadcast: Option<String>,
#[arg(long = "catalog-format")]
pub catalog_format: Option<CatalogFormatArg>,
#[command(flatten)]
pub select: crate::subscribe::SelectArgs,
#[command(subcommand)]
pub sink: ExportSink,
}
#[derive(Subcommand, Clone)]
pub enum ExportSink {
Fmp4(Fragmented),
Mkv(Fragmented),
Ts(Container),
Flv(Container),
H264(Container),
H265(Container),
Hls(crate::hls::ExportArgs),
Rtmp(crate::rtmp::ExportArgs),
Srt(crate::srt::Args),
Rtc(crate::rtc::Args),
}
impl ExportSink {
pub fn stdout(&self) -> Option<(SubscribeFormat, Duration, Option<Duration>)> {
Some(match self {
Self::Fmp4(args) => (
SubscribeFormat::Fmp4,
args.container.latency_max,
args.fragment_duration,
),
Self::Mkv(args) => (SubscribeFormat::Mkv, args.container.latency_max, args.fragment_duration),
Self::Ts(args) => (SubscribeFormat::Ts, args.latency_max, None),
Self::Flv(args) => (SubscribeFormat::Flv, args.latency_max, None),
Self::H264(args) => (SubscribeFormat::H264, args.latency_max, None),
Self::H265(args) => (SubscribeFormat::H265, args.latency_max, None),
_ => return None,
})
}
}
#[derive(Args, Clone)]
pub struct Container {
#[arg(long = "latency-max", default_value = "500ms", value_parser = humantime::parse_duration)]
pub latency_max: Duration,
}
#[derive(Args, Clone)]
pub struct Fragmented {
#[command(flatten)]
pub container: Container,
#[arg(long, value_parser = humantime::parse_duration)]
pub fragment_duration: Option<Duration>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid() {
Cli::command().debug_assert();
}
#[test]
fn valid_stage() {
Stage::command().debug_assert();
}
#[test]
fn single_stage() {
let cli = Invocation::try_parse_from(["moq", "--client-connect", "http://relay", "import", "ts"]).unwrap();
assert_eq!(cli.stages.len(), 1);
assert_eq!(cli.stages[0].name(), "import");
assert!(cli.validate().is_ok());
}
#[test]
fn multiple_stages() {
let cli = Invocation::try_parse_from([
"moq",
"--client-connect",
"http://localhost:4444/event",
"import",
"--broadcast",
"cam1.hang",
"rtmp",
"--listen",
"0.0.0.0:1935",
"--",
"import",
"--broadcast",
"cam2.hang",
"rtmp",
"--listen",
"0.0.0.0:1936",
"--",
"export",
"--broadcast",
"cam1.hang",
"hls",
"--listen",
"0.0.0.0:8080",
])
.unwrap();
assert!(cli.validate().is_ok());
assert_eq!(cli.stages.len(), 3);
assert_eq!(
cli.moq.client.connect.as_ref().map(ToString::to_string).as_deref(),
Some("http://localhost:4444/event")
);
let names: Vec<String> = cli.stages.iter().map(|stage| stage.broadcast(&cli.moq)).collect();
assert_eq!(names, ["cam1.hang", "cam2.hang", "cam1.hang"]);
assert_eq!(cli.stages[2].name(), "export");
}
#[test]
fn broadcast_falls_back_to_the_global() {
let cli = Invocation::try_parse_from([
"moq",
"--client-connect",
"http://relay",
"--broadcast",
"room.hang",
"import",
"ts",
"--",
"export",
"--broadcast",
"other.hang",
"fmp4",
])
.unwrap();
assert_eq!(cli.stages[0].broadcast(&cli.moq), "room.hang");
assert_eq!(cli.stages[1].broadcast(&cli.moq), "other.hang");
}
#[test]
fn broadcast_defaults_to_root() {
let cli = Invocation::try_parse_from(["moq", "--client-connect", "http://relay", "import", "ts"]).unwrap();
assert_eq!(cli.stages[0].broadcast(&cli.moq), "");
}
#[test]
fn rejects_unstageable_verbs() {
let cli = Invocation::try_parse_from([
"moq",
"--client-connect",
"http://relay",
"import",
"ts",
"--",
"token",
"generate",
"--algorithm",
"ES256",
])
.unwrap();
let err = cli.validate().unwrap_err().to_string();
assert!(err.contains("token"), "{err}");
}
#[test]
fn stage_errors_are_parse_errors() {
let Err(err) = Invocation::try_parse_from([
"moq",
"--client-connect",
"http://relay",
"import",
"ts",
"--",
"import",
"rtmp",
"--bogus",
]) else {
panic!("expected a parse error")
};
assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
}
#[test]
fn a_dash_prefixed_path_is_written_relative() {
let cli = Invocation::try_parse_from([
"moq",
"--client-connect",
"http://relay",
"import",
"hls",
"./-odd.m3u8",
])
.unwrap();
let Command::Import(import) = &cli.stages[0] else {
panic!("expected import")
};
let ImportSource::Hls(hls) = &import.source else {
panic!("expected hls")
};
assert_eq!(hls.playlist, "./-odd.m3u8");
}
#[test]
fn rejects_an_empty_stage() {
for argv in [
vec!["moq", "--client-connect", "http://relay", "import", "ts", "--"],
vec![
"moq",
"--client-connect",
"http://relay",
"import",
"ts",
"--",
"--",
"export",
"fmp4",
],
] {
let Err(err) = Invocation::try_parse_from(argv.clone()) else {
panic!("expected a parse error for {argv:?}")
};
assert_eq!(err.kind(), clap::error::ErrorKind::MissingSubcommand);
assert!(err.to_string().contains("must be followed by"), "{err}");
}
}
#[test]
fn stages_reject_globals() {
let Err(err) = Invocation::try_parse_from([
"moq",
"--client-connect",
"http://relay",
"import",
"ts",
"--",
"--client-connect",
"http://other",
"import",
"fmp4",
]) else {
panic!("expected a parse error")
};
assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
}
#[test]
fn imports_without_rate_control_can_share_a_connection() {
let cli = Invocation::try_parse_from([
"moq",
"--client-connect",
"http://relay",
"import",
"--broadcast",
"a.hang",
"rtmp",
"--listen",
"127.0.0.1:1935",
"--",
"import",
"--broadcast",
"b.hang",
"srt",
"--listen",
"127.0.0.1:9000",
])
.unwrap();
assert!(cli.validate().is_ok());
}
#[cfg(feature = "capture")]
#[test]
fn an_adaptive_capture_must_be_the_only_import() {
let client: &[&str] = &["--client-connect", "http://relay"];
let server: &[&str] = &["--server-bind", "[::]:4443"];
let cases: [(&[&str], &[&str], bool); 3] = [
(client, &["import", "capture"], false),
(client, &["import", "rtmp", "--listen", "127.0.0.1:1935"], false),
(server, &["import", "capture"], true),
];
for (side, second, ok) in cases {
let argv = [&["moq"][..], side, &["import", "capture", "--"], second].concat();
let cli = Invocation::try_parse_from(argv.clone()).unwrap();
assert_eq!(cli.validate().is_ok(), ok, "{argv:?}");
}
let cli = Invocation::try_parse_from([
"moq",
"--client-connect",
"http://relay",
"import",
"capture",
"--no-video",
"--",
"import",
"rtmp",
"--listen",
"127.0.0.1:1935",
])
.unwrap();
assert!(cli.validate().is_ok());
let cli = Invocation::try_parse_from([
"moq",
"--client-connect",
"http://relay",
"import",
"capture",
"--",
"export",
"--broadcast",
"other.hang",
"fmp4",
])
.unwrap();
assert!(cli.validate().is_ok());
}
#[cfg(feature = "capture")]
#[test]
fn audio_only_capture_is_not_bandwidth_adaptive() {
for (args, adaptive) in [(vec!["capture"], true), (vec!["capture", "--no-video"], false)] {
let argv = [vec!["moq", "--client-connect", "http://relay", "import"], args].concat();
let cli = Invocation::try_parse_from(argv).unwrap();
let Command::Import(import) = &cli.stages[0] else {
panic!("expected import")
};
assert_eq!(import.source.uses_bandwidth(), adaptive);
}
}
#[test]
fn latency_max_is_unset_unless_asked_for() {
let cli = Cli::try_parse_from(["moq", "import", "ts"]).unwrap();
let Command::Import(import) = cli.command else {
panic!("expected import")
};
assert_eq!(import.latency_max, None);
assert!(import.source.honors_latency_max());
let cli = Cli::try_parse_from(["moq", "import", "--latency-max", "5s", "ts"]).unwrap();
let Command::Import(import) = cli.command else {
panic!("expected import")
};
assert_eq!(import.latency_max, Some(std::time::Duration::from_secs(5)));
let cli = Cli::try_parse_from(["moq", "import", "rtmp", "--listen", "127.0.0.1:1935"]).unwrap();
let Command::Import(import) = cli.command else {
panic!("expected import")
};
assert!(!import.source.honors_latency_max());
}
#[test]
fn token_verb() {
let cli = Cli::try_parse_from(["moq", "token", "generate", "--algorithm", "ES256"]).unwrap();
assert!(matches!(cli.command, Command::Token(_)));
assert!(cli.moq.validate().is_err());
assert!(cli.moq.reject("token").is_ok());
for flag in [
["--client-connect", "https://relay.example.com"],
["--broadcast", "room"],
] {
let cli = Cli::try_parse_from(["moq", flag[0], flag[1], "token", "generate"]).unwrap();
let err = cli.moq.reject("token").unwrap_err().to_string();
assert!(err.contains(flag[0]), "{err}");
}
}
#[cfg(feature = "play")]
#[test]
fn play_verb() {
let cli = Cli::try_parse_from([
"moq",
"--client-connect",
"https://relay.example.com/anon",
"--broadcast",
"room.hang",
"play",
"--video-name",
"hd",
])
.unwrap();
let Command::Play(play) = cli.command else {
panic!("expected play")
};
assert_eq!(play.latency_max, Duration::from_millis(500));
assert_eq!(play.select.video_name.as_deref(), Some("hd"));
assert!(cli.moq.validate().is_ok());
assert!(play.validate().is_ok());
}
#[cfg(feature = "play")]
#[test]
fn play_rejects_undecodable_codecs() {
for flag in [["--video-codec", "vp9"], ["--audio-codec", "aac"]] {
let cli = Cli::try_parse_from([
"moq",
"--client-connect",
"https://relay.example.com/anon",
"play",
flag[0],
flag[1],
])
.unwrap();
let Command::Play(play) = cli.command else {
panic!("expected play")
};
let err = play.validate().unwrap_err().to_string();
assert!(err.contains(flag[1]), "{err}");
}
}
}