Skip to main content

arcly_stream/protocol/rtsp/
egress.rs

1//! RTSP **egress server** — serve a live stream to RTSP players (VLC, ffmpeg).
2//!
3//! The serving counterpart to the client-pull [ingest handler](super::RtspHandler):
4//! [`RtspServer`] accepts TCP connections, runs the
5//! `OPTIONS → DESCRIBE → SETUP → PLAY → TEARDOWN` state machine, and streams the
6//! requested stream's H.264 access units as RTP over the TCP-interleaved
7//! transport (RFC 2326 §10.12), reusing the shared
8//! [`RtpPacketizer`](crate::protocol::rtp::RtpPacketizer).
9//!
10//! Interleaved (RTP-over-TCP) transport only — the universally-supported path
11//! that needs no separate UDP ports. The stream is selected from the request
12//! URI's `/app/stream` path.
13
14use std::net::SocketAddr;
15use std::ops::ControlFlow;
16use std::sync::Arc;
17
18use tokio::io::{AsyncReadExt, AsyncWriteExt};
19use tokio::net::{TcpListener, TcpStream, UdpSocket};
20use tokio_util::sync::CancellationToken;
21use tracing::{debug, info, warn};
22
23use super::message::{InterleavedFrame, RtspRequest};
24use super::sdp::Sdp;
25use crate::auth::{Credentials, EgressGate};
26use crate::bus::PlaybackRegistry;
27use crate::inbound::IngestContext;
28use crate::protocol::rtp::{
29    AccessUnit, DepacketizeError, H264Depacketizer, H265Depacketizer, RtpHeader, RtpPacketizer,
30};
31use crate::{CodecId, MediaFrame, Result, StreamKey};
32
33/// After this many consecutive RTP packets fail to depacketize, the ingest is
34/// treated as a codec/format mismatch and the connection is dropped — instead of
35/// flooding the log and feeding garbage forever.
36const MAX_CONSECUTIVE_DEPACK_FAILURES: u32 = 64;
37
38/// Publish one depacketized video access unit, synthesizing the out-of-band
39/// **CONFIG** frame that container packagers (HLS/DASH fMP4 & MPEG-TS) need.
40///
41/// RTP delivers parameter sets (SPS/PPS for H.264, VPS/SPS/PPS for H.265) only
42/// in-band, prepended to each IDR access unit. An RTSP *player* recovers them
43/// from the bitstream, but a fMP4/TS muxer must build its init segment from a
44/// dedicated `FrameFlags::CONFIG` access unit (see `PackagerCore::ensure_init`)
45/// — without one, no init segment is produced and browser HLS/DASH preview
46/// never starts. So on every keyframe we extract the parameter sets and emit a
47/// CONFIG frame first; the bus caches it for late-joining packagers/subscribers.
48fn publish_au(
49    session: &crate::inbound::PublishSession,
50    codec: CodecId,
51    au: AccessUnit,
52    sdp_config: &Option<bytes::Bytes>,
53) -> Result<()> {
54    let pts = (au.timestamp / 90) as i64; // 90 kHz → ms
55    if au.keyframe {
56        // Prefer parameter sets carried in-band on this IDR; otherwise fall back
57        // to the SDP `sprop-*` sets. Emitting the CONFIG *inline before each
58        // keyframe* (not just once at RECORD) is what lets a late-subscribing
59        // packager — which joins after RECORD and replays nothing — build its
60        // init segment from the first keyframe it sees, mirroring RTMP/MPEG-TS.
61        let cfg =
62            crate::codec::dispatch::parameter_sets(codec, &au.data).or_else(|| sdp_config.clone());
63        if let Some(params) = cfg {
64            let mut frame = MediaFrame::new_video(pts, pts, params, codec, true);
65            frame.flags |= crate::FrameFlags::CONFIG;
66            session.publish_frame(frame)?;
67        }
68    }
69    let mf = MediaFrame::new_video(pts, pts, au.data, codec, au.keyframe);
70    session.publish_frame(mf)?;
71    Ok(())
72}
73
74/// Publish a video CONFIG frame carrying `annexb` parameter sets (SPS/PPS, plus
75/// VPS for H.265). Sent once at `RECORD` from the `ANNOUNCE` SDP's `sprop-*`
76/// fields so the bus caches a decoder config for fMP4/TS packagers (HLS/DASH)
77/// even when the encoder never repeats parameter sets in-band.
78fn publish_config(
79    session: &crate::inbound::PublishSession,
80    codec: CodecId,
81    annexb: bytes::Bytes,
82) -> Result<()> {
83    let mut cfg = MediaFrame::new_video(0, 0, annexb, codec, true);
84    cfg.flags |= crate::FrameFlags::CONFIG;
85    session.publish_frame(cfg)?;
86    Ok(())
87}
88
89/// Video depacketizer chosen from the publisher's announced codec. H.264 and
90/// H.265 share the same RTP push API and `AccessUnit` shape, so the ingest loop
91/// is codec-agnostic past this enum.
92enum VideoDepack {
93    H264(H264Depacketizer),
94    H265(H265Depacketizer),
95}
96
97impl VideoDepack {
98    fn new(codec: CodecId) -> Self {
99        match codec {
100            CodecId::H265 => VideoDepack::H265(H265Depacketizer::new()),
101            _ => VideoDepack::H264(H264Depacketizer::new()),
102        }
103    }
104    fn codec(&self) -> CodecId {
105        match self {
106            VideoDepack::H264(_) => CodecId::H264,
107            VideoDepack::H265(_) => CodecId::H265,
108        }
109    }
110    fn push(
111        &mut self,
112        payload: &[u8],
113        marker: bool,
114        timestamp: u32,
115        sequence: u16,
116    ) -> std::result::Result<Option<AccessUnit>, DepacketizeError> {
117        match self {
118            VideoDepack::H264(d) => d.push(payload, marker, timestamp, sequence),
119            VideoDepack::H265(d) => d.push(payload, marker, timestamp, sequence),
120        }
121    }
122}
123
124/// The video codec announced in an `ANNOUNCE` SDP body (defaults to H.264).
125fn video_codec_from_sdp(body: &[u8]) -> CodecId {
126    let sdp = Sdp::parse(&String::from_utf8_lossy(body));
127    let enc = sdp
128        .media
129        .iter()
130        .find(|m| m.media == "video")
131        .and_then(|m| m.encoding.as_deref())
132        .map(|e| e.to_ascii_uppercase());
133    match enc.as_deref() {
134        Some("H265") | Some("HEVC") => CodecId::H265,
135        _ => CodecId::H264,
136    }
137}
138
139/// A negotiated UDP media transport for one RTSP session: our bound RTP socket
140/// and the player/publisher's RTP address (`peer ip : client_port`).
141struct UdpMedia {
142    rtp: Arc<UdpSocket>,
143    client_rtp: SocketAddr,
144    server_rtp_port: u16,
145}
146
147/// Parse `client_port=A-B` from a SETUP `Transport` header.
148fn parse_client_ports(transport: &str) -> Option<(u16, u16)> {
149    let v = transport
150        .split(';')
151        .find_map(|p| p.trim().strip_prefix("client_port="))?;
152    let (a, b) = v.split_once('-')?;
153    Some((a.trim().parse().ok()?, b.trim().parse().ok()?))
154}
155
156/// A unified RTSP **server**: serves players (`OPTIONS/DESCRIBE/SETUP/PLAY`) and
157/// ingests publishers (`ANNOUNCE/SETUP/RECORD`) on one TCP port, mirroring a
158/// real RTSP server (e.g. MediaMTX). Stream is selected by the request URI's
159/// `/app/stream` path.
160pub struct RtspServer {
161    playback: Arc<dyn PlaybackRegistry>,
162    ingest: IngestContext,
163    bind: SocketAddr,
164    /// Egress gate (per-app toggle + play token). `None` = open playback.
165    gate: Option<EgressGate>,
166}
167
168impl RtspServer {
169    /// Serve streams from `playback` and ingest publishers via `ingest`,
170    /// listening on `bind`.
171    pub fn new(
172        playback: Arc<dyn PlaybackRegistry>,
173        ingest: IngestContext,
174        bind: SocketAddr,
175    ) -> Self {
176        Self {
177            playback,
178            ingest,
179            bind,
180            gate: None,
181        }
182    }
183
184    /// Gate playback (egress) requests through `gate` (per-app toggle + token).
185    pub fn with_gate(mut self, gate: EgressGate) -> Self {
186        self.gate = Some(gate);
187        self
188    }
189
190    /// Accept and serve RTSP connections until `shutdown` fires.
191    pub async fn run(self, shutdown: CancellationToken) -> Result<()> {
192        let listener = TcpListener::bind(self.bind).await?;
193        info!(bind = %self.bind, "rtsp server listening");
194        loop {
195            tokio::select! {
196                _ = shutdown.cancelled() => break,
197                accepted = listener.accept() => {
198                    let (sock, peer) = match accepted {
199                        Ok(v) => v,
200                        Err(e) => { warn!(error = %e, "rtsp accept failed"); continue; }
201                    };
202                    let playback = Arc::clone(&self.playback);
203                    let ingest = self.ingest.clone();
204                    let gate = self.gate.clone();
205                    let shutdown = shutdown.clone();
206                    tokio::spawn(async move {
207                        if let Err(e) = serve_connection(sock, playback, ingest, gate, shutdown).await {
208                            debug!(%peer, error = %e, "rtsp connection ended");
209                        }
210                    });
211                }
212            }
213        }
214        Ok(())
215    }
216}
217
218/// The `token` query parameter from an RTSP URI, if present.
219fn uri_token(uri: &str) -> Option<String> {
220    crate::auth::token_from_query(uri)
221}
222
223/// The RTP payload type advertised for H.264 (must match the SDP `rtpmap`).
224const PT_H264: u8 = 96;
225/// The interleaved channel for RTP (RTCP would be channel 1).
226const RTP_CHANNEL: u8 = 0;
227
228async fn serve_connection(
229    mut sock: TcpStream,
230    playback: Arc<dyn PlaybackRegistry>,
231    ingest: IngestContext,
232    gate: Option<EgressGate>,
233    shutdown: CancellationToken,
234) -> Result<()> {
235    let mut buf = Vec::with_capacity(2048);
236    // A distinct session id per connection (a real server tracks state per id).
237    let session = new_session_id();
238    // Set by ANNOUNCE: this connection is a publisher; RECORD will ingest it.
239    let mut announce_key: Option<StreamKey> = None;
240    // Video codec from the ANNOUNCE SDP (H.264 unless it says H.265/HEVC).
241    let mut announce_codec = CodecId::H264;
242    // Out-of-band parameter sets from the ANNOUNCE SDP's `sprop-*` fields, used
243    // to seed the decoder config (CONFIG frame) at RECORD.
244    let mut announce_config: Option<bytes::Bytes> = None;
245    // The TCP peer: its IP is the UDP media destination when UDP is negotiated,
246    // and the player address handed to the egress gate for token IP-binding.
247    let peer = sock.peer_addr().ok();
248    let peer_ip = peer.map(|a| a.ip());
249    // Set by a UDP SETUP; consumed by RECORD/PLAY. `None` = TCP-interleaved.
250    let mut udp: Option<UdpMedia> = None;
251    loop {
252        let Some((req, body)) = read_request(&mut sock, &mut buf).await? else {
253            return Ok(()); // peer closed
254        };
255        let key = stream_key_from_uri(&req.uri);
256        match req.method.as_str() {
257            "OPTIONS" => {
258                sock.write_all(options_response(req.cseq).as_bytes())
259                    .await?
260            }
261            // ── Publisher (ingest) path: ANNOUNCE → SETUP → RECORD ──
262            "ANNOUNCE" => match &key {
263                Some(k) => {
264                    announce_key = Some(k.clone());
265                    let sdp = Sdp::parse(&String::from_utf8_lossy(&body));
266                    announce_codec = video_codec_from_sdp(&body);
267                    announce_config = sdp.video_config_annexb();
268                    sock.write_all(simple_ok(req.cseq, &session).as_bytes())
269                        .await?;
270                }
271                None => sock.write_all(not_found(req.cseq).as_bytes()).await?,
272            },
273            "RECORD" => match announce_key.take().or_else(|| key.clone()) {
274                Some(k) => {
275                    sock.write_all(simple_ok(req.cseq, &session).as_bytes())
276                        .await?;
277                    // Admission tags `proto=rtsp` so the per-app RTSP ingress
278                    // toggle / publish token are enforced; token rides the URI.
279                    let mut creds = Credentials::default();
280                    creds.params.push(("proto".into(), "rtsp".into()));
281                    creds.token = uri_token(&req.uri);
282                    // Carry the peer so admission can bind a signed publish token
283                    // to the publisher's IP when the app enables it.
284                    creds.addr = peer;
285                    let sess = ingest.open_publish_checked(k.clone(), &creds).await?;
286                    // Seed the decoder config from the ANNOUNCE SDP so HLS/DASH
287                    // packagers can build an init segment immediately, before the
288                    // first keyframe (encoders rarely repeat parameter sets
289                    // in-band over RTP). In-band sets, when present, still flow
290                    // through `publish_au` as additional CONFIG frames.
291                    // Decoder config from the ANNOUNCE SDP's `sprop-*` fields, if
292                    // present. Seeded into the bus cache now and re-emitted inline
293                    // before each keyframe (see `publish_au`) so packagers that
294                    // subscribe after RECORD still get it.
295                    let config = announce_config.take();
296                    if let Some(cfg) = &config {
297                        publish_config(&sess, announce_codec, cfg.clone())?;
298                    }
299                    match udp.take() {
300                        Some(u) => {
301                            info!(stream = %k, codec = ?announce_codec, "rtsp ingest (RECORD/UDP) started");
302                            return record_udp(sock, sess, u, announce_codec, config, shutdown)
303                                .await;
304                        }
305                        None => {
306                            info!(stream = %k, codec = ?announce_codec, "rtsp ingest (RECORD/TCP) started");
307                            return record(sock, sess, buf, announce_codec, config, shutdown).await;
308                        }
309                    }
310                }
311                None => sock.write_all(not_found(req.cseq).as_bytes()).await?,
312            },
313            // ── Player (egress) path: DESCRIBE → SETUP → PLAY ──
314            "DESCRIBE" => match key.as_ref().and_then(|k| playback.get_stream(k).ok()) {
315                Some(handle) => {
316                    // Advertise the stream's real codec (H.264 or H.265) so the
317                    // player decodes it correctly.
318                    let codec = stream_video_codec(&handle);
319                    let sdp = build_sdp(codec);
320                    sock.write_all(describe_response(req.cseq, &req.uri, &sdp).as_bytes())
321                        .await?;
322                }
323                None => sock.write_all(not_found(req.cseq).as_bytes()).await?,
324            },
325            "SETUP" => {
326                let transport = req
327                    .headers
328                    .iter()
329                    .find(|(n, _)| n.eq_ignore_ascii_case("transport"))
330                    .map(|(_, v)| v.as_str())
331                    .unwrap_or("");
332                let is_tcp = transport.is_empty()
333                    || transport.contains("TCP")
334                    || transport.contains("interleaved");
335                if is_tcp {
336                    // TCP-interleaved: echo the client's channels + record mode.
337                    udp = None;
338                    sock.write_all(setup_response(req.cseq, &session, transport).as_bytes())
339                        .await?;
340                } else if let Some((cl_rtp, cl_rtcp)) = parse_client_ports(transport) {
341                    // UDP: bind a server RTP socket and advertise its port.
342                    match (peer_ip, UdpSocket::bind("0.0.0.0:0").await.ok()) {
343                        (Some(ip), Some(rtp_sock)) => {
344                            let server_rtp_port =
345                                rtp_sock.local_addr().map(|a| a.port()).unwrap_or(0);
346                            udp = Some(UdpMedia {
347                                rtp: Arc::new(rtp_sock),
348                                client_rtp: SocketAddr::new(ip, cl_rtp),
349                                server_rtp_port,
350                            });
351                            let record = transport.to_ascii_lowercase().contains("mode=record");
352                            sock.write_all(
353                                setup_response_udp(
354                                    req.cseq,
355                                    &session,
356                                    cl_rtp,
357                                    cl_rtcp,
358                                    server_rtp_port,
359                                    server_rtp_port.wrapping_add(1),
360                                    record,
361                                )
362                                .as_bytes(),
363                            )
364                            .await?;
365                        }
366                        _ => {
367                            sock.write_all(unsupported_transport(req.cseq).as_bytes())
368                                .await?;
369                        }
370                    }
371                } else {
372                    sock.write_all(unsupported_transport(req.cseq).as_bytes())
373                        .await?;
374                }
375            }
376            "PLAY" => {
377                let live = key.filter(|k| playback.get_stream(k).is_ok());
378                match live {
379                    Some(key) => {
380                        // Egress gate: per-app toggle + play token.
381                        let allowed = match gate.as_ref() {
382                            Some(g) => g(key.clone(), uri_token(&req.uri), peer).await,
383                            None => true,
384                        };
385                        if !allowed {
386                            sock.write_all(unauthorized(req.cseq).as_bytes()).await?;
387                            continue;
388                        }
389                        sock.write_all(play_response(req.cseq, &session).as_bytes())
390                            .await?;
391                        return match udp.take() {
392                            Some(u) => play_udp(sock, &playback, key, u, shutdown).await,
393                            None => play(sock, &playback, key, shutdown).await,
394                        };
395                    }
396                    None => sock.write_all(not_found(req.cseq).as_bytes()).await?,
397                }
398            }
399            "TEARDOWN" => {
400                sock.write_all(simple_ok(req.cseq, &session).as_bytes())
401                    .await?;
402                return Ok(());
403            }
404            other => {
405                debug!(method = other, "rtsp: unsupported method");
406                sock.write_all(not_implemented(req.cseq).as_bytes()).await?;
407            }
408        }
409    }
410}
411
412/// Ingest loop: read TCP-interleaved RTP from a publisher, depacketize channel-0
413/// H.264 into access units, and publish them until the publisher disconnects or
414/// `shutdown` fires. `carry` is any bytes already buffered after RECORD.
415async fn record(
416    mut sock: TcpStream,
417    session: crate::inbound::PublishSession,
418    mut carry: Vec<u8>,
419    codec: CodecId,
420    config: Option<bytes::Bytes>,
421    shutdown: CancellationToken,
422) -> Result<()> {
423    let mut depack = VideoDepack::new(codec);
424    let mut fails = 0u32;
425    drain_record(&mut carry, &mut depack, &session, &mut fails, &config)?;
426    let mut read = [0u8; 16 * 1024];
427    loop {
428        tokio::select! {
429            _ = shutdown.cancelled() => break,
430            n = sock.read(&mut read) => {
431                let n = n?;
432                if n == 0 { break; }
433                carry.extend_from_slice(&read[..n]);
434                drain_record(&mut carry, &mut depack, &session, &mut fails, &config)?;
435            }
436        }
437    }
438    session.finish().await
439}
440
441/// Ingest loop for **UDP** transport: each datagram on our RTP socket is one RTP
442/// packet — depacketize H.264 and publish. Ends on the publisher's RTSP control
443/// connection closing (TEARDOWN / EOF) or `shutdown`.
444async fn record_udp(
445    mut sock: TcpStream,
446    session: crate::inbound::PublishSession,
447    udp: UdpMedia,
448    codec: CodecId,
449    config: Option<bytes::Bytes>,
450    shutdown: CancellationToken,
451) -> Result<()> {
452    let mut depack = VideoDepack::new(codec);
453    let mut dgram = vec![0u8; 64 * 1024];
454    let mut ctl = [0u8; 4096];
455    let mut fails = 0u32;
456    loop {
457        tokio::select! {
458            _ = shutdown.cancelled() => break,
459            // Publisher closed the RTSP control connection.
460            n = sock.read(&mut ctl) => {
461                if n.unwrap_or(0) == 0 { break; }
462            }
463            r = udp.rtp.recv_from(&mut dgram) => {
464                let Ok((n, _from)) = r else { continue };
465                let Some(header) = RtpHeader::parse(&dgram[..n]) else { continue };
466                let payload = &dgram[header.payload_offset..n];
467                match depack.push(payload, header.marker, header.timestamp, header.sequence) {
468                    Ok(Some(au)) => {
469                        fails = 0;
470                        publish_au(&session, codec, au, &config)?;
471                    }
472                    Ok(None) => fails = 0,
473                    Err(_) => {
474                        fails += 1;
475                        if fails >= MAX_CONSECUTIVE_DEPACK_FAILURES {
476                            warn!(?codec, "rtsp udp ingest: repeated depacketize \
477                                  failures — aborting (codec mismatch?)");
478                            break;
479                        }
480                    }
481                }
482            }
483        }
484    }
485    session.finish().await
486}
487
488/// Egress for **UDP** transport: packetize the stream's H.264 access units to RTP
489/// and send each packet as a datagram to the player's `client_port`. Stops when
490/// the stream ends, the player closes the RTSP control connection, or shutdown.
491async fn play_udp(
492    mut sock: TcpStream,
493    playback: &Arc<dyn PlaybackRegistry>,
494    key: StreamKey,
495    udp: UdpMedia,
496    shutdown: CancellationToken,
497) -> Result<()> {
498    let handle = playback.get_stream(&key)?;
499    let codec = stream_video_codec(&handle);
500    // Replay the GOP/instant-start (parameter sets + a keyframe), then live.
501    let mut sub = handle.subscribe_resilient();
502    let mut packetizer = packetizer_for(codec);
503    let mut pkts: Vec<Vec<u8>> = Vec::new();
504    let mut ctl = [0u8; 4096];
505    loop {
506        tokio::select! {
507            _ = shutdown.cancelled() => break,
508            n = sock.read(&mut ctl) => {
509                if n.unwrap_or(0) == 0 { break; } // player gone / TEARDOWN
510            }
511            frame = sub.recv() => {
512                let Some(frame) = frame else { break };
513                if !frame.is_video() || frame.codec != codec {
514                    continue;
515                }
516                let timestamp = (frame.pts.max(0) as u64).wrapping_mul(90) as u32;
517                packetizer.packetize_into(&frame.data, timestamp, &mut pkts);
518                for pkt in pkts.iter() {
519                    if udp.rtp.send_to(pkt, udp.client_rtp).await.is_err() {
520                        return Ok(());
521                    }
522                }
523            }
524        }
525    }
526    let _ = udp.server_rtp_port; // (advertised in SETUP)
527    Ok(())
528}
529
530/// Consume whole interleaved frames from `buf`, depacketizing channel-0 video
531/// RTP into access units and publishing them. Leaves any partial frame in `buf`.
532fn drain_record(
533    buf: &mut Vec<u8>,
534    depack: &mut VideoDepack,
535    session: &crate::inbound::PublishSession,
536    fails: &mut u32,
537    config: &Option<bytes::Bytes>,
538) -> Result<()> {
539    // Desync guard: interleaved data must begin with `$`. Anything else means
540    // the peer isn't speaking TCP-interleaved RTP (e.g. it negotiated UDP but
541    // dumped bytes here) — bail instead of mis-framing on stray `$` bytes and
542    // flooding logs with bogus NAL types.
543    if buf.first().is_some_and(|&b| b != b'$') {
544        return Err(crate::StreamError::protocol(
545            "rtsp ingest: non-interleaved data on control connection",
546        ));
547    }
548    let codec = depack.codec();
549    let mut consumed = 0;
550    while let Some((frame, len)) = InterleavedFrame::parse(&buf[consumed..]) {
551        consumed += len;
552        // Even channel = RTP media, odd = RTCP (ignored). Video on channel 0.
553        if frame.channel != 0 {
554            continue;
555        }
556        let Some(header) = RtpHeader::parse(frame.payload) else {
557            continue;
558        };
559        let payload = &frame.payload[header.payload_offset..];
560        match depack.push(payload, header.marker, header.timestamp, header.sequence) {
561            Ok(Some(au)) => {
562                *fails = 0;
563                publish_au(session, codec, au, config)?;
564            }
565            Ok(None) => *fails = 0,
566            Err(_) => {
567                *fails += 1;
568                if *fails >= MAX_CONSECUTIVE_DEPACK_FAILURES {
569                    warn!(
570                        ?codec,
571                        "rtsp ingest: repeated depacketize failures — \
572                          aborting (codec mismatch or corrupt RTP?)"
573                    );
574                    return Err(crate::StreamError::protocol(
575                        "rtsp ingest: depacketize failure threshold",
576                    ));
577                }
578            }
579        }
580    }
581    buf.drain(..consumed);
582    Ok(())
583}
584
585/// Stream `key`'s H.264 access units as interleaved RTP until the stream ends,
586/// the client disconnects, or `shutdown` fires.
587async fn play(
588    mut sock: TcpStream,
589    playback: &Arc<dyn PlaybackRegistry>,
590    key: StreamKey,
591    shutdown: CancellationToken,
592) -> Result<()> {
593    let handle = playback.get_stream(&key)?;
594    let codec = stream_video_codec(&handle);
595    let packetizer = packetizer_for(codec);
596
597    // Replay the instant-start buffer then forward live frames; a write error
598    // means the player disconnected, which ends the session gracefully.
599    let mut sink = RtspSink {
600        sock: &mut sock,
601        packetizer,
602        codec,
603        pkts: Vec::new(),
604        wbuf: Vec::with_capacity(1500),
605    };
606    handle.drive_to(&shutdown, &mut sink).await
607}
608
609/// Streams a stream's frames to one RTSP player as interleaved RTP-over-TCP.
610struct RtspSink<'a> {
611    sock: &'a mut TcpStream,
612    packetizer: RtpPacketizer,
613    /// The negotiated video codec; frames of any other codec are skipped.
614    codec: CodecId,
615    /// Reused across frames: the per-frame RTP packet buffers.
616    pkts: Vec<Vec<u8>>,
617    /// Reused across frames: the interleaved-framing write buffer.
618    wbuf: Vec<u8>,
619}
620
621#[async_trait::async_trait]
622impl crate::bus::FrameSink for RtspSink<'_> {
623    async fn send(&mut self, frame: Arc<MediaFrame>) -> Result<ControlFlow<()>> {
624        // A write error means the player disconnected — stop the drive cleanly.
625        match send_frame(
626            self.sock,
627            &mut self.packetizer,
628            self.codec,
629            &mut self.pkts,
630            &mut self.wbuf,
631            &frame,
632        )
633        .await
634        {
635            Ok(()) => Ok(ControlFlow::Continue(())),
636            Err(_) => Ok(ControlFlow::Break(())),
637        }
638    }
639}
640
641async fn send_frame(
642    sock: &mut TcpStream,
643    packetizer: &mut RtpPacketizer,
644    codec: CodecId,
645    pkts: &mut Vec<Vec<u8>>,
646    wbuf: &mut Vec<u8>,
647    frame: &MediaFrame,
648) -> std::io::Result<()> {
649    if !frame.is_video() || frame.codec != codec {
650        return Ok(()); // only the negotiated video codec is served
651    }
652    // Clamp to 0 before scaling: a negative PTS cast straight to u64 would become
653    // an enormous value and make the RTP timestamp jump wildly.
654    let timestamp = (frame.pts.max(0) as u64).wrapping_mul(90) as u32; // ms → 90 kHz
655    packetizer.packetize_into(&frame.data, timestamp, pkts);
656    // Coalesce every interleaved RTP packet of this access unit into one write
657    // (and one reused buffer) rather than a syscall + allocation per packet.
658    wbuf.clear();
659    for pkt in pkts.iter() {
660        frame_interleaved(RTP_CHANNEL, pkt, wbuf);
661    }
662    sock.write_all(wbuf).await
663}
664
665/// Append an RTP packet framed for the RTSP TCP-interleaved transport to `out`:
666/// `$` + 1-byte channel + 2-byte big-endian length + the RTP packet.
667fn frame_interleaved(channel: u8, rtp: &[u8], out: &mut Vec<u8>) {
668    out.push(b'$');
669    out.push(channel);
670    out.extend_from_slice(&(rtp.len() as u16).to_be_bytes());
671    out.extend_from_slice(rtp);
672}
673
674/// Read one RTSP request (headers + any `Content-Length` body — e.g. the SDP of
675/// an `ANNOUNCE`) from `sock`, returning `None` on a clean EOF. Bytes past the
676/// request (e.g. interleaved RTP after `RECORD`) stay in `buf`.
677async fn read_request(
678    sock: &mut TcpStream,
679    buf: &mut Vec<u8>,
680) -> Result<Option<(RtspRequest, Vec<u8>)>> {
681    let mut tmp = [0u8; 1024];
682    loop {
683        if let Some(end) = find_double_crlf(buf) {
684            let head = String::from_utf8_lossy(&buf[..end]).into_owned();
685            let Some(req) = RtspRequest::parse(&head) else {
686                buf.drain(..end);
687                return Ok(None);
688            };
689            let want = content_length(&req);
690            // Wait for the full body to arrive.
691            while buf.len() < end + want {
692                let n = sock.read(&mut tmp).await?;
693                if n == 0 {
694                    return Ok(None);
695                }
696                buf.extend_from_slice(&tmp[..n]);
697            }
698            let body = buf[end..end + want].to_vec();
699            buf.drain(..end + want);
700            return Ok(Some((req, body)));
701        }
702        let n = sock.read(&mut tmp).await?;
703        if n == 0 {
704            return Ok(None);
705        }
706        buf.extend_from_slice(&tmp[..n]);
707        if buf.len() > 64 * 1024 {
708            return Err(crate::StreamError::protocol("rtsp request too large"));
709        }
710    }
711}
712
713/// The request's `Content-Length`, or 0.
714fn content_length(req: &RtspRequest) -> usize {
715    req.headers
716        .iter()
717        .find(|(n, _)| n.eq_ignore_ascii_case("content-length"))
718        .and_then(|(_, v)| v.parse().ok())
719        .unwrap_or(0)
720}
721
722fn find_double_crlf(buf: &[u8]) -> Option<usize> {
723    buf.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4)
724}
725
726/// Map an RTSP URI (`rtsp://host[:port]/app/stream[/trackID][?q]`) to a stream.
727fn stream_key_from_uri(uri: &str) -> Option<StreamKey> {
728    let rest = uri.strip_prefix("rtsp://")?;
729    let path = rest.split_once('/').map(|(_, p)| p)?;
730    let path = path.split(['?', ';']).next().unwrap_or(path);
731    let mut segs = path.split('/').filter(|s| !s.is_empty());
732    let app = segs.next()?;
733    let stream = segs.next()?;
734    Some(StreamKey::new(app, stream))
735}
736
737// ── Response builders ────────────────────────────────────────────────────────
738
739fn options_response(cseq: u32) -> String {
740    format!(
741        "RTSP/1.0 200 OK\r\nCSeq: {cseq}\r\n\
742         Public: OPTIONS, DESCRIBE, SETUP, PLAY, TEARDOWN\r\n\r\n"
743    )
744}
745
746fn build_sdp(codec: CodecId) -> String {
747    // Minimal elementary description; the player learns parameter sets in-band.
748    let enc = if codec == CodecId::H265 {
749        "H265"
750    } else {
751        "H264"
752    };
753    format!(
754        "v=0\r\n\
755         o=- 0 0 IN IP4 0.0.0.0\r\n\
756         s=arcly-stream\r\n\
757         t=0 0\r\n\
758         m=video 0 RTP/AVP 96\r\n\
759         a=rtpmap:96 {enc}/90000\r\n\
760         a=control:streamid=0\r\n"
761    )
762}
763
764/// The stream's video codec, read from its cached CONFIG frame — synchronous, no
765/// subscription and no wait (the bus caches the config via `ArcSwap`). Defaults
766/// to H.264 when no video config has been cached yet.
767fn stream_video_codec(handle: &crate::bus::StreamHandle) -> CodecId {
768    handle
769        .cached_configs()
770        .0
771        .map(|f| f.codec)
772        .unwrap_or(CodecId::H264)
773}
774
775/// A packetizer for `codec` with the RTSP server's SSRC and MTU.
776fn packetizer_for(codec: CodecId) -> RtpPacketizer {
777    if codec == CodecId::H265 {
778        RtpPacketizer::new_h265(PT_H264, 0x5254_5350, 1400)
779    } else {
780        RtpPacketizer::new(PT_H264, 0x5254_5350, 1400)
781    }
782}
783
784fn describe_response(cseq: u32, uri: &str, sdp: &str) -> String {
785    format!(
786        "RTSP/1.0 200 OK\r\nCSeq: {cseq}\r\n\
787         Content-Base: {uri}\r\nContent-Type: application/sdp\r\n\
788         Content-Length: {}\r\n\r\n{sdp}",
789        sdp.len()
790    )
791}
792
793fn setup_response(cseq: u32, session: &str, client_transport: &str) -> String {
794    // Echo the client's interleaved channel pair (default 0-1) and preserve
795    // `mode=record` for publishers, so the negotiated transport matches exactly.
796    let interleaved = client_transport
797        .split(';')
798        .find_map(|p| p.trim().strip_prefix("interleaved="))
799        .unwrap_or("0-1");
800    let mode =
801        if client_transport.contains("mode=record") || client_transport.contains("mode=RECORD") {
802            ";mode=record"
803        } else {
804            ""
805        };
806    format!(
807        "RTSP/1.0 200 OK\r\nCSeq: {cseq}\r\n\
808         Transport: RTP/AVP/TCP;unicast;interleaved={interleaved}{mode}\r\n\
809         Session: {session}\r\n\r\n"
810    )
811}
812
813#[allow(clippy::too_many_arguments)]
814fn setup_response_udp(
815    cseq: u32,
816    session: &str,
817    client_rtp: u16,
818    client_rtcp: u16,
819    server_rtp: u16,
820    server_rtcp: u16,
821    record: bool,
822) -> String {
823    let mode = if record { ";mode=record" } else { "" };
824    format!(
825        "RTSP/1.0 200 OK\r\nCSeq: {cseq}\r\n\
826         Transport: RTP/AVP;unicast;client_port={client_rtp}-{client_rtcp};\
827         server_port={server_rtp}-{server_rtcp}{mode}\r\n\
828         Session: {session}\r\n\r\n"
829    )
830}
831
832fn unsupported_transport(cseq: u32) -> String {
833    format!("RTSP/1.0 461 Unsupported Transport\r\nCSeq: {cseq}\r\n\r\n")
834}
835
836fn play_response(cseq: u32, session: &str) -> String {
837    format!("RTSP/1.0 200 OK\r\nCSeq: {cseq}\r\nSession: {session}\r\nRange: npt=0.000-\r\n\r\n")
838}
839
840fn simple_ok(cseq: u32, session: &str) -> String {
841    format!("RTSP/1.0 200 OK\r\nCSeq: {cseq}\r\nSession: {session}\r\n\r\n")
842}
843
844fn not_implemented(cseq: u32) -> String {
845    format!("RTSP/1.0 501 Not Implemented\r\nCSeq: {cseq}\r\n\r\n")
846}
847
848fn not_found(cseq: u32) -> String {
849    format!("RTSP/1.0 404 Not Found\r\nCSeq: {cseq}\r\n\r\n")
850}
851
852fn unauthorized(cseq: u32) -> String {
853    format!("RTSP/1.0 401 Unauthorized\r\nCSeq: {cseq}\r\n\r\n")
854}
855
856/// A fresh, hard-to-guess RTSP session id (hex), unique per connection. Seeded
857/// from the OS-backed `RandomState`, so no RNG dependency is pulled in.
858fn new_session_id() -> String {
859    use std::collections::hash_map::RandomState;
860    use std::hash::{BuildHasher, Hasher};
861    use std::time::{SystemTime, UNIX_EPOCH};
862    let mut h = RandomState::new().build_hasher();
863    h.write_u128(
864        SystemTime::now()
865            .duration_since(UNIX_EPOCH)
866            .map(|d| d.as_nanos())
867            .unwrap_or(0),
868    );
869    format!("{:016X}", h.finish())
870}
871
872#[cfg(test)]
873mod tests {
874    use super::*;
875    use crate::protocol::rtsp::message::{InterleavedFrame, RtspResponse};
876
877    #[test]
878    fn interleaved_frame_round_trips() {
879        let rtp = [0x80u8, 0xE0, 0x00, 0x01, 0xAA, 0xBB];
880        let mut framed = Vec::new();
881        frame_interleaved(0, &rtp, &mut framed);
882        assert_eq!(framed[0], b'$');
883        let (f, used) = InterleavedFrame::parse(&framed).expect("parse");
884        assert_eq!(used, framed.len());
885        assert_eq!(f.channel, 0);
886        assert_eq!(f.payload, &rtp);
887    }
888
889    #[test]
890    fn stream_key_parsed_from_uri_variants() {
891        let k = stream_key_from_uri("rtsp://host:554/live/cam").unwrap();
892        assert_eq!((k.app.as_str(), k.stream_id.as_str()), ("live", "cam"));
893        // trailing track/control + query are stripped.
894        let k = stream_key_from_uri("rtsp://h/live/cam/streamid=0?x=1").unwrap();
895        assert_eq!((k.app.as_str(), k.stream_id.as_str()), ("live", "cam"));
896        assert!(stream_key_from_uri("rtsp://host/onlyapp").is_none());
897    }
898
899    #[test]
900    fn uri_token_extracted() {
901        assert_eq!(
902            uri_token("rtsp://h/live/cam?token=abc").as_deref(),
903            Some("abc")
904        );
905        assert_eq!(uri_token("rtsp://h/live/cam").as_deref(), None);
906    }
907
908    #[test]
909    fn announce_request_parses_with_body_len() {
910        let req = RtspRequest::parse(
911            "ANNOUNCE rtsp://h/live/cam RTSP/1.0\r\nCSeq: 2\r\nContent-Length: 5\r\n",
912        )
913        .unwrap();
914        assert_eq!(req.method, "ANNOUNCE");
915        assert_eq!(content_length(&req), 5);
916        assert_eq!(
917            stream_key_from_uri(&req.uri).map(|k| (k.app.to_string(), k.stream_id.to_string())),
918            Some(("live".into(), "cam".into()))
919        );
920    }
921
922    #[test]
923    fn responses_are_well_formed() {
924        assert!(options_response(2).contains("Public: OPTIONS"));
925        let d = describe_response(3, "rtsp://h/live/cam", &build_sdp(CodecId::H264));
926        assert!(build_sdp(CodecId::H265).contains("H265/90000"));
927        let parsed = RtspResponse::parse(
928            d.split("\r\n\r\n").next().unwrap(),
929            d.split("\r\n\r\n").nth(1).unwrap_or("").to_string(),
930        )
931        .expect("response parses");
932        assert_eq!(parsed.header("Content-Type"), Some("application/sdp"));
933        let setup = setup_response(4, "DEADBEEF", "RTP/AVP/TCP;unicast;interleaved=0-1");
934        assert!(setup.contains("interleaved=0-1"));
935        assert!(setup.contains("Session: DEADBEEF"));
936        // A record-mode client gets mode=record echoed back.
937        let rec = setup_response(5, "S", "RTP/AVP/TCP;unicast;interleaved=0-1;mode=record");
938        assert!(rec.contains("mode=record"));
939        // UDP-only request is refused with 461 (so clients retry over TCP).
940        assert!(unsupported_transport(6).starts_with("RTSP/1.0 461"));
941        assert!(not_found(7).starts_with("RTSP/1.0 404"));
942    }
943}