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