use serde::Deserialize;
use super::PreflightCheck;
use super::exec::{DeployExecutor, DeployOp, FileContents, RemoteCommand};
pub const MEDIAMTX_RTMP_PORT: u16 = 1935;
pub const MEDIAMTX_HLS_PORT: u16 = 8888;
pub const MEDIAMTX_WEBRTC_PORT: u16 = 8889;
pub const MEDIAMTX_PLAYBACK_PORT: u16 = 9996;
pub const MEDIAMTX_API_PORT: u16 = 9997;
pub const MEDIAMTX_WEBRTC_LOCAL_UDP_PORT: u16 = 8189;
const HLS_VARIANT: &str = "lowLatency";
const HLS_PART_DURATION: &str = "200ms";
const HLS_SEGMENT_DURATION: &str = "2s";
const HLS_SEGMENT_COUNT: u32 = 60;
fn default_recordings_dir() -> String {
"/recordings".to_owned()
}
fn default_record_delete_after() -> String {
"72h".to_owned()
}
fn default_config_path() -> String {
"/etc/mediamtx/mediamtx.yml".to_owned()
}
fn default_binary_path() -> String {
"/usr/local/bin/mediamtx".to_owned()
}
fn default_unit_name() -> String {
"mediamtx".to_owned()
}
const fn default_api_port() -> u16 {
MEDIAMTX_API_PORT
}
const fn default_rtmp_port() -> u16 {
MEDIAMTX_RTMP_PORT
}
const fn default_hls_port() -> u16 {
MEDIAMTX_HLS_PORT
}
const fn default_webrtc_port() -> u16 {
MEDIAMTX_WEBRTC_PORT
}
const fn default_playback_port() -> u16 {
MEDIAMTX_PLAYBACK_PORT
}
const fn default_webrtc_local_udp() -> u16 {
MEDIAMTX_WEBRTC_LOCAL_UDP_PORT
}
pub const DEFAULT_FFMPEG_BIN: &str = "/usr/bin/ffmpeg";
fn default_ffmpeg_bin() -> String {
DEFAULT_FFMPEG_BIN.to_owned()
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct MediaMtxHostConfig {
pub enabled: bool,
pub api_port: u16,
pub rtmp_port: u16,
pub hls_port: u16,
pub webrtc_port: u16,
pub playback_port: u16,
pub webrtc_local_udp: u16,
pub recordings_dir: String,
pub record_delete_after: String,
pub config_path: String,
pub binary_path: String,
pub unit_name: String,
pub webrtc_additional_hosts: Vec<String>,
}
impl Default for MediaMtxHostConfig {
fn default() -> Self {
Self {
enabled: false,
api_port: default_api_port(),
rtmp_port: default_rtmp_port(),
hls_port: default_hls_port(),
webrtc_port: default_webrtc_port(),
playback_port: default_playback_port(),
webrtc_local_udp: default_webrtc_local_udp(),
recordings_dir: default_recordings_dir(),
record_delete_after: default_record_delete_after(),
config_path: default_config_path(),
binary_path: default_binary_path(),
unit_name: default_unit_name(),
webrtc_additional_hosts: Vec::new(),
}
}
}
#[derive(Debug, Default, Deserialize)]
struct MediaTomlRoot {
#[serde(default)]
media: MediaSection,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct MediaSection {
mediamtx: MediaMtxHostConfig,
ffmpeg: FfmpegSection,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
struct FfmpegSection {
bin: String,
}
impl Default for FfmpegSection {
fn default() -> Self {
Self {
bin: default_ffmpeg_bin(),
}
}
}
impl MediaMtxHostConfig {
#[must_use]
pub fn required_csp_origins(&self) -> Vec<String> {
vec![
format!("http://127.0.0.1:{}", self.webrtc_port),
format!("http://127.0.0.1:{}", self.hls_port),
format!("http://127.0.0.1:{}", self.playback_port),
]
}
}
pub fn media_host_config_from_value(
root: toml::Value,
) -> Result<(MediaMtxHostConfig, String), toml::de::Error> {
let parsed: MediaTomlRoot = root.try_into()?;
Ok((parsed.media.mediamtx, parsed.media.ffmpeg.bin))
}
#[must_use]
pub fn render_mediamtx_yml(cfg: &MediaMtxHostConfig) -> String {
let webrtc_hosts = if cfg.webrtc_additional_hosts.is_empty() {
"[]".to_owned()
} else {
let joined = cfg
.webrtc_additional_hosts
.iter()
.map(|h| yaml_single_quote(h))
.collect::<Vec<_>>()
.join(", ");
format!("[{joined}]")
};
let record_path = yaml_single_quote(&format!(
"{}/%path/%Y-%m-%d_%H-%M-%S-%f",
cfg.recordings_dir
));
let record_delete = yaml_single_quote(&cfg.record_delete_after);
format!(
"logLevel: info\n\
\n\
# Only the protocols autumn-media manages are enabled below (RTMP ingest,\n\
# HLS, WebRTC/WHEP playback, the control API, and the recording-playback\n\
# server). Every other protocol MediaMTX turns on by default is disabled\n\
# explicitly, so the systemd unit only ever opens the ports this deploy\n\
# plan declares — an occupied default RTSP (:8554), SRT (:8890), or MoQ\n\
# (:8892) port cannot make `systemctl restart mediamtx` fail after\n\
# preflight passed. (`rtsp: no` also disables RTSPS; `moq: no` closes the\n\
# default-on MoQ HTTP/2+HTTP/3 listeners — autumn-media does not use MoQ;\n\
# `metrics`/`pprof` default off but are pinned off for the same 'declared\n\
# ports only' contract.)\n\
rtsp: no\n\
srt: no\n\
moq: no\n\
metrics: no\n\
pprof: no\n\
\n\
# The control API is the server-side control plane: the co-located app\n\
# reaches it at 127.0.0.1:{api} and it is intentionally excluded from the\n\
# browser-facing CSP origins, so bind it to loopback only rather than\n\
# every interface. The viewer/ingest listeners below (RTMP, HLS,\n\
# WebRTC/WHEP, playback) stay bound on all interfaces so encoders and\n\
# browsers can reach them.\n\
api: true\n\
apiAddress: 127.0.0.1:{api}\n\
\n\
playback: true\n\
playbackAddress: :{playback}\n\
\n\
rtmp: true\n\
rtmpAddress: :{rtmp}\n\
\n\
hls: true\n\
hlsAddress: :{hls}\n\
hlsAllowOrigins: ['*']\n\
hlsVariant: {hls_variant}\n\
hlsPartDuration: {hls_part}\n\
# DVR-on-live window: retained window = hlsSegmentCount x hlsSegmentDuration\n\
# = {seg_count} x {seg_dur}. Surfaced to the player so the UI never promises\n\
# more rewind than MediaMTX actually keeps.\n\
hlsSegmentCount: {seg_count}\n\
hlsSegmentDuration: {seg_dur}\n\
\n\
webrtc: true\n\
webrtcAddress: :{webrtc}\n\
webrtcAllowOrigins: ['*']\n\
webrtcLocalUDPAddress: :{webrtc_udp}\n\
webrtcAdditionalHosts: {webrtc_hosts}\n\
\n\
pathDefaults:\n\
\x20\x20source: publisher\n\
\x20\x20record: true\n\
\x20\x20recordPath: {rec_path}\n\
\x20\x20recordFormat: fmp4\n\
\x20\x20recordPartDuration: 1s\n\
\x20\x20recordSegmentDuration: 5m\n\
\x20\x20recordDeleteAfter: {rec_delete}\n\
\n\
paths:\n\
# Broadcast ingest (single-publisher channels), as in arroyo.\n\
\x20\x20~^live/.+$:\n\
\x20\x20\x20\x20source: publisher\n\
# autumn-media Rooms (Slice 6): room/{{namespace?}}/{{room_id}}/{{participant}}.\n\
# arroyo is broadcast-only and has no room matcher; this is the addition.\n\
\x20\x20~^room/.+$:\n\
\x20\x20\x20\x20source: publisher\n",
api = cfg.api_port,
playback = cfg.playback_port,
rtmp = cfg.rtmp_port,
hls = cfg.hls_port,
webrtc = cfg.webrtc_port,
webrtc_udp = cfg.webrtc_local_udp,
hls_variant = HLS_VARIANT,
hls_part = HLS_PART_DURATION,
seg_count = HLS_SEGMENT_COUNT,
seg_dur = HLS_SEGMENT_DURATION,
webrtc_hosts = webrtc_hosts,
rec_path = record_path,
rec_delete = record_delete,
)
}
#[must_use]
fn yaml_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
#[must_use]
fn systemd_quote(value: &str) -> String {
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
}
#[must_use]
pub fn render_mediamtx_unit(cfg: &MediaMtxHostConfig) -> String {
format!(
"[Unit]\n\
Description=MediaMTX (autumn-media host daemon)\n\
After=network-online.target\n\
Wants=network-online.target\n\
\n\
[Service]\n\
Type=simple\n\
ExecStart={bin} {config}\n\
Restart=always\n\
RestartSec=2\n\
\n\
[Install]\n\
WantedBy=multi-user.target\n",
bin = systemd_quote(&cfg.binary_path),
config = systemd_quote(&cfg.config_path),
)
}
#[must_use]
fn parent_dir(path: &str) -> Option<&str> {
match path.rsplit_once('/') {
Some((parent, _)) if !parent.is_empty() => Some(parent),
_ => None,
}
}
#[derive(Debug, Clone)]
pub struct MediaMtxController {
cfg: MediaMtxHostConfig,
}
impl MediaMtxController {
#[must_use]
pub const fn new(cfg: MediaMtxHostConfig) -> Self {
Self { cfg }
}
#[must_use]
pub fn unit_path(&self) -> String {
format!("/etc/systemd/system/{}.service", self.cfg.unit_name)
}
#[must_use]
pub fn ensure_installed_ops(&self) -> Vec<DeployOp> {
if !self.cfg.enabled {
return Vec::new();
}
let unit = self.cfg.unit_name.clone();
let config_tmp = format!("{}.autumn-new", self.cfg.config_path);
let unit_path = self.unit_path();
let unit_tmp = format!("{unit_path}.autumn-new");
let mut ops = Vec::new();
if let Some(parent) = parent_dir(&self.cfg.config_path) {
ops.push(DeployOp::Run(RemoteCommand::new(
"media-prepare-dirs",
format!("mkdir -p {}", super::exec::shell_quote(parent)),
)));
}
ops.push(DeployOp::WriteFile {
label: "media-write-config",
contents: FileContents::Plain(render_mediamtx_yml(&self.cfg)),
remote_path: config_tmp.clone(),
mode: Some(0o644),
});
ops.push(DeployOp::WriteFile {
label: "media-write-unit",
contents: FileContents::Plain(render_mediamtx_unit(&self.cfg)),
remote_path: unit_tmp.clone(),
mode: Some(0o644),
});
let cfg_live = super::exec::shell_quote(&self.cfg.config_path);
let cfg_new = super::exec::shell_quote(&config_tmp);
let unit_live = super::exec::shell_quote(&unit_path);
let unit_new = super::exec::shell_quote(&unit_tmp);
let unit_service = super::exec::shell_quote(&format!("{unit}.service"));
let install = format!(
"set -e; \
changed=0; \
if ! cmp -s {cfg_new} {cfg_live}; then cp {cfg_new} {cfg_live}; chmod 0644 {cfg_live}; changed=1; fi; \
if ! cmp -s {unit_new} {unit_live}; then cp {unit_new} {unit_live}; chmod 0644 {unit_live}; changed=1; fi; \
rm -f {cfg_new} {unit_new}; \
systemctl daemon-reload; \
systemctl enable {unit_service}; \
if [ \"$changed\" -ne 0 ] || ! systemctl is-active --quiet {unit_service}; then \
systemctl restart {unit_service}; \
fi",
);
ops.push(DeployOp::Run(RemoteCommand::new("media-install", install)));
ops
}
}
pub const CHECK_FFMPEG_PREFLIGHT: &str = "media_ffmpeg_preflight";
pub const CHECK_RECORDINGS_DIR_WRITABLE: &str = "media_recordings_dir_writable";
pub const CHECK_MEDIAMTX_PORTS_AVAILABLE: &str = "media_mediamtx_ports_available";
pub const CHECK_MEDIAMTX_BINARY: &str = "media_mediamtx_binary";
pub const CHECK_MEDIAMTX_PORTS_DISTINCT: &str = "media_mediamtx_ports_distinct";
const FFMPEG_HINT: &str =
"Install FFmpeg on the host (apt package) and set `[media.ffmpeg] bin` to its path";
const RECORDINGS_HINT: &str =
"Create `[media.mediamtx] recordings_dir` on the host and make it writable by the media user";
const PORTS_HINT: &str =
"Free the conflicting MediaMTX ports on the host (or stop the service already bound to them)";
const PORTS_DISTINCT_HINT: &str = "Give each MediaMTX TCP listener (`rtmp_port`/`hls_port`/`webrtc_port`/`playback_port`/`api_port`) \
a distinct port in `[media.mediamtx]`";
const MEDIAMTX_BINARY_HINT: &str = "Install the MediaMTX binary at `[media.mediamtx] binary_path` on the host — a host-bootstrap \
prerequisite (download/version-pin the Go binary, exactly as autumn does for kamal-proxy)";
const FFMPEG_UNRESOLVED_HINT: &str = "Set `[media.ffmpeg] bin` to a concrete path to verify it here, or ensure \
AUTUMN_MEDIA__FFMPEG__BIN / the referenced ${VAR} is set in the SERVICE \
environment (the deploy side defers env-indirected paths to runtime)";
#[must_use]
pub fn ffmpeg_preflight(exec: &impl DeployExecutor, ffmpeg_bin: &str) -> PreflightCheck {
if ffmpeg_bin.trim().is_empty() || ffmpeg_bin.contains("${") {
return PreflightCheck {
name: CHECK_FFMPEG_PREFLIGHT,
passed: false,
deferred: true,
detail: format!(
"FFmpeg path uses env/interpolation indirection (`{ffmpeg_bin}`) resolved in \
the service environment; deferring verification to runtime"
),
hint: Some(FFMPEG_UNRESOLVED_HINT),
};
}
let cmd = RemoteCommand::new(
"media-ffmpeg-preflight",
format!("{} -version", super::exec::shell_quote(ffmpeg_bin)),
);
match exec.run(&cmd) {
Ok(out) if out.stdout.contains("ffmpeg version") => PreflightCheck {
name: CHECK_FFMPEG_PREFLIGHT,
passed: true,
deferred: false,
detail: format!("FFmpeg resolves at `{ffmpeg_bin}` and reports a version banner"),
hint: None,
},
Ok(_) => PreflightCheck {
name: CHECK_FFMPEG_PREFLIGHT,
passed: false,
deferred: false,
detail: format!(
"`{ffmpeg_bin} -version` ran but did not report an `ffmpeg version` banner — \
the binary is not FFmpeg"
),
hint: Some(FFMPEG_HINT),
},
Err(err) => PreflightCheck {
name: CHECK_FFMPEG_PREFLIGHT,
passed: false,
deferred: false,
detail: format!("could not run `{ffmpeg_bin} -version` on the host: {err}"),
hint: Some(FFMPEG_HINT),
},
}
}
#[must_use]
pub fn recordings_dir_writable(
exec: &impl DeployExecutor,
cfg: &MediaMtxHostConfig,
) -> PreflightCheck {
let dir = &cfg.recordings_dir;
let quoted = super::exec::shell_quote(dir);
let cmd = RemoteCommand::new(
"media-recordings-dir",
format!("test -d {quoted} && test -w {quoted}"),
);
match exec.run(&cmd) {
Ok(_) => PreflightCheck {
name: CHECK_RECORDINGS_DIR_WRITABLE,
passed: true,
deferred: false,
detail: format!("recordings directory `{dir}` exists and is writable"),
hint: None,
},
Err(err) => PreflightCheck {
name: CHECK_RECORDINGS_DIR_WRITABLE,
passed: false,
deferred: false,
detail: format!("recordings directory `{dir}` is missing or not writable: {err}"),
hint: Some(RECORDINGS_HINT),
},
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Protocol {
Tcp,
Udp,
}
#[must_use]
fn mediamtx_required_ports(cfg: &MediaMtxHostConfig) -> Vec<(u16, Protocol)> {
vec![
(cfg.api_port, Protocol::Tcp),
(cfg.rtmp_port, Protocol::Tcp),
(cfg.hls_port, Protocol::Tcp),
(cfg.webrtc_port, Protocol::Tcp),
(cfg.playback_port, Protocol::Tcp),
(cfg.webrtc_local_udp, Protocol::Udp),
]
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ListeningSocket {
port: u16,
protocol: Protocol,
owner: Option<String>,
}
#[must_use]
fn parse_listening_sockets(ss_output: &str) -> Vec<ListeningSocket> {
ss_output
.lines()
.filter_map(|line| {
let mut cols = line.split_whitespace();
let protocol = match cols.next()? {
"tcp" => Protocol::Tcp,
"udp" => Protocol::Udp,
_ => return None,
};
let local = cols.nth(3)?;
let port = local.rsplit(':').next()?.parse::<u16>().ok()?;
Some(ListeningSocket {
port,
protocol,
owner: process_name_from_ss_line(line),
})
})
.collect()
}
#[must_use]
fn process_name_from_ss_line(line: &str) -> Option<String> {
let after_quote = line.split_once("users:((\"")?.1;
let name = after_quote.split_once('"')?.0;
(!name.is_empty()).then(|| name.to_owned())
}
#[must_use]
fn managed_process_name(cfg: &MediaMtxHostConfig) -> &str {
cfg.binary_path
.rsplit('/')
.next()
.filter(|s| !s.is_empty())
.unwrap_or(cfg.binary_path.as_str())
}
#[must_use]
fn unit_is_active(exec: &impl DeployExecutor, unit_name: &str) -> bool {
let unit_service = super::exec::shell_quote(&format!("{unit_name}.service"));
let cmd = RemoteCommand::new(
"media-unit-active",
format!("systemctl is-active --quiet {unit_service}"),
);
exec.run(&cmd).is_ok()
}
#[must_use]
pub fn mediamtx_ports_available(
exec: &impl DeployExecutor,
cfg: &MediaMtxHostConfig,
) -> PreflightCheck {
let cmd = RemoteCommand::new("media-ports", "ss -H -tulnp".to_owned());
let output = match exec.run(&cmd) {
Ok(out) => out.stdout,
Err(err) => {
return PreflightCheck {
name: CHECK_MEDIAMTX_PORTS_AVAILABLE,
passed: false,
deferred: false,
detail: format!("could not verify MediaMTX port availability (`ss` failed): {err}"),
hint: Some(PORTS_HINT),
};
}
};
let sockets = parse_listening_sockets(&output);
if sockets.is_empty() && !output.trim().is_empty() {
return PreflightCheck {
name: CHECK_MEDIAMTX_PORTS_AVAILABLE,
passed: false,
deferred: false,
detail: "could not verify MediaMTX port availability: `ss` output did not parse into \
any listening ports"
.to_owned(),
hint: Some(PORTS_HINT),
};
}
let ours = managed_process_name(cfg);
let mut foreign: Vec<u16> = Vec::new();
let mut ours_ports: Vec<u16> = Vec::new();
for (port, protocol) in mediamtx_required_ports(cfg) {
let mut matching = sockets
.iter()
.filter(|s| s.port == port && s.protocol == protocol)
.peekable();
if matching.peek().is_none() {
continue; }
if matching.all(|s| s.owner.as_deref() == Some(ours)) {
ours_ports.push(port);
} else {
foreign.push(port);
}
}
let mut held_by_us: Vec<u16> = Vec::new();
let mut unmanaged: Vec<u16> = Vec::new();
if !ours_ports.is_empty() {
if unit_is_active(exec, &cfg.unit_name) {
held_by_us = ours_ports;
} else {
unmanaged = ours_ports;
}
}
let mut conflict_msgs: Vec<String> = Vec::new();
foreign.sort_unstable();
foreign.dedup();
if !foreign.is_empty() {
conflict_msgs.push(format!(
"MediaMTX port(s) already in use by another service on the host: {}",
join_ports(&foreign)
));
}
unmanaged.sort_unstable();
unmanaged.dedup();
if !unmanaged.is_empty() {
conflict_msgs.push(format!(
"MediaMTX port(s) {} are held by a `{ours}` process, but the configured \
`{}` unit is not active — an unmanaged/manual MediaMTX (or a crashed unit) still \
owns them, so starting our unit would launch a second instance over them",
join_ports(&unmanaged),
cfg.unit_name,
));
}
if !conflict_msgs.is_empty() {
return PreflightCheck {
name: CHECK_MEDIAMTX_PORTS_AVAILABLE,
passed: false,
deferred: false,
detail: conflict_msgs.join("; "),
hint: Some(PORTS_HINT),
};
}
let detail = if held_by_us.is_empty() {
"all MediaMTX ports are free on the host".to_owned()
} else {
format!(
"MediaMTX ports are available; port(s) {} are already held by our own managed \
MediaMTX (same-port redeploy)",
join_ports(&held_by_us),
)
};
PreflightCheck {
name: CHECK_MEDIAMTX_PORTS_AVAILABLE,
passed: true,
deferred: false,
detail,
hint: None,
}
}
#[must_use]
fn join_ports(ports: &[u16]) -> String {
ports
.iter()
.map(u16::to_string)
.collect::<Vec<_>>()
.join(", ")
}
#[must_use]
pub fn mediamtx_binary_preflight(
exec: &impl DeployExecutor,
cfg: &MediaMtxHostConfig,
) -> PreflightCheck {
let bin = &cfg.binary_path;
let cmd = RemoteCommand::new(
"media-mediamtx-binary",
format!("test -x {}", super::exec::shell_quote(bin)),
);
match exec.run(&cmd) {
Ok(_) => PreflightCheck {
name: CHECK_MEDIAMTX_BINARY,
passed: true,
deferred: false,
detail: format!("MediaMTX binary `{bin}` exists and is executable"),
hint: None,
},
Err(err) => PreflightCheck {
name: CHECK_MEDIAMTX_BINARY,
passed: false,
deferred: false,
detail: format!(
"MediaMTX binary `{bin}` is missing or not executable: {err} — MediaMTX cannot \
start, so the post-cutover `systemctl restart` would fail; the binary is a \
host-bootstrap prerequisite (download/version-pin it, like kamal-proxy)"
),
hint: Some(MEDIAMTX_BINARY_HINT),
},
}
}
#[must_use]
pub fn mediamtx_ports_distinct(cfg: &MediaMtxHostConfig) -> PreflightCheck {
let tcp: [(u16, &str); 5] = [
(cfg.rtmp_port, "rtmp_port"),
(cfg.hls_port, "hls_port"),
(cfg.webrtc_port, "webrtc_port"),
(cfg.playback_port, "playback_port"),
(cfg.api_port, "api_port"),
];
let mut by_port: std::collections::BTreeMap<u16, Vec<&str>> = std::collections::BTreeMap::new();
for (port, field) in tcp {
by_port.entry(port).or_default().push(field);
}
let conflicts: Vec<String> = by_port
.iter()
.filter(|(_, fields)| fields.len() > 1)
.map(|(port, fields)| format!("TCP port {port} is bound by {}", fields.join(", ")))
.collect();
if conflicts.is_empty() {
PreflightCheck {
name: CHECK_MEDIAMTX_PORTS_DISTINCT,
passed: true,
deferred: false,
detail: "all MediaMTX TCP listener ports are distinct".to_owned(),
hint: None,
}
} else {
PreflightCheck {
name: CHECK_MEDIAMTX_PORTS_DISTINCT,
passed: false,
deferred: false,
detail: format!(
"MediaMTX listener ports must be unique per protocol, but {} — two servers \
cannot bind the same TCP port, so `systemctl restart mediamtx` would fail after \
cutover",
conflicts.join("; ")
),
hint: Some(PORTS_DISTINCT_HINT),
}
}
}
#[must_use]
pub fn collect_media_doctor_checks(
exec: &impl DeployExecutor,
cfg: &MediaMtxHostConfig,
ffmpeg_bin: &str,
) -> Vec<PreflightCheck> {
vec![
mediamtx_ports_distinct(cfg),
ffmpeg_preflight(exec, ffmpeg_bin),
mediamtx_binary_preflight(exec, cfg),
recordings_dir_writable(exec, cfg),
mediamtx_ports_available(exec, cfg),
]
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::path::Path;
use super::super::exec::{CommandOutput, DeployExecError};
#[derive(Default)]
struct RecordingExecutor {
run_labels: RefCell<Vec<&'static str>>,
fail_labels: Vec<&'static str>,
stdout_by_label: Vec<(&'static str, String)>,
}
impl RecordingExecutor {
fn new() -> Self {
Self::default()
}
fn failing_on(label: &'static str) -> Self {
Self {
fail_labels: vec![label],
..Self::default()
}
}
fn with_stdout(mut self, label: &'static str, stdout: impl Into<String>) -> Self {
self.stdout_by_label.push((label, stdout.into()));
self
}
fn failing(mut self, label: &'static str) -> Self {
self.fail_labels.push(label);
self
}
fn labels(&self) -> Vec<&'static str> {
self.run_labels.borrow().clone()
}
}
impl DeployExecutor for RecordingExecutor {
fn run(&self, cmd: &RemoteCommand) -> Result<CommandOutput, DeployExecError> {
self.run_labels.borrow_mut().push(cmd.label);
if self.fail_labels.contains(&cmd.label) {
return Err(DeployExecError::CommandFailed {
label: cmd.label,
message: "scripted failure".to_owned(),
});
}
let stdout = self
.stdout_by_label
.iter()
.find(|(l, _)| *l == cmd.label)
.map(|(_, out)| out.clone())
.unwrap_or_default();
Ok(CommandOutput {
stdout,
stderr: String::new(),
})
}
fn upload(
&self,
_local: &Path,
_remote_path: &str,
_mode: Option<u32>,
) -> Result<(), DeployExecError> {
Ok(())
}
}
#[test]
fn config_defaults_are_the_localhost_disabled_shape() {
let cfg = MediaMtxHostConfig::default();
assert!(!cfg.enabled, "controller is disabled by default");
assert_eq!(cfg.api_port, MEDIAMTX_API_PORT);
assert_eq!(cfg.rtmp_port, MEDIAMTX_RTMP_PORT);
assert_eq!(cfg.hls_port, MEDIAMTX_HLS_PORT);
assert_eq!(cfg.webrtc_port, MEDIAMTX_WEBRTC_PORT);
assert_eq!(cfg.playback_port, MEDIAMTX_PLAYBACK_PORT);
assert_eq!(cfg.webrtc_local_udp, MEDIAMTX_WEBRTC_LOCAL_UDP_PORT);
assert_eq!(cfg.recordings_dir, "/recordings");
assert_eq!(cfg.record_delete_after, "72h");
assert_eq!(cfg.config_path, "/etc/mediamtx/mediamtx.yml");
assert_eq!(cfg.binary_path, "/usr/local/bin/mediamtx");
assert_eq!(cfg.unit_name, "mediamtx");
assert!(cfg.webrtc_additional_hosts.is_empty());
}
fn host_cfg_from_toml_str(s: &str) -> Result<(MediaMtxHostConfig, String), toml::de::Error> {
media_host_config_from_value(toml::from_str(s)?)
}
#[test]
fn empty_toml_yields_disabled_defaults() {
let (cfg, bin) = host_cfg_from_toml_str("").expect("empty toml parses");
assert!(!cfg.enabled);
assert_eq!(cfg.api_port, MEDIAMTX_API_PORT);
assert_eq!(bin, DEFAULT_FFMPEG_BIN);
}
#[test]
fn toml_reads_the_media_mediamtx_section() {
let toml = "\
[media.mediamtx]
enabled = true
recordings_dir = \"/srv/recordings\"
record_delete_after = \"48h\"
webrtc_additional_hosts = [\"stream.example.com\", \"203.0.113.7\"]
unit_name = \"mediamtx-prod\"
";
let (cfg, _) = host_cfg_from_toml_str(toml).expect("parses");
assert!(cfg.enabled);
assert_eq!(cfg.recordings_dir, "/srv/recordings");
assert_eq!(cfg.record_delete_after, "48h");
assert_eq!(
cfg.webrtc_additional_hosts,
vec!["stream.example.com".to_owned(), "203.0.113.7".to_owned()]
);
assert_eq!(cfg.unit_name, "mediamtx-prod");
assert_eq!(cfg.hls_port, MEDIAMTX_HLS_PORT);
}
#[test]
fn toml_without_media_table_is_default() {
let (cfg, _) = host_cfg_from_toml_str("[deploy]\nhost = \"h\"\n").expect("parses");
assert!(!cfg.enabled);
}
#[test]
fn ffmpeg_bin_defaults_and_reads_from_media_ffmpeg() {
assert_eq!(
host_cfg_from_toml_str("").expect("empty parses").1,
DEFAULT_FFMPEG_BIN
);
let (_, bin) =
host_cfg_from_toml_str("[media.ffmpeg]\nbin = \"/opt/ffmpeg\"\n").expect("parses");
assert_eq!(bin, "/opt/ffmpeg");
}
#[test]
fn media_config_fails_closed_on_malformed_mediamtx_section() {
assert!(host_cfg_from_toml_str("[media.mediamtx]\nenabled = \"yes\"\n").is_err());
assert!(host_cfg_from_toml_str("[media.mediamtx]\napi_port = \"nope\"\n").is_err());
}
#[test]
fn media_config_fails_closed_on_malformed_ffmpeg_section() {
let toml = "[media.ffmpeg]\nbin = 123\n";
assert!(host_cfg_from_toml_str(toml).is_err());
}
#[test]
fn rendered_yml_contains_every_port() {
let yml = render_mediamtx_yml(&MediaMtxHostConfig::default());
assert!(
yml.contains("apiAddress: 127.0.0.1:9997"),
"api port: {yml}"
);
assert!(yml.contains("playbackAddress: :9996"), "playback: {yml}");
assert!(yml.contains("rtmpAddress: :1935"), "rtmp: {yml}");
assert!(yml.contains("hlsAddress: :8888"), "hls: {yml}");
assert!(yml.contains("webrtcAddress: :8889"), "webrtc: {yml}");
assert!(yml.contains("webrtcLocalUDPAddress: :8189"), "udp: {yml}");
}
#[test]
fn rendered_yml_binds_api_to_loopback_but_leaves_public_listeners_open() {
let yml = render_mediamtx_yml(&MediaMtxHostConfig::default());
assert!(
yml.contains("apiAddress: 127.0.0.1:9997"),
"control API must bind loopback only: {yml}"
);
assert!(
!yml.contains("apiAddress: :9997"),
"control API must NOT bind all interfaces: {yml}"
);
for public in [
"rtmpAddress: :1935",
"hlsAddress: :8888",
"webrtcAddress: :8889",
"playbackAddress: :9996",
] {
assert!(yml.contains(public), "public listener {public}: {yml}");
}
assert!(
!yml.contains("rtmpAddress: 127.0.0.1")
&& !yml.contains("hlsAddress: 127.0.0.1")
&& !yml.contains("webrtcAddress: 127.0.0.1")
&& !yml.contains("playbackAddress: 127.0.0.1"),
"no public listener may be loopback-bound: {yml}"
);
}
#[test]
fn rendered_yml_has_ll_hls_recording_and_webrtc_config() {
let yml = render_mediamtx_yml(&MediaMtxHostConfig::default());
assert!(yml.contains("hlsVariant: lowLatency"));
assert!(yml.contains("hlsPartDuration: 200ms"));
assert!(yml.contains("hlsSegmentDuration: 2s"));
assert!(yml.contains("hlsSegmentCount: 60"));
assert!(yml.contains("record: true"));
assert!(yml.contains("recordPath: '/recordings/%path/%Y-%m-%d_%H-%M-%S-%f'"));
assert!(yml.contains("recordFormat: fmp4"));
assert!(yml.contains("recordDeleteAfter: '72h'"));
assert!(yml.contains("webrtc: true"));
assert!(yml.contains("webrtcAdditionalHosts: []"));
}
#[test]
fn rendered_yml_uses_current_mediamtx_cors_schema() {
let yml = render_mediamtx_yml(&MediaMtxHostConfig::default());
assert!(
yml.contains("hlsAllowOrigins: ['*']"),
"HLS CORS must use the current plural array key: {yml}"
);
assert!(
!yml.contains("hlsAllowOrigin:"),
"the deprecated singular hlsAllowOrigin key must not be rendered: {yml}"
);
assert!(
yml.contains("webrtcAllowOrigins: ['*']"),
"WebRTC CORS must use the plural array key: {yml}"
);
}
#[test]
fn rendered_yml_disables_unmanaged_protocols_but_keeps_managed_ones() {
let yml = render_mediamtx_yml(&MediaMtxHostConfig::default());
assert!(yml.contains("rtsp: no"), "RTSP must be disabled: {yml}");
assert!(yml.contains("srt: no"), "SRT must be disabled: {yml}");
assert!(yml.contains("moq: no"), "MoQ must be disabled: {yml}");
assert!(yml.contains("metrics: no"), "metrics must be off: {yml}");
assert!(yml.contains("pprof: no"), "pprof must be off: {yml}");
assert!(yml.contains("rtmp: true"), "rtmp managed: {yml}");
assert!(yml.contains("hls: true"), "hls managed: {yml}");
assert!(yml.contains("webrtc: true"), "webrtc managed: {yml}");
assert!(yml.contains("api: true"), "api managed: {yml}");
assert!(yml.contains("playback: true"), "playback managed: {yml}");
}
#[test]
fn rendered_yml_has_both_live_and_room_path_matchers() {
let yml = render_mediamtx_yml(&MediaMtxHostConfig::default());
assert!(yml.contains("~^live/.+$:"), "broadcast matcher: {yml}");
assert!(yml.contains("~^room/.+$:"), "room matcher: {yml}");
}
#[test]
fn rendered_yml_honors_custom_ports_dirs_and_hosts() {
let cfg = MediaMtxHostConfig {
api_port: 19997,
hls_port: 18888,
recordings_dir: "/data/rec".to_owned(),
record_delete_after: "24h".to_owned(),
webrtc_additional_hosts: vec!["a.example.com".to_owned(), "b.example.com".to_owned()],
..MediaMtxHostConfig::default()
};
let yml = render_mediamtx_yml(&cfg);
assert!(yml.contains("apiAddress: 127.0.0.1:19997"));
assert!(yml.contains("hlsAddress: :18888"));
assert!(yml.contains("recordPath: '/data/rec/%path/%Y-%m-%d_%H-%M-%S-%f'"));
assert!(yml.contains("recordDeleteAfter: '24h'"));
assert!(yml.contains("webrtcAdditionalHosts: ['a.example.com', 'b.example.com']"));
}
#[test]
fn rendered_unit_uses_binary_and_config_paths_and_restarts_always() {
let unit = render_mediamtx_unit(&MediaMtxHostConfig::default());
assert!(
unit.contains("ExecStart=\"/usr/local/bin/mediamtx\" \"/etc/mediamtx/mediamtx.yml\"\n"),
"ExecStart: {unit}"
);
assert!(unit.contains("Restart=always\n"), "restart: {unit}");
assert!(unit.contains("After=network-online.target\n"));
assert!(unit.contains("WantedBy=multi-user.target\n"));
}
#[test]
fn rendered_unit_quotes_whitespace_paths_in_execstart() {
let cfg = MediaMtxHostConfig {
binary_path: "/opt/media mtx/mediamtx".to_owned(),
config_path: "/etc/media mtx/mediamtx.yml".to_owned(),
..MediaMtxHostConfig::default()
};
let unit = render_mediamtx_unit(&cfg);
assert!(
unit.contains(
"ExecStart=\"/opt/media mtx/mediamtx\" \"/etc/media mtx/mediamtx.yml\"\n"
),
"ExecStart must double-quote whitespace paths: {unit}"
);
assert!(
!unit.contains("ExecStart=/opt/media mtx/mediamtx"),
"ExecStart must not interpolate the path unquoted: {unit}"
);
}
#[test]
fn systemd_quote_escapes_backslash_and_double_quote() {
assert_eq!(systemd_quote("/usr/bin/mediamtx"), "\"/usr/bin/mediamtx\"");
assert_eq!(systemd_quote("/opt/a b/mtx"), "\"/opt/a b/mtx\"");
assert_eq!(systemd_quote(r#"/a"b"#), "\"/a\\\"b\"");
assert_eq!(systemd_quote(r"/a\b"), "\"/a\\\\b\"");
}
#[test]
fn rendered_yml_quotes_whitespace_and_special_config_values() {
let cfg = MediaMtxHostConfig {
recordings_dir: "/mnt/rec ords".to_owned(),
record_delete_after: "72h".to_owned(),
..MediaMtxHostConfig::default()
};
let yml = render_mediamtx_yml(&cfg);
assert!(
yml.contains("recordPath: '/mnt/rec ords/%path/%Y-%m-%d_%H-%M-%S-%f'"),
"recordPath must be single-quoted: {yml}"
);
let hashy = MediaMtxHostConfig {
recordings_dir: "/mnt/rec#1".to_owned(),
..MediaMtxHostConfig::default()
};
let yml = render_mediamtx_yml(&hashy);
assert!(
yml.contains("recordPath: '/mnt/rec#1/%path/%Y-%m-%d_%H-%M-%S-%f'"),
"a `#` path must be quoted, not truncated by a YAML comment: {yml}"
);
}
#[test]
fn install_op_quotes_whitespace_unit_name() {
let cfg = MediaMtxHostConfig {
enabled: true,
unit_name: "media mtx".to_owned(),
..MediaMtxHostConfig::default()
};
let ops = MediaMtxController::new(cfg).ensure_installed_ops();
let DeployOp::Run(cmd) = &ops[3] else {
panic!("op 3 must be the install Run op");
};
assert!(
cmd.shell.contains("systemctl enable 'media mtx.service'"),
"unit name must be shell-quoted: {}",
cmd.shell
);
assert!(
cmd.shell
.contains("systemctl is-active --quiet 'media mtx.service'"),
"{}",
cmd.shell
);
assert!(
cmd.shell.contains("systemctl restart 'media mtx.service'"),
"{}",
cmd.shell
);
}
#[test]
fn controller_noops_when_disabled() {
let controller = MediaMtxController::new(MediaMtxHostConfig::default());
assert!(
controller.ensure_installed_ops().is_empty(),
"a disabled controller emits no ops"
);
}
#[test]
fn controller_emits_write_write_install_when_enabled() {
let cfg = MediaMtxHostConfig {
enabled: true,
..MediaMtxHostConfig::default()
};
let controller = MediaMtxController::new(cfg);
let ops = controller.ensure_installed_ops();
assert_eq!(ops.len(), 4);
let DeployOp::Run(mkdir) = &ops[0] else {
panic!("op 0 must be the mkdir Run op, got {:?}", ops[0]);
};
assert_eq!(mkdir.label, "media-prepare-dirs");
assert_eq!(mkdir.shell, "mkdir -p '/etc/mediamtx'");
match &ops[1] {
DeployOp::WriteFile {
label,
contents,
remote_path,
mode,
} => {
assert_eq!(*label, "media-write-config");
assert_eq!(remote_path, "/etc/mediamtx/mediamtx.yml.autumn-new");
assert_eq!(*mode, Some(0o644));
let FileContents::Plain(yml) = contents else {
panic!("config must be plain text");
};
assert!(yml.contains("~^room/.+$:"));
}
other => panic!("op 1 must write the config, got {other:?}"),
}
match &ops[2] {
DeployOp::WriteFile {
label,
contents,
remote_path,
mode,
} => {
assert_eq!(*label, "media-write-unit");
assert_eq!(
remote_path,
"/etc/systemd/system/mediamtx.service.autumn-new"
);
assert_eq!(*mode, Some(0o644));
let FileContents::Plain(unit) = contents else {
panic!("unit must be plain text");
};
assert!(unit.contains("ExecStart=\"/usr/local/bin/mediamtx\""));
}
other => panic!("op 2 must write the unit, got {other:?}"),
}
assert!(matches!(&ops[3], DeployOp::Run(c) if c.label == "media-install"));
}
#[test]
fn controller_install_op_is_conditional_restart() {
let cfg = MediaMtxHostConfig {
enabled: true,
..MediaMtxHostConfig::default()
};
let ops = MediaMtxController::new(cfg).ensure_installed_ops();
let DeployOp::Run(cmd) = &ops[3] else {
panic!("op 3 must be the install Run op");
};
assert_eq!(cmd.label, "media-install");
let shell = &cmd.shell;
assert!(
shell.contains(
"cmp -s '/etc/mediamtx/mediamtx.yml.autumn-new' '/etc/mediamtx/mediamtx.yml'"
),
"must cmp the staged config against the live config: {shell}"
);
assert!(
shell.contains(
"cmp -s '/etc/systemd/system/mediamtx.service.autumn-new' \
'/etc/systemd/system/mediamtx.service'"
),
"must cmp the staged unit against the live unit: {shell}"
);
assert!(
shell.contains("changed=1"),
"must track a changed flag: {shell}"
);
assert!(
shell.contains(
"cp '/etc/mediamtx/mediamtx.yml.autumn-new' '/etc/mediamtx/mediamtx.yml'"
),
"must copy the staged config into place on change: {shell}"
);
assert!(
shell.contains(
"rm -f '/etc/mediamtx/mediamtx.yml.autumn-new' \
'/etc/systemd/system/mediamtx.service.autumn-new'"
),
"must clean up the staged temp files: {shell}"
);
assert!(shell.contains("systemctl daemon-reload"), "{shell}");
assert!(
shell.contains("systemctl enable 'mediamtx.service'"),
"{shell}"
);
assert!(
!shell.contains("enable --now"),
"must not `enable --now` (that would start unconditionally): {shell}"
);
assert!(
shell.contains(
"if [ \"$changed\" -ne 0 ] || ! systemctl is-active --quiet 'mediamtx.service'; then"
),
"restart must be gated on change or an inactive unit: {shell}"
);
assert!(
shell.contains("systemctl restart 'mediamtx.service'"),
"{shell}"
);
}
#[test]
fn controller_honors_custom_unit_name_in_paths_and_command() {
let cfg = MediaMtxHostConfig {
enabled: true,
unit_name: "mediamtx-prod".to_owned(),
..MediaMtxHostConfig::default()
};
let controller = MediaMtxController::new(cfg);
assert_eq!(
controller.unit_path(),
"/etc/systemd/system/mediamtx-prod.service"
);
let ops = controller.ensure_installed_ops();
let DeployOp::Run(cmd) = &ops[3] else {
panic!("op 3 must be the install Run op");
};
assert!(
cmd.shell
.contains("systemctl enable 'mediamtx-prod.service'")
);
assert!(
cmd.shell
.contains("is-active --quiet 'mediamtx-prod.service'")
);
assert!(cmd.shell.contains("restart 'mediamtx-prod.service'"));
assert!(
cmd.shell
.contains("/etc/systemd/system/mediamtx-prod.service.autumn-new")
);
}
#[test]
fn controller_mkdirs_config_parent_before_writing_config() {
let cfg = MediaMtxHostConfig {
enabled: true,
config_path: "/opt/media/etc/mediamtx.yml".to_owned(),
..MediaMtxHostConfig::default()
};
let ops = MediaMtxController::new(cfg).ensure_installed_ops();
let mkdir_idx = ops
.iter()
.position(|op| op.label() == "media-prepare-dirs")
.expect("a media-prepare-dirs op must be emitted");
let write_idx = ops
.iter()
.position(|op| op.label() == "media-write-config")
.expect("a media-write-config op must be emitted");
assert!(
mkdir_idx < write_idx,
"mkdir must precede the config write (mkdir at {mkdir_idx}, write at {write_idx})"
);
let DeployOp::Run(mkdir) = &ops[mkdir_idx] else {
panic!("media-prepare-dirs must be a Run op");
};
assert_eq!(mkdir.shell, "mkdir -p '/opt/media/etc'");
}
#[test]
fn parent_dir_extracts_parent_or_none() {
assert_eq!(
parent_dir("/etc/mediamtx/mediamtx.yml"),
Some("/etc/mediamtx")
);
assert_eq!(parent_dir("/opt/a/b/c.yml"), Some("/opt/a/b"));
assert_eq!(parent_dir("/mediamtx.yml"), None);
assert_eq!(parent_dir("mediamtx.yml"), None);
}
#[test]
fn ffmpeg_preflight_passes_on_version_banner() {
let exec = RecordingExecutor::new().with_stdout(
"media-ffmpeg-preflight",
"ffmpeg version 6.1.1 Copyright (c)",
);
let check = ffmpeg_preflight(&exec, "/usr/bin/ffmpeg");
assert!(check.passed, "detail: {}", check.detail);
assert_eq!(check.name, CHECK_FFMPEG_PREFLIGHT);
assert_eq!(exec.labels(), vec!["media-ffmpeg-preflight"]);
}
#[test]
fn ffmpeg_preflight_fails_without_banner() {
let exec =
RecordingExecutor::new().with_stdout("media-ffmpeg-preflight", "not-ffmpeg output");
let check = ffmpeg_preflight(&exec, "/usr/bin/ffmpeg");
assert!(!check.passed);
assert!(!check.deferred);
assert!(check.blocking());
assert!(check.hint.is_some());
}
#[test]
fn ffmpeg_preflight_fails_when_binary_missing() {
let exec = RecordingExecutor::failing_on("media-ffmpeg-preflight");
let check = ffmpeg_preflight(&exec, "/usr/bin/ffmpeg");
assert!(!check.passed);
assert!(!check.deferred);
assert!(check.blocking());
assert!(check.detail.contains("could not run"));
}
#[test]
fn ffmpeg_preflight_defers_env_indirected_paths_without_probing() {
for configured in [
"${FF_BIN_UNSET_XYZ}",
"/opt/${VER}/ffmpeg",
"${AUTUMN_MEDIA__FFMPEG__BIN}",
"",
] {
let exec = RecordingExecutor::new();
let check = ffmpeg_preflight(&exec, configured);
assert!(!check.passed, "must be non-passing for `{configured}`");
assert!(check.deferred, "must be deferred for `{configured}`");
assert!(
!check.blocking(),
"deferred check must be non-blocking for `{configured}`"
);
assert!(
check.detail.contains("indirection") && check.detail.contains("deferring"),
"detail must name the deferral for `{configured}`: {}",
check.detail
);
assert!(check.hint.is_some());
assert!(
exec.labels().is_empty(),
"no probe must run for `{configured}`, ran {:?}",
exec.labels()
);
}
}
#[test]
fn ffmpeg_preflight_probes_a_concrete_literal_path() {
let exec = RecordingExecutor::new()
.with_stdout("media-ffmpeg-preflight", "ffmpeg version 6.1.1 Copyright");
let check = ffmpeg_preflight(&exec, "/usr/bin/ffmpeg");
assert!(check.passed, "detail: {}", check.detail);
assert_eq!(exec.labels(), vec!["media-ffmpeg-preflight"]);
}
#[test]
fn mediamtx_binary_preflight_passes_when_executable() {
let exec = RecordingExecutor::new();
let check = mediamtx_binary_preflight(&exec, &MediaMtxHostConfig::default());
assert!(check.passed, "detail: {}", check.detail);
assert_eq!(check.name, CHECK_MEDIAMTX_BINARY);
assert_eq!(exec.labels(), vec!["media-mediamtx-binary"]);
assert!(check.hint.is_none());
}
#[test]
fn mediamtx_binary_preflight_fails_when_missing_or_not_executable() {
let exec = RecordingExecutor::failing_on("media-mediamtx-binary");
let cfg = MediaMtxHostConfig::default();
let check = mediamtx_binary_preflight(&exec, &cfg);
assert!(!check.passed);
assert_eq!(check.name, CHECK_MEDIAMTX_BINARY);
assert!(
check.detail.contains(&cfg.binary_path),
"detail must name binary_path: {}",
check.detail
);
assert!(
check.detail.contains("systemctl restart"),
"detail must state the post-cutover restart would fail: {}",
check.detail
);
assert!(check.hint.is_some());
}
#[test]
fn mediamtx_binary_preflight_uses_test_x_and_shell_quotes_the_path() {
struct CaptureExec {
cmd: RefCell<Option<String>>,
}
impl DeployExecutor for CaptureExec {
fn run(&self, cmd: &RemoteCommand) -> Result<CommandOutput, DeployExecError> {
*self.cmd.borrow_mut() = Some(cmd.shell.clone());
Ok(CommandOutput {
stdout: String::new(),
stderr: String::new(),
})
}
fn upload(
&self,
_local: &Path,
_remote_path: &str,
_mode: Option<u32>,
) -> Result<(), DeployExecError> {
Ok(())
}
}
let exec = CaptureExec {
cmd: RefCell::new(None),
};
let cfg = MediaMtxHostConfig {
binary_path: "/opt/media mtx/mediamtx".to_owned(),
..MediaMtxHostConfig::default()
};
let _ = mediamtx_binary_preflight(&exec, &cfg);
let cmd = exec.cmd.borrow().clone().expect("command ran");
assert!(cmd.starts_with("test -x "), "must be `test -x`: {cmd}");
assert!(
cmd.contains("'/opt/media mtx/mediamtx'"),
"binary_path must be shell-quoted: {cmd}"
);
}
#[test]
fn recordings_dir_passes_when_writable() {
let exec = RecordingExecutor::new();
let check = recordings_dir_writable(&exec, &MediaMtxHostConfig::default());
assert!(check.passed, "detail: {}", check.detail);
assert_eq!(check.name, CHECK_RECORDINGS_DIR_WRITABLE);
assert_eq!(exec.labels(), vec!["media-recordings-dir"]);
}
#[test]
fn recordings_dir_fails_when_not_writable() {
let exec = RecordingExecutor::failing_on("media-recordings-dir");
let check = recordings_dir_writable(&exec, &MediaMtxHostConfig::default());
assert!(!check.passed);
assert!(check.detail.contains("/recordings"));
assert!(check.hint.is_some());
}
#[test]
fn parse_listening_sockets_extracts_port_protocol_and_owner() {
let ss = "\
tcp LISTEN 0 128 0.0.0.0:8888 0.0.0.0:* users:((\"mediamtx\",pid=10,fd=5))
tcp LISTEN 0 128 [::]:22 [::]:* users:((\"sshd\",pid=5,fd=3))
udp UNCONN 0 0 *:8189 *:*
sctp LISTEN 0 128 0.0.0.0:9999 0.0.0.0:*
";
let sockets = parse_listening_sockets(ss);
assert!(sockets.iter().any(|s| s.port == 8888
&& s.protocol == Protocol::Tcp
&& s.owner.as_deref() == Some("mediamtx")));
assert!(sockets.iter().any(|s| s.port == 22
&& s.protocol == Protocol::Tcp
&& s.owner.as_deref() == Some("sshd")));
assert!(
sockets
.iter()
.any(|s| s.port == 8189 && s.protocol == Protocol::Udp && s.owner.is_none())
);
assert_eq!(sockets.len(), 3);
}
#[test]
fn ports_available_passes_when_ports_free() {
let exec = RecordingExecutor::new().with_stdout(
"media-ports",
"tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=5,fd=3))\n",
);
let check = mediamtx_ports_available(&exec, &MediaMtxHostConfig::default());
assert!(check.passed, "detail: {}", check.detail);
assert_eq!(check.name, CHECK_MEDIAMTX_PORTS_AVAILABLE);
assert!(check.detail.contains("free"), "detail: {}", check.detail);
}
#[test]
fn ports_available_fails_when_foreign_process_holds_port() {
let exec = RecordingExecutor::new().with_stdout(
"media-ports",
"tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=5,fd=3))\n\
tcp LISTEN 0 128 0.0.0.0:8888 0.0.0.0:* users:((\"nginx\",pid=99,fd=6))\n",
);
let check = mediamtx_ports_available(&exec, &MediaMtxHostConfig::default());
assert!(!check.passed, "detail: {}", check.detail);
assert!(check.detail.contains("8888"), "detail: {}", check.detail);
assert!(check.detail.contains("another service"));
assert!(check.hint.is_some());
}
#[test]
fn ports_available_passes_when_our_mediamtx_owns_all_ports() {
let exec = RecordingExecutor::new().with_stdout(
"media-ports",
"tcp LISTEN 0 128 0.0.0.0:9997 0.0.0.0:* users:((\"mediamtx\",pid=10,fd=3))\n\
tcp LISTEN 0 128 0.0.0.0:1935 0.0.0.0:* users:((\"mediamtx\",pid=10,fd=4))\n\
tcp LISTEN 0 128 0.0.0.0:8888 0.0.0.0:* users:((\"mediamtx\",pid=10,fd=5))\n\
tcp LISTEN 0 128 0.0.0.0:8889 0.0.0.0:* users:((\"mediamtx\",pid=10,fd=6))\n\
tcp LISTEN 0 128 0.0.0.0:9996 0.0.0.0:* users:((\"mediamtx\",pid=10,fd=7))\n\
udp UNCONN 0 0 0.0.0.0:8189 0.0.0.0:* users:((\"mediamtx\",pid=10,fd=8))\n",
);
let check = mediamtx_ports_available(&exec, &MediaMtxHostConfig::default());
assert!(check.passed, "detail: {}", check.detail);
assert!(
check.detail.contains("our own managed"),
"detail: {}",
check.detail
);
assert_eq!(exec.labels(), vec!["media-ports", "media-unit-active"]);
}
#[test]
fn ports_available_fails_when_changed_port_held_by_foreign_service() {
let cfg = MediaMtxHostConfig {
hls_port: 8080,
..MediaMtxHostConfig::default()
};
let exec = RecordingExecutor::new().with_stdout(
"media-ports",
"tcp LISTEN 0 128 0.0.0.0:9997 0.0.0.0:* users:((\"mediamtx\",pid=10,fd=3))\n\
tcp LISTEN 0 128 0.0.0.0:1935 0.0.0.0:* users:((\"mediamtx\",pid=10,fd=4))\n\
tcp LISTEN 0 128 0.0.0.0:8080 0.0.0.0:* users:((\"nginx\",pid=99,fd=6))\n",
);
let check = mediamtx_ports_available(&exec, &cfg);
assert!(!check.passed, "detail: {}", check.detail);
assert!(check.detail.contains("8080"), "detail: {}", check.detail);
}
#[test]
fn ports_available_ignores_same_port_different_protocol_listener() {
let exec = RecordingExecutor::new().with_stdout(
"media-ports",
"tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=5,fd=3))\n\
tcp LISTEN 0 128 0.0.0.0:8189 0.0.0.0:* users:((\"someapp\",pid=77,fd=9))\n",
);
let check = mediamtx_ports_available(&exec, &MediaMtxHostConfig::default());
assert!(check.passed, "detail: {}", check.detail);
assert!(check.detail.contains("free"), "detail: {}", check.detail);
let exec = RecordingExecutor::new().with_stdout(
"media-ports",
"udp UNCONN 0 0 0.0.0.0:8888 0.0.0.0:* users:((\"someapp\",pid=88,fd=4))\n",
);
let check = mediamtx_ports_available(&exec, &MediaMtxHostConfig::default());
assert!(check.passed, "detail: {}", check.detail);
}
#[test]
fn ports_available_fails_on_same_protocol_foreign_listener() {
let exec = RecordingExecutor::new().with_stdout(
"media-ports",
"tcp LISTEN 0 128 0.0.0.0:8888 0.0.0.0:* users:((\"nginx\",pid=99,fd=6))\n",
);
let check = mediamtx_ports_available(&exec, &MediaMtxHostConfig::default());
assert!(!check.passed, "detail: {}", check.detail);
assert!(check.detail.contains("8888"), "detail: {}", check.detail);
assert!(check.detail.contains("another service"));
}
#[test]
fn ports_available_fails_on_udp_foreign_listener_on_udp_target() {
let exec = RecordingExecutor::new().with_stdout(
"media-ports",
"udp UNCONN 0 0 0.0.0.0:8189 0.0.0.0:* users:((\"coturn\",pid=42,fd=8))\n",
);
let check = mediamtx_ports_available(&exec, &MediaMtxHostConfig::default());
assert!(!check.passed, "detail: {}", check.detail);
assert!(check.detail.contains("8189"), "detail: {}", check.detail);
}
#[test]
fn ports_available_fails_closed_when_mediamtx_owns_ports_but_unit_inactive() {
let exec = RecordingExecutor::new()
.with_stdout(
"media-ports",
"tcp LISTEN 0 128 0.0.0.0:9997 0.0.0.0:* users:((\"mediamtx\",pid=10,fd=3))\n\
tcp LISTEN 0 128 0.0.0.0:8888 0.0.0.0:* users:((\"mediamtx\",pid=10,fd=5))\n",
)
.failing("media-unit-active");
let check = mediamtx_ports_available(&exec, &MediaMtxHostConfig::default());
assert!(!check.passed, "detail: {}", check.detail);
assert!(check.detail.contains("8888"), "detail: {}", check.detail);
assert!(
check.detail.contains("not active"),
"detail: {}",
check.detail
);
assert_eq!(exec.labels(), vec!["media-ports", "media-unit-active"]);
}
#[test]
fn ports_available_does_not_query_is_active_when_no_mediamtx_port_held() {
let exec = RecordingExecutor::new().with_stdout(
"media-ports",
"tcp LISTEN 0 128 0.0.0.0:8888 0.0.0.0:* users:((\"nginx\",pid=99,fd=6))\n",
);
let check = mediamtx_ports_available(&exec, &MediaMtxHostConfig::default());
assert!(!check.passed);
assert_eq!(exec.labels(), vec!["media-ports"]);
}
#[test]
fn ports_available_fails_closed_on_unattributable_listener() {
let exec = RecordingExecutor::new().with_stdout(
"media-ports",
"tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=5,fd=3))\n\
tcp LISTEN 0 128 0.0.0.0:8888 0.0.0.0:*\n",
);
let check = mediamtx_ports_available(&exec, &MediaMtxHostConfig::default());
assert!(!check.passed, "detail: {}", check.detail);
assert!(check.detail.contains("8888"), "detail: {}", check.detail);
}
#[test]
fn ports_available_fails_closed_when_ss_errors() {
let exec = RecordingExecutor::failing_on("media-ports");
let check = mediamtx_ports_available(&exec, &MediaMtxHostConfig::default());
assert!(!check.passed);
assert!(check.detail.contains("could not verify"));
}
#[test]
fn ports_available_fails_closed_on_unparseable_output() {
let exec =
RecordingExecutor::new().with_stdout("media-ports", "garbage that has no ports\n");
let check = mediamtx_ports_available(&exec, &MediaMtxHostConfig::default());
assert!(!check.passed);
assert!(check.detail.contains("could not verify"));
}
#[test]
fn collect_runs_all_five_checks_in_order() {
let exec = RecordingExecutor::new()
.with_stdout("media-ffmpeg-preflight", "ffmpeg version 6.1")
.with_stdout(
"media-ports",
"tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=5,fd=3))\n",
);
let checks =
collect_media_doctor_checks(&exec, &MediaMtxHostConfig::default(), "/usr/bin/ffmpeg");
assert_eq!(checks.len(), 5);
assert!(checks.iter().all(|c| c.passed), "all should pass");
assert_eq!(checks[0].name, CHECK_MEDIAMTX_PORTS_DISTINCT);
assert_eq!(
exec.labels(),
vec![
"media-ffmpeg-preflight",
"media-mediamtx-binary",
"media-recordings-dir",
"media-ports"
]
);
}
#[test]
fn collect_with_env_indirected_ffmpeg_has_no_blocking_failures() {
let exec = RecordingExecutor::new().with_stdout(
"media-ports",
"tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=5,fd=3))\n",
);
let checks = collect_media_doctor_checks(
&exec,
&MediaMtxHostConfig::default(),
"${AUTUMN_MEDIA__FFMPEG__BIN}",
);
let ffmpeg = checks
.iter()
.find(|c| c.name == CHECK_FFMPEG_PREFLIGHT)
.expect("ffmpeg check present");
assert!(ffmpeg.deferred && !ffmpeg.passed);
assert!(!exec.labels().contains(&"media-ffmpeg-preflight"));
assert_eq!(
checks.iter().filter(|c| c.blocking()).count(),
0,
"deferred ffmpeg must not block the deploy"
);
}
#[test]
fn ports_distinct_passes_for_default_config() {
let check = mediamtx_ports_distinct(&MediaMtxHostConfig::default());
assert!(
check.passed,
"default ports are all distinct: {}",
check.detail
);
assert_eq!(check.name, CHECK_MEDIAMTX_PORTS_DISTINCT);
}
#[test]
fn ports_distinct_fails_when_two_tcp_listeners_share_a_port() {
let cfg = MediaMtxHostConfig {
hls_port: 8888,
playback_port: 8888,
..MediaMtxHostConfig::default()
};
let check = mediamtx_ports_distinct(&cfg);
assert!(!check.passed);
assert!(check.detail.contains("8888"), "detail: {}", check.detail);
assert!(
check.detail.contains("hls_port"),
"detail: {}",
check.detail
);
assert!(
check.detail.contains("playback_port"),
"detail: {}",
check.detail
);
assert!(check.hint.is_some());
}
#[test]
fn ports_distinct_allows_tcp_port_equal_to_udp_port() {
let cfg = MediaMtxHostConfig {
rtmp_port: MEDIAMTX_WEBRTC_LOCAL_UDP_PORT,
..MediaMtxHostConfig::default()
};
assert_eq!(cfg.rtmp_port, cfg.webrtc_local_udp);
let check = mediamtx_ports_distinct(&cfg);
assert!(
check.passed,
"a TCP port equal to the UDP port is not a same-protocol conflict: {}",
check.detail
);
}
#[test]
fn media_host_config_from_value_reads_merged_media_subtree() {
let root: toml::Value = toml::from_str(
"[media.mediamtx]\nenabled = true\napi_port = 19997\n[media.ffmpeg]\nbin = \"/opt/ff\"\n",
)
.expect("parses");
let (cfg, bin) = media_host_config_from_value(root).expect("deserializes");
assert!(cfg.enabled);
assert_eq!(cfg.api_port, 19997);
assert_eq!(bin, "/opt/ff");
}
#[test]
fn media_host_config_from_value_absent_media_is_disabled_default() {
let root: toml::Value = toml::from_str("[deploy]\nhost = \"h\"\n").expect("parses");
let (cfg, bin) = media_host_config_from_value(root).expect("deserializes");
assert!(!cfg.enabled);
assert_eq!(bin, DEFAULT_FFMPEG_BIN);
}
#[test]
fn media_host_config_from_value_fails_closed_on_ill_typed_value() {
let root: toml::Value =
toml::from_str("[media.mediamtx]\nenabled = \"yes\"\n").expect("parses");
assert!(media_host_config_from_value(root).is_err());
}
#[test]
fn required_csp_origins_lists_browser_facing_ports() {
let origins = MediaMtxHostConfig::default().required_csp_origins();
assert!(
origins.contains(&"http://127.0.0.1:8889".to_owned()),
"webrtc"
);
assert!(origins.contains(&"http://127.0.0.1:8888".to_owned()), "hls");
assert!(
origins.contains(&"http://127.0.0.1:9996".to_owned()),
"playback"
);
assert!(!origins.iter().any(|o| o.contains("9997")));
}
}