Skip to main content

arcly_stream/protocol/rtsp/
mod.rs

1//! Native RTSP ingest handler (feature `rtsp`).
2//!
3//! Pulls live media from RTSP sources — IP cameras, hardware encoders, and
4//! restreamers — and bridges it onto the engine bus through the standard
5//! [`InboundProtocol`] seam. The handler acts as an **RTSP client**: for each
6//! configured [`RtspSource`] it drives the session state machine
7//! (`OPTIONS → DESCRIBE → SETUP → PLAY → TEARDOWN`), then depacketizes the RTP
8//! media into Annex-B access units and publishes them.
9//!
10//! # Transport
11//!
12//! TCP-interleaved transport (RTP-over-RTSP, RFC 2326 §10.12) is the default and
13//! the most camera-compatible: media and control share the one TCP connection,
14//! so it traverses NAT and firewalls that block the classic UDP transport. The
15//! [interleaved framing][InterleavedFrame] (`$ channel length …`) is parsed
16//! here; the RTP payloads feed the shared [`H264Depacketizer`].
17//!
18//! [`InboundProtocol`]: crate::inbound::InboundProtocol
19//! [`H264Depacketizer`]: crate::protocol::rtp::H264Depacketizer
20//!
21//! # Async behavior & teardown
22//!
23//! [`serve`](crate::inbound::InboundProtocol::serve) spawns one pull task per
24//! source and supervises them until `shutdown` fires, at which point each task
25//! issues `TEARDOWN` and releases its [`PublishSession`](crate::inbound::PublishSession). A source that drops is
26//! retried with backoff so a flaky camera link self-heals.
27//!
28//! # Scope
29//!
30//! The message, SDP, and interleaved-framing parsers are complete and unit
31//! tested. **Digest and Basic authentication** are handled natively: embed
32//! credentials in the URL (`rtsp://user:pass@host/path`) and the client answers a
33//! `401` challenge.
34//!
35//! Besides the client-pull ingest, an RTSP **server** ([`RtspServer`]) serves
36//! live streams to players (VLC, ffmpeg) over the same TCP-interleaved
37//! transport, running the `OPTIONS → DESCRIBE → SETUP → PLAY → TEARDOWN` state
38//! machine and answering `404` for unknown streams.
39//!
40//! ONVIF camera discovery (WS-Discovery) lives in the separate `onvif` module
41//! behind the `onvif` feature.
42
43mod auth;
44mod base64;
45mod egress;
46mod message;
47mod sdp;
48
49pub use egress::RtspServer;
50pub use message::{InterleavedFrame, RtspMethod, RtspRequest, RtspResponse};
51pub use sdp::{MediaDescription, Sdp};
52
53use crate::inbound::{InboundProtocol, IngestContext};
54use crate::protocol::rtp::{AacDepacketizer, H264Depacketizer, RtpHeader};
55use crate::{CodecId, MediaFrame, Result, StreamKey};
56use async_trait::async_trait;
57use std::time::Duration;
58use tokio_util::sync::CancellationToken;
59use tracing::{debug, warn};
60
61/// One RTSP source to pull and the stream key it publishes to.
62#[derive(Debug, Clone)]
63pub struct RtspSource {
64    /// Absolute `rtsp://` URL (credentials, if any, embedded by the host).
65    pub url: String,
66    /// Engine stream key the pulled media is published under.
67    pub key: StreamKey,
68}
69
70impl RtspSource {
71    /// A source pulling `url` and publishing it as `key`.
72    pub fn new(url: impl Into<String>, key: StreamKey) -> Self {
73        Self {
74            url: url.into(),
75            key,
76        }
77    }
78}
79
80/// RTSP ingest worker — pulls every configured [`RtspSource`] concurrently.
81#[derive(Debug)]
82pub struct RtspHandler {
83    sources: Vec<RtspSource>,
84    retry_backoff: Duration,
85}
86
87impl Default for RtspHandler {
88    fn default() -> Self {
89        Self::new()
90    }
91}
92
93impl RtspHandler {
94    /// A handler with no sources. Add them with [`source`](Self::source).
95    pub fn new() -> Self {
96        Self {
97            sources: Vec::new(),
98            retry_backoff: Duration::from_secs(3),
99        }
100    }
101
102    /// Register a source to pull.
103    pub fn source(mut self, source: RtspSource) -> Self {
104        self.sources.push(source);
105        self
106    }
107
108    /// Override the reconnect backoff applied after a source drops (default 3s).
109    pub fn retry_backoff(mut self, backoff: Duration) -> Self {
110        self.retry_backoff = backoff;
111        self
112    }
113
114    /// Pull one source until `shutdown`, reconnecting on failure. Owned
115    /// arguments so each source runs on its own spawned task.
116    async fn run_source(
117        source: RtspSource,
118        ctx: IngestContext,
119        shutdown: CancellationToken,
120        backoff: Duration,
121    ) {
122        loop {
123            if shutdown.is_cancelled() {
124                return;
125            }
126            // Race the whole pull session against shutdown. `pull_once` performs
127            // blocking network I/O (TCP connect, request/response, the RTP read
128            // loop); a peer that silently drops packets would otherwise wedge the
129            // connect/read for the OS timeout and make graceful shutdown hang.
130            // Cancelling drops the future, closing the socket immediately.
131            tokio::select! {
132                _ = shutdown.cancelled() => return,
133                res = Self::pull_once(&source, &ctx, &shutdown) => {
134                    if let Err(e) = res {
135                        warn!(url = %source.url, error = %e, "rtsp source dropped; will retry");
136                    }
137                }
138            }
139            tokio::select! {
140                _ = shutdown.cancelled() => return,
141                _ = tokio::time::sleep(backoff) => {}
142            }
143        }
144    }
145
146    /// One full pull session for `source`. Connects, negotiates, and streams
147    /// interleaved RTP until the link drops or `shutdown` fires.
148    async fn pull_once(
149        source: &RtspSource,
150        ctx: &IngestContext,
151        shutdown: &CancellationToken,
152    ) -> Result<()> {
153        use tokio::io::{AsyncReadExt, AsyncWriteExt};
154        use tokio::net::TcpStream;
155
156        // The host may embed credentials in the URL (rtsp://user:pass@host/…);
157        // strip them off the wire and answer any 401 Digest/Basic challenge.
158        let (url, creds) = auth::split_userinfo(&source.url);
159        let mut challenge: Option<auth::Challenge> = None;
160
161        let (host, port) = message::host_port(&url)
162            .ok_or_else(|| crate::StreamError::protocol("malformed rtsp url"))?;
163        let mut stream = TcpStream::connect((host.as_str(), port)).await?;
164        let mut cseq = 1u32;
165
166        // OPTIONS → DESCRIBE → SETUP → PLAY (each retried once with auth on a 401).
167        Self::send_authed(
168            &mut stream,
169            RtspMethod::Options,
170            &url,
171            &mut cseq,
172            &[],
173            &creds,
174            &mut challenge,
175        )
176        .await?;
177
178        let describe = Self::send_authed(
179            &mut stream,
180            RtspMethod::Describe,
181            &url,
182            &mut cseq,
183            &[("Accept", "application/sdp")],
184            &creds,
185            &mut challenge,
186        )
187        .await?;
188        let sdp = Sdp::parse(&describe.body);
189        debug!(url = %url, media = sdp.media.len(), "rtsp DESCRIBE parsed");
190
191        // SETUP the first video track over interleaved channels 0/1.
192        let setup_url = sdp.first_video_control(&url).unwrap_or_else(|| url.clone());
193        let setup = Self::send_authed(
194            &mut stream,
195            RtspMethod::Setup,
196            &setup_url,
197            &mut cseq,
198            &[("Transport", "RTP/AVP/TCP;unicast;interleaved=0-1")],
199            &creds,
200            &mut challenge,
201        )
202        .await?;
203        let session_id = message::session_id(&setup).unwrap_or_default();
204
205        // If the session offers an AAC audio track, SETUP it on channels 2/3.
206        let mut audio_clock = None;
207        if sdp.has_aac_audio() {
208            if let Some(audio_url) = sdp.first_audio_control(&url) {
209                Self::send_authed(
210                    &mut stream,
211                    RtspMethod::Setup,
212                    &audio_url,
213                    &mut cseq,
214                    &[
215                        ("Transport", "RTP/AVP/TCP;unicast;interleaved=2-3"),
216                        ("Session", &session_id),
217                    ],
218                    &creds,
219                    &mut challenge,
220                )
221                .await?;
222                audio_clock = sdp
223                    .media
224                    .iter()
225                    .find(|m| m.media == "audio")
226                    .and_then(|m| m.clock_rate)
227                    .or(Some(48_000));
228                debug!(url = %url, "rtsp AAC audio track set up on ch 2/3");
229            }
230        }
231
232        Self::send_authed(
233            &mut stream,
234            RtspMethod::Play,
235            &url,
236            &mut cseq,
237            &[("Session", &session_id)],
238            &creds,
239            &mut challenge,
240        )
241        .await?;
242
243        // Stream interleaved RTP → depacketize → publish.
244        let session = ctx.open_publish(source.key.clone()).await?;
245        let mut depack = H264Depacketizer::new();
246        let (size_len, index_len) = sdp.audio_aac_lengths();
247        let aac = AacDepacketizer::with_lengths(size_len, index_len);
248        let mut buf = Vec::with_capacity(64 * 1024);
249        let mut read = [0u8; 16 * 1024];
250
251        loop {
252            tokio::select! {
253                _ = shutdown.cancelled() => break,
254                n = stream.read(&mut read) => {
255                    let n = n?;
256                    if n == 0 { break; }
257                    buf.extend_from_slice(&read[..n]);
258                    Self::drain_interleaved(&mut buf, &mut depack, &aac, audio_clock, &session)?;
259                }
260            }
261        }
262
263        // Best-effort TEARDOWN, then release the publish slot.
264        let _ = Self::send_authed(
265            &mut stream,
266            RtspMethod::Teardown,
267            &url,
268            &mut cseq,
269            &[("Session", &session_id)],
270            &creds,
271            &mut challenge,
272        )
273        .await;
274        let _ = stream.shutdown().await;
275        session.finish().await
276    }
277
278    /// Send one RTSP request and return its response, transparently answering a
279    /// `401` with a Digest/Basic `Authorization` and retrying once. `cseq` is
280    /// advanced for every request actually sent.
281    #[allow(clippy::too_many_arguments)]
282    async fn send_authed<S>(
283        stream: &mut S,
284        method: RtspMethod,
285        uri: &str,
286        cseq: &mut u32,
287        headers: &[(&str, &str)],
288        creds: &Option<(String, String)>,
289        challenge: &mut Option<auth::Challenge>,
290    ) -> Result<RtspResponse>
291    where
292        S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
293    {
294        // First attempt — include auth if we already learned a challenge.
295        let auth1 = match (challenge.as_ref(), creds.as_ref()) {
296            (Some(ch), Some((u, p))) => Some(auth::authorization(ch, u, p, method.as_str(), uri)),
297            _ => None,
298        };
299        let mut hdrs: Vec<(&str, &str)> = headers.to_vec();
300        if let Some(a) = &auth1 {
301            hdrs.push(("Authorization", a));
302        }
303        message::write_request(stream, method, uri, *cseq, &hdrs).await?;
304        *cseq += 1;
305        let resp = message::read_response(stream).await?;
306
307        // Not a challenge (or we have no credentials) → return as-is.
308        if resp.status != 401 || creds.is_none() {
309            return Ok(resp);
310        }
311        let Some(ch) = resp
312            .header("WWW-Authenticate")
313            .and_then(auth::parse_challenge)
314        else {
315            return Ok(resp);
316        };
317        *challenge = Some(ch);
318
319        // Retry once with the freshly-parsed challenge.
320        let (u, p) = creds.as_ref().unwrap();
321        let auth2 = auth::authorization(challenge.as_ref().unwrap(), u, p, method.as_str(), uri);
322        let mut hdrs: Vec<(&str, &str)> = headers.to_vec();
323        hdrs.push(("Authorization", &auth2));
324        message::write_request(stream, method, uri, *cseq, &hdrs).await?;
325        *cseq += 1;
326        message::read_response(stream).await
327    }
328
329    /// Consume whole interleaved frames from `buf`, depacketizing channel-0 video
330    /// RTP and (when `audio_clock` is set) channel-2 AAC RTP into frames and
331    /// publishing them. Leaves any partial frame in `buf`.
332    fn drain_interleaved(
333        buf: &mut Vec<u8>,
334        depack: &mut H264Depacketizer,
335        aac: &AacDepacketizer,
336        audio_clock: Option<u32>,
337        session: &crate::inbound::PublishSession,
338    ) -> Result<()> {
339        let mut consumed = 0;
340        while let Some((frame, len)) = InterleavedFrame::parse(&buf[consumed..]) {
341            consumed += len;
342            let Some(header) = RtpHeader::parse(frame.payload) else {
343                continue;
344            };
345            let payload = &frame.payload[header.payload_offset..];
346            // Channel 0 = video RTP, channel 2 = audio RTP; odd channels are
347            // RTCP, ignored on ingest.
348            match frame.channel {
349                0 => {
350                    match depack.push(payload, header.marker, header.timestamp, header.sequence) {
351                        Ok(Some(au)) => {
352                            let pts = (au.timestamp / 90) as i64; // 90 kHz → ms
353                            let mf = MediaFrame::new_video(
354                                pts,
355                                pts,
356                                au.data,
357                                CodecId::H264,
358                                au.keyframe,
359                            );
360                            let _ = session.publish_frame(mf)?;
361                        }
362                        Ok(None) => {}
363                        Err(e) => debug!(?e, "rtp depacketize skip"),
364                    }
365                }
366                2 => {
367                    if let Some(clock) = audio_clock {
368                        match aac.push(payload) {
369                            Ok(units) => {
370                                for au in units {
371                                    let pts =
372                                        (header.timestamp as i64 * 1000) / clock.max(1) as i64;
373                                    let mf = MediaFrame::new_audio(pts, au, CodecId::AAC);
374                                    let _ = session.publish_frame(mf)?;
375                                }
376                            }
377                            Err(e) => debug!(?e, "aac depacketize skip"),
378                        }
379                    }
380                }
381                _ => {}
382            }
383        }
384        buf.drain(..consumed);
385        Ok(())
386    }
387}
388
389#[async_trait]
390impl InboundProtocol for RtspHandler {
391    fn name(&self) -> &'static str {
392        "rtsp"
393    }
394
395    async fn serve(&self, ctx: IngestContext, shutdown: CancellationToken) -> Result<()> {
396        // Pull every source concurrently on its own task; await them all so the
397        // worker only returns once every source has drained on shutdown.
398        let mut tasks = tokio::task::JoinSet::new();
399        for source in &self.sources {
400            tasks.spawn(Self::run_source(
401                source.clone(),
402                ctx.clone(),
403                shutdown.clone(),
404                self.retry_backoff,
405            ));
406        }
407        while tasks.join_next().await.is_some() {}
408        Ok(())
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    #[test]
417    fn source_builder_sets_url_and_key() {
418        let s = RtspSource::new("rtsp://cam/stream", StreamKey::new("live", "cam1"));
419        assert_eq!(s.url, "rtsp://cam/stream");
420        assert_eq!(s.key.stream_id.as_str(), "cam1");
421    }
422
423    #[test]
424    fn handler_collects_sources() {
425        let h = RtspHandler::new()
426            .source(RtspSource::new("rtsp://a/1", StreamKey::new("live", "a")))
427            .source(RtspSource::new("rtsp://b/2", StreamKey::new("live", "b")));
428        assert_eq!(h.sources.len(), 2);
429        assert_eq!(h.name(), "rtsp");
430    }
431}