heldar_kernel/services/
mediamtx.rs1use serde::Serialize;
5use serde_json::json;
6
7use crate::camera_url;
8use crate::config::Config;
9use crate::error::{AppError, AppResult};
10use crate::models::Camera;
11use crate::state::AppState;
12
13const SOFTWARE_CODEC_ARGS: &str =
15 "-c:v libx264 -preset ultrafast -tune zerolatency -profile:v baseline -pix_fmt yuv420p -g 30";
16
17pub fn transcode_codec_args(cfg: &Config) -> String {
21 select_codec_args(&cfg.live_transcode_engine, &cfg.vaapi_device)
22}
23
24fn select_codec_args(engine: &str, vaapi_device: &str) -> String {
25 match engine {
26 "software" => SOFTWARE_CODEC_ARGS.to_string(),
27 "vaapi" => {
29 format!("-vaapi_device {vaapi_device} -vf format=nv12,hwupload -c:v h264_vaapi -g 30")
30 }
31 "nvenc" => "-c:v h264_nvenc -preset p1 -tune ll -profile:v baseline -pix_fmt yuv420p -g 30"
33 .to_string(),
34 other => {
35 tracing::warn!(
36 engine = %other,
37 "unknown HELDAR_LIVE_TRANSCODE_ENGINE; falling back to software (libx264)"
38 );
39 SOFTWARE_CODEC_ARGS.to_string()
40 }
41 }
42}
43
44#[derive(Debug, Serialize)]
45pub struct LiveUrls {
46 pub name: String,
47 pub hls_url: String,
48 pub webrtc_url: String,
49 pub rtsp_url: String,
50}
51
52pub async fn ensure_live(state: &AppState, camera_id: &str) -> AppResult<LiveUrls> {
54 let cam: Option<Camera> = sqlx::query_as::<_, Camera>("SELECT * FROM cameras WHERE id = ?")
55 .bind(camera_id)
56 .fetch_optional(&state.pool)
57 .await?;
58 let cam = cam.ok_or_else(|| AppError::NotFound(format!("camera {camera_id} not found")))?;
59
60 let source = camera_url::stream_url(&cam, "sub")
61 .or_else(|| camera_url::record_url(&cam))
62 .ok_or_else(|| AppError::BadRequest("camera has no stream URL".into()))?;
63
64 let name = format!("cam_{camera_id}");
65 let api = state.cfg.mediamtx_api_url.trim_end_matches('/');
66
67 let existing = state
68 .http
69 .get(format!("{api}/v3/config/paths/get/{name}"))
70 .send()
71 .await;
72 let already = matches!(existing, Ok(ref r) if r.status().is_success());
73
74 if !already {
75 let codec_args = transcode_codec_args(&state.cfg);
82 let audio_args = if cam.record_audio {
86 "-c:a aac -b:a 96k"
87 } else {
88 "-an"
89 };
90 let run_on_demand = format!(
91 "ffmpeg -nostdin -rtsp_transport tcp -timeout 10000000 -i {source} {audio_args} \
92{codec_args} \
93-f rtsp rtsp://localhost:$RTSP_PORT/$MTX_PATH"
94 );
95 let body = json!({
96 "runOnDemand": run_on_demand,
97 "runOnDemandRestart": true,
98 "runOnDemandCloseAfter": "10s",
99 });
100 let resp = state
101 .http
102 .post(format!("{api}/v3/config/paths/add/{name}"))
103 .json(&body)
104 .send()
105 .await
106 .map_err(|e| AppError::Other(anyhow::anyhow!("MediaMTX unreachable at {api}: {e}")))?;
107 let code = resp.status();
108 if !code.is_success() && code.as_u16() != 400 {
109 let txt = resp.text().await.unwrap_or_default();
110 return Err(AppError::Other(anyhow::anyhow!(
111 "MediaMTX add-path failed ({code}): {txt}"
112 )));
113 }
114 }
115
116 let hls = state.cfg.mediamtx_hls_base.trim_end_matches('/');
117 let webrtc = state.cfg.mediamtx_webrtc_base.trim_end_matches('/');
118 let rtsp = state.cfg.mediamtx_rtsp_base.trim_end_matches('/');
119 Ok(LiveUrls {
120 hls_url: format!("{hls}/{name}/index.m3u8"),
121 webrtc_url: format!("{webrtc}/{name}"),
122 rtsp_url: format!("{rtsp}/{name}"),
123 name,
124 })
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130
131 #[test]
132 fn codec_args_select_by_engine() {
133 assert_eq!(
134 select_codec_args("software", "/dev/dri/renderD128"),
135 SOFTWARE_CODEC_ARGS
136 );
137 let vaapi = select_codec_args("vaapi", "/dev/dri/renderD129");
138 assert!(vaapi.contains("h264_vaapi"));
139 assert!(vaapi.contains("/dev/dri/renderD129"));
140 assert!(select_codec_args("nvenc", "/dev/dri/renderD128").contains("h264_nvenc"));
141 assert_eq!(
143 select_codec_args("bogus", "/dev/dri/renderD128"),
144 SOFTWARE_CODEC_ARGS
145 );
146 }
147}