use std::time::Duration;
use clap::{ArgGroup, Args, Parser, Subcommand};
use hang::moq_net;
use crate::publish::PublishFormat;
use crate::subscribe::{CatalogFormatArg, SubscribeFormat};
#[derive(Parser, Clone)]
#[command(name = "moq", version = env!("VERSION"))]
pub struct Cli {
#[command(flatten)]
pub log: moq_native::Log,
#[command(flatten)]
pub moq: MoqSide,
#[command(subcommand)]
pub command: Command,
}
#[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,
}
#[derive(Args, Clone)]
pub struct Import {
#[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 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 = "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::*;
use clap::CommandFactory;
#[test]
fn valid() {
Cli::command().debug_assert();
}
#[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}");
}
}
}