Skip to main content

arcly_stream/protocol/rtmp/
mod.rs

1//! A working **RTMP** [`InboundProtocol`](crate::InboundProtocol): publish
2//! (ingest) and play (egress) over the real Adobe wire protocol.
3//!
4//! This is the crate's **reference implementation** of the
5//! [multi-protocol ingestion architecture](crate::inbound) — a real, end-to-end
6//! template for the shape an RTSP, SRT, or WHIP worker would take.
7//!
8//! Gated behind the `rtmp` feature. This is not a stub over the generic TCP
9//! accept loop — it performs the simple handshake, reassembles the chunk stream,
10//! decodes AMF0 commands, and bridges FLV audio/video tags to the engine's
11//! lock-free bus:
12//!
13//! * **Publish** — `connect` → `createStream` → `publish` brings up a
14//!   [`StreamHandle`] via [`PublishRegistry::start_publish`]. Incoming AVC NALUs
15//!   are converted to Annex-B (keyframes self-contained with SPS/PPS) and AAC to
16//!   ADTS, then pushed through [`StreamHandle::publish_frame`] — so the GOP cache,
17//!   QoS counters, and any [`Muxer`](crate::packager::Muxer) (e.g.
18//!   [`MpegTsMuxer`](crate::packager::MpegTsMuxer)) work unchanged.
19//! * **Play** — `play` resolves an existing stream, replays the cached configs +
20//!   current GOP for an instant start, then forwards live frames as FLV tags.
21//!
22//! ```no_run
23//! # async fn demo() -> arcly_stream::Result<()> {
24//! use arcly_stream::prelude::*;
25//! use arcly_stream::protocol::rtmp::RtmpHandler;
26//!
27//! let engine = Engine::builder()
28//!     .application(AppSpec::new("live").gop_cache(120))
29//!     .build();
30//! // `.with_playback` enables `play`; `Arc<Engine>` coerces to both registry traits.
31//! let handler = RtmpHandler::new("0.0.0.0:1935".parse().unwrap())
32//!     .with_playback(engine.clone());
33//! // Publish to rtmp://host/live/<stream>, play from the same URL.
34//! engine.serve(vec![Box::new(handler)], CancellationToken::new()).await
35//! # }
36//! ```
37
38mod amf;
39mod chunk;
40mod flv;
41mod handshake;
42mod relay;
43
44pub use relay::RtmpRelay;
45
46use crate::auth::{token_from_query, Credentials, EgressGate};
47use crate::bus::{PlaybackRegistry, PublishRegistry, StreamHandle};
48use crate::{MediaFrame, Result, StreamError, StreamKey};
49use amf::Amf0Value;
50use async_trait::async_trait;
51use chunk::{ChunkReader, ChunkWriter, RtmpMessage};
52use std::net::SocketAddr;
53use std::sync::Arc;
54use tokio::net::TcpStream;
55use tokio_util::sync::CancellationToken;
56use tracing::{debug, info, warn};
57
58/// Extract a `token=…` value from an RTMP stream name's query
59/// (`name?token=xxx&…`). Returns `None` when absent. The publish token rides
60/// here because RTMP has no separate credential field; OBS forwards the full
61/// stream key verbatim.
62fn stream_token_of(name: Option<&Amf0Value>) -> Option<String> {
63    crate::auth::token_from_query(name.and_then(Amf0Value::as_str)?)
64}
65
66/// Default RTMP port.
67pub const DEFAULT_RTMP_PORT: u16 = 1935;
68/// Chunk size the server announces to peers (larger than the 128-byte default
69/// reduces per-chunk header overhead on the media path).
70const OUT_CHUNK_SIZE: usize = 4096;
71
72// RTMP message type ids.
73const MSG_SET_CHUNK_SIZE: u8 = 1;
74const MSG_ACK: u8 = 3;
75const MSG_USER_CONTROL: u8 = 4;
76const MSG_WINDOW_ACK_SIZE: u8 = 5;
77const MSG_SET_PEER_BANDWIDTH: u8 = 6;
78const MSG_AUDIO: u8 = 8;
79const MSG_VIDEO: u8 = 9;
80const MSG_DATA_AMF3: u8 = 15;
81const MSG_DATA_AMF0: u8 = 18;
82const MSG_COMMAND_AMF3: u8 = 17;
83const MSG_COMMAND_AMF0: u8 = 20;
84
85// Chunk-stream ids we emit on.
86const CSID_CONTROL: u8 = 2;
87const CSID_COMMAND: u8 = 3;
88const CSID_AUDIO: u8 = 4;
89const CSID_VIDEO: u8 = 6;
90
91/// The message stream id assigned to every published/played stream. RTMP allows
92/// many; one per connection is sufficient for a live origin.
93const STREAM_ID: u32 = 1;
94
95/// RTMP protocol handler. One instance serves an address; each connection is an
96/// independent publish or play session.
97///
98/// Publish (ingest) works through the [`InboundProtocol`](crate::InboundProtocol)
99/// contract alone — the [`IngestContext`](crate::inbound::IngestContext) handed to
100/// [`serve`](crate::InboundProtocol::serve) carries the publish registry. Play
101/// (egress) additionally needs read access to live streams, which the ingest
102/// context does not provide, so supply a [`PlaybackRegistry`] via
103/// [`with_playback`](Self::with_playback) (the bundled [`Engine`](crate::Engine)
104/// implements both). Without it, `play` requests are answered with a failure status.
105pub struct RtmpHandler {
106    addr: SocketAddr,
107    max_connections: usize,
108    playback: Option<Arc<dyn PlaybackRegistry>>,
109    gate: Option<EgressGate>,
110}
111
112impl RtmpHandler {
113    /// Create a handler bound to `addr` (typically `0.0.0.0:1935`).
114    pub fn new(addr: SocketAddr) -> Self {
115        Self {
116            addr,
117            max_connections: 1024,
118            playback: None,
119            gate: None,
120        }
121    }
122
123    /// Cap the number of concurrent connections (default 1024).
124    pub fn max_connections(mut self, max: usize) -> Self {
125        self.max_connections = max;
126        self
127    }
128
129    /// Enable `play` (egress) by providing a [`PlaybackRegistry`] to resolve live
130    /// streams from. Pass the same engine you publish into.
131    pub fn with_playback(mut self, playback: Arc<dyn PlaybackRegistry>) -> Self {
132        self.playback = Some(playback);
133        self
134    }
135
136    /// Gate `play` (egress) requests through `gate` — e.g. a per-app egress
137    /// toggle plus a play token carried in the play stream name's query
138    /// (`play?token=…`). Without a gate, playback is open (any client may pull a
139    /// live stream once [`with_playback`](Self::with_playback) is set). Mirrors
140    /// the RTSP/SRT server egress gate.
141    pub fn with_gate(mut self, gate: EgressGate) -> Self {
142        self.gate = Some(gate);
143        self
144    }
145}
146
147/// `RtmpHandler` is the **reference [`InboundProtocol`](crate::InboundProtocol)
148/// implementation**: a real-world template for the multi-protocol ingestion
149/// architecture. It owns a TCP listener via
150/// [`run_tcp_ingest_server`](crate::protocol::run_tcp_ingest_server), performs the
151/// RTMP handshake per connection, and bridges FLV AVC/AAC onto the bus through the
152/// [`IngestContext`](crate::inbound::IngestContext)'s registry — exactly the shape
153/// an RTSP, SRT, or WHIP worker would take.
154#[async_trait]
155impl crate::inbound::InboundProtocol for RtmpHandler {
156    fn name(&self) -> &'static str {
157        "rtmp"
158    }
159
160    async fn serve(
161        &self,
162        ctx: crate::inbound::IngestContext,
163        shutdown: CancellationToken,
164    ) -> Result<()> {
165        info!(addr = %self.addr, "rtmp handler listening");
166        // Per-connection sessions own their own multi-stream state machine, so
167        // the handler talks to the registry directly rather than a single
168        // PublishSession; the ergonomic session path is shown in the
169        // `custom_protocol_plugin` example.
170        let registry = Arc::clone(ctx.registry());
171        let playback = self.playback.clone();
172        let gate = self.gate.clone();
173        crate::protocol::run_tcp_ingest_server(
174            self.addr,
175            self.max_connections,
176            shutdown,
177            move |sock, peer| {
178                let registry = Arc::clone(&registry);
179                let playback = playback.clone();
180                let gate = gate.clone();
181                async move {
182                    if let Err(e) = handle_connection(sock, peer, registry, playback, gate).await {
183                        warn!(%peer, error = %e, "rtmp session ended with error");
184                    }
185                }
186            },
187        )
188        .await
189    }
190}
191
192/// Handshake, split the socket, and run the session state machine.
193async fn handle_connection(
194    mut sock: TcpStream,
195    peer: SocketAddr,
196    registry: Arc<dyn PublishRegistry>,
197    playback: Option<Arc<dyn PlaybackRegistry>>,
198    gate: Option<EgressGate>,
199) -> Result<()> {
200    handshake::accept(&mut sock).await?;
201    debug!(%peer, "rtmp handshake complete");
202    let (read_half, write_half) = sock.into_split();
203    let mut session = Session::new(
204        ChunkReader::new(read_half),
205        ChunkWriter::new(write_half),
206        registry,
207        playback,
208        gate,
209        Some(peer),
210    );
211    session.run().await
212}
213
214/// The decoded role of a session, determined by the first `publish`/`play` command.
215#[derive(PartialEq)]
216enum Role {
217    Pending,
218    Publishing,
219    Playing,
220}
221
222/// Per-connection RTMP session state.
223struct Session<R, W> {
224    reader: ChunkReader<R>,
225    writer: ChunkWriter<W>,
226    registry: Arc<dyn PublishRegistry>,
227    playback: Option<Arc<dyn PlaybackRegistry>>,
228    gate: Option<EgressGate>,
229    /// The connecting peer, handed to the egress gate for token IP-binding.
230    peer: Option<SocketAddr>,
231    app: Option<String>,
232    role: Role,
233    publish_key: Option<StreamKey>,
234    handle: Option<StreamHandle>,
235    avc: Option<flv::AvcConfig>,
236    aac: Option<flv::AudioConfig>,
237    /// Egress: whether an AAC sequence header has been sent to a play client yet.
238    audio_seq_sent: bool,
239    stop: bool,
240}
241
242impl<R, W> Session<R, W>
243where
244    R: tokio::io::AsyncRead + Unpin,
245    W: tokio::io::AsyncWrite + Unpin,
246{
247    fn new(
248        reader: ChunkReader<R>,
249        writer: ChunkWriter<W>,
250        registry: Arc<dyn PublishRegistry>,
251        playback: Option<Arc<dyn PlaybackRegistry>>,
252        gate: Option<EgressGate>,
253        peer: Option<SocketAddr>,
254    ) -> Self {
255        Self {
256            reader,
257            writer,
258            registry,
259            playback,
260            gate,
261            peer,
262            app: None,
263            role: Role::Pending,
264            publish_key: None,
265            handle: None,
266            avc: None,
267            aac: None,
268            audio_seq_sent: false,
269            stop: false,
270        }
271    }
272
273    /// Drive the session to completion, releasing the publish slot on exit.
274    async fn run(&mut self) -> Result<()> {
275        let result = self.event_loop().await;
276        if let Some(key) = self.publish_key.take() {
277            let _ = self.registry.end_publish(&key).await;
278        }
279        match result {
280            Ok(()) => Ok(()),
281            Err(e) if is_disconnect(&e) => Ok(()),
282            Err(e) => Err(e),
283        }
284    }
285
286    async fn event_loop(&mut self) -> Result<()> {
287        while !self.stop {
288            let msg = self.reader.read_message().await?;
289            self.process(msg).await?;
290        }
291        Ok(())
292    }
293
294    async fn process(&mut self, msg: RtmpMessage) -> Result<()> {
295        match msg.type_id {
296            MSG_SET_CHUNK_SIZE => {
297                if let Ok(b) = <[u8; 4]>::try_from(&msg.payload[..msg.payload.len().min(4)]) {
298                    self.reader.set_chunk_size(u32::from_be_bytes(b) as usize);
299                }
300            }
301            MSG_COMMAND_AMF0 => self.handle_command(&msg.payload).await?,
302            MSG_COMMAND_AMF3 => {
303                // AMF3 command payloads are prefixed with a single 0x00 byte then
304                // carry an AMF0 body in practice.
305                self.handle_command(&msg.payload[1.min(msg.payload.len())..])
306                    .await?
307            }
308            MSG_VIDEO if self.role == Role::Publishing => self.on_video(&msg)?,
309            MSG_AUDIO if self.role == Role::Publishing => self.on_audio(&msg)?,
310            MSG_DATA_AMF0 | MSG_DATA_AMF3 => { /* onMetaData — not required for transport */ }
311            MSG_ACK | MSG_WINDOW_ACK_SIZE | MSG_SET_PEER_BANDWIDTH | MSG_USER_CONTROL => {}
312            other => debug!(
313                type_id = other,
314                stream = msg.msg_stream_id,
315                "ignoring RTMP message"
316            ),
317        }
318        Ok(())
319    }
320
321    // ── Command channel (AMF0) ──────────────────────────────────────────────
322
323    async fn handle_command(&mut self, payload: &[u8]) -> Result<()> {
324        let values = amf::decode_all(payload);
325        let Some(name) = values.first().and_then(Amf0Value::as_str) else {
326            return Ok(());
327        };
328        let txn = values.get(1).and_then(Amf0Value::as_number).unwrap_or(0.0);
329        match name {
330            "connect" => self.on_connect(txn, values.get(2)).await,
331            "createStream" => self.on_create_stream(txn).await,
332            "publish" => self.on_publish(values.get(3)).await,
333            "play" => self.on_play(values.get(3)).await,
334            "releaseStream" | "FCPublish" | "FCUnpublish" | "deleteStream" | "closeStream"
335            | "FCSubscribe" => Ok(()),
336            other => {
337                debug!(command = other, "unhandled RTMP command");
338                Ok(())
339            }
340        }
341    }
342
343    async fn on_connect(&mut self, txn: f64, cmd_obj: Option<&Amf0Value>) -> Result<()> {
344        let app = cmd_obj
345            .and_then(|o| o.get("app"))
346            .and_then(Amf0Value::as_str)
347            .unwrap_or("")
348            .split('?')
349            .next()
350            .unwrap_or("")
351            .to_string();
352        self.app = Some(app);
353
354        // Window Ack Size, Set Peer Bandwidth, Set Chunk Size — protocol prelude.
355        self.send_control(MSG_WINDOW_ACK_SIZE, &2_500_000u32.to_be_bytes())
356            .await?;
357        let mut bw = Vec::from(2_500_000u32.to_be_bytes());
358        bw.push(2); // limit type: dynamic
359        self.send_control(MSG_SET_PEER_BANDWIDTH, &bw).await?;
360        self.send_control(MSG_SET_CHUNK_SIZE, &(OUT_CHUNK_SIZE as u32).to_be_bytes())
361            .await?;
362        self.writer.set_chunk_size(OUT_CHUNK_SIZE);
363
364        let props = amf::object(vec![
365            ("fmsVer", amf::string("FMS/3,0,1,123")),
366            ("capabilities", Amf0Value::Number(31.0)),
367        ]);
368        let info = amf::object(vec![
369            ("level", amf::string("status")),
370            ("code", amf::string("NetConnection.Connect.Success")),
371            ("description", amf::string("Connection succeeded.")),
372            ("objectEncoding", Amf0Value::Number(0.0)),
373        ]);
374        self.send_command(
375            0,
376            &[amf::string("_result"), Amf0Value::Number(txn), props, info],
377        )
378        .await
379    }
380
381    async fn on_create_stream(&mut self, txn: f64) -> Result<()> {
382        self.send_command(
383            0,
384            &[
385                amf::string("_result"),
386                Amf0Value::Number(txn),
387                Amf0Value::Null,
388                Amf0Value::Number(STREAM_ID as f64),
389            ],
390        )
391        .await
392    }
393
394    async fn on_publish(&mut self, name: Option<&Amf0Value>) -> Result<()> {
395        let key = self.stream_key(name)?;
396        // The publish token rides in the stream key's query (`name?token=…`),
397        // which OBS sends verbatim. Authorize *before* claiming a slot so a bad
398        // token is rejected at admission, not killed reactively after ingest.
399        let mut creds = match stream_token_of(name) {
400            Some(t) => Credentials::token(t),
401            None => Credentials::default(),
402        };
403        // Carry the peer so admission can bind a signed publish token to the
404        // publisher's IP when the app enables it.
405        creds.addr = self.peer;
406        let handle = self.registry.start_publish_checked(&key, &creds).await?;
407        info!(stream = %key, "rtmp publish started");
408        self.handle = Some(handle);
409        self.publish_key = Some(key);
410        self.role = Role::Publishing;
411        self.send_status("status", "NetStream.Publish.Start", "Publishing started.")
412            .await
413    }
414
415    async fn on_play(&mut self, name: Option<&Amf0Value>) -> Result<()> {
416        let key = self.stream_key(name)?;
417        let Some(playback) = self.playback.clone() else {
418            self.send_status("error", "NetStream.Play.Failed", "Playback not enabled.")
419                .await?;
420            self.stop = true;
421            return Ok(());
422        };
423        // Egress gate: the per-app play toggle plus a play token, when configured,
424        // carried in the play stream name's query (`play?token=…`). A `None` gate
425        // means open playback. A rejected request fails cleanly like a missing
426        // stream rather than leaking whether the stream exists.
427        if let Some(gate) = self.gate.clone() {
428            let token = name.and_then(Amf0Value::as_str).and_then(token_from_query);
429            if !gate(key.clone(), token, self.peer).await {
430                self.send_status("error", "NetStream.Play.Failed", "Playback not authorized.")
431                    .await?;
432                self.stop = true;
433                return Ok(());
434            }
435        }
436        let handle = playback.get_stream(&key)?;
437        info!(stream = %key, "rtmp play started");
438        self.role = Role::Playing;
439
440        // StreamBegin user-control event, then the play status events.
441        let mut begin = Vec::from(0u16.to_be_bytes()); // event type 0 = StreamBegin
442        begin.extend_from_slice(&STREAM_ID.to_be_bytes());
443        self.send_control(MSG_USER_CONTROL, &begin).await?;
444        self.send_status("status", "NetStream.Play.Reset", "Playing and resetting.")
445            .await?;
446        self.send_status("status", "NetStream.Play.Start", "Started playing.")
447            .await?;
448
449        self.serve_play(handle).await?;
450        self.stop = true;
451        Ok(())
452    }
453
454    // ── Publish: FLV tag → MediaFrame ───────────────────────────────────────
455
456    fn on_video(&mut self, msg: &RtmpMessage) -> Result<()> {
457        let Some(header) = flv::parse_video_header(&msg.payload) else {
458            return Ok(()); // non-AVC video, ignored
459        };
460        let body = &msg.payload[header.body_offset..];
461        let ts = msg.timestamp as i64;
462
463        if header.avc_packet_type == flv::PKT_SEQUENCE_HEADER {
464            let cfg = flv::AvcConfig::parse(body)
465                .ok_or_else(|| StreamError::protocol("bad AVCDecoderConfigurationRecord"))?;
466            let mut frame =
467                MediaFrame::new_video(ts, ts, cfg.to_annexb(), crate::CodecId::H264, false);
468            frame.flags |= crate::FrameFlags::CONFIG;
469            self.avc = Some(cfg);
470            self.publish(frame);
471        } else if header.avc_packet_type == flv::PKT_RAW {
472            let Some(cfg) = self.avc.as_ref() else {
473                return Ok(()); // NALUs before the sequence header; drop
474            };
475            let annexb = cfg
476                .avcc_to_annexb(body, header.is_keyframe)
477                .ok_or_else(|| StreamError::protocol("malformed AVCC NAL"))?;
478            let pts = ts + header.composition_time as i64;
479            let frame =
480                MediaFrame::new_video(pts, ts, annexb, crate::CodecId::H264, header.is_keyframe);
481            self.publish(frame);
482        }
483        Ok(())
484    }
485
486    fn on_audio(&mut self, msg: &RtmpMessage) -> Result<()> {
487        let payload = &msg.payload;
488        let Some(&b0) = payload.first() else {
489            return Ok(());
490        };
491        if b0 >> 4 != flv::AUDIO_FORMAT_AAC {
492            return Ok(()); // non-AAC audio, ignored
493        }
494        let aac_packet_type = *payload.get(1).unwrap_or(&0);
495        let body = &payload[2.min(payload.len())..];
496        let ts = msg.timestamp as i64;
497
498        if aac_packet_type == flv::PKT_SEQUENCE_HEADER {
499            let cfg = flv::AudioConfig::parse(body)
500                .ok_or_else(|| StreamError::protocol("bad AudioSpecificConfig"))?;
501            self.aac = Some(cfg);
502            let mut frame =
503                MediaFrame::new_audio(ts, bytes::Bytes::copy_from_slice(body), crate::CodecId::AAC);
504            frame.flags |= crate::FrameFlags::CONFIG;
505            self.publish(frame);
506        } else if let Some(cfg) = self.aac.as_ref() {
507            let adts = cfg.to_adts(body);
508            self.publish(MediaFrame::new_audio(ts, adts, crate::CodecId::AAC));
509        }
510        Ok(())
511    }
512
513    fn publish(&self, frame: MediaFrame) {
514        if let Some(handle) = self.handle.as_ref() {
515            let _ = handle.publish_frame(frame);
516        }
517    }
518
519    // ── Play: MediaFrame → FLV tag ──────────────────────────────────────────
520
521    async fn serve_play(&mut self, handle: StreamHandle) -> Result<()> {
522        // Instant start: replay cached configs + current GOP, then go live.
523        for frame in handle.replay_buffer() {
524            self.send_media(&frame).await?;
525        }
526        let mut sub = handle.subscribe_resilient();
527        while let Some(frame) = sub.recv().await {
528            self.send_media(&frame).await?;
529        }
530        Ok(())
531    }
532
533    async fn send_media(&mut self, frame: &MediaFrame) -> Result<()> {
534        let is_config = frame.flags.contains(crate::FrameFlags::CONFIG);
535        match frame.codec {
536            crate::CodecId::H264 => {
537                let ts = frame.dts.max(0) as u32;
538                let tag = if is_config {
539                    let cfg = annexb_to_avc_config(&frame.data);
540                    flv::build_video_tag(false, flv::PKT_SEQUENCE_HEADER, 0, &cfg.to_avc_record())
541                } else {
542                    let avcc = crate::codec::h264::annexb_to_avcc(&frame.data);
543                    let cts = (frame.pts - frame.dts) as i32;
544                    flv::build_video_tag(frame.is_keyframe(), flv::PKT_RAW, cts, &avcc)
545                };
546                self.writer
547                    .write_message(CSID_VIDEO, MSG_VIDEO, ts, STREAM_ID, &tag)
548                    .await
549            }
550            crate::CodecId::AAC => {
551                let ts = frame.pts.max(0) as u32;
552                if is_config {
553                    self.audio_seq_sent = true;
554                    let tag = flv::build_audio_tag(flv::PKT_SEQUENCE_HEADER, &frame.data);
555                    return self
556                        .writer
557                        .write_message(CSID_AUDIO, MSG_AUDIO, ts, STREAM_ID, &tag)
558                        .await;
559                }
560                // A play client needs the AAC sequence header before raw frames.
561                // If the source provided no config frame, synthesize one from the
562                // ADTS header so egress is self-sufficient from any AAC source.
563                if !self.audio_seq_sent {
564                    if let Some(cfg) = flv::AudioConfig::from_adts(&frame.data) {
565                        let seq = flv::build_audio_tag(flv::PKT_SEQUENCE_HEADER, &cfg.to_asc());
566                        self.writer
567                            .write_message(CSID_AUDIO, MSG_AUDIO, ts, STREAM_ID, &seq)
568                            .await?;
569                        self.audio_seq_sent = true;
570                    }
571                }
572                // Engine stores ADTS; strip the 7-byte header back to raw AAC.
573                let raw = frame.data.get(7..).unwrap_or(&[]);
574                let tag = flv::build_audio_tag(flv::PKT_RAW, raw);
575                self.writer
576                    .write_message(CSID_AUDIO, MSG_AUDIO, ts, STREAM_ID, &tag)
577                    .await
578            }
579            _ => Ok(()), // only H.264 + AAC are carried over RTMP
580        }
581    }
582
583    // ── Low-level senders ───────────────────────────────────────────────────
584
585    /// Resolve the [`StreamKey`] for this session from a publish/play stream name.
586    fn stream_key(&self, name: Option<&Amf0Value>) -> Result<StreamKey> {
587        let app = self
588            .app
589            .clone()
590            .ok_or_else(|| StreamError::protocol("stream command before connect"))?;
591        let stream = name
592            .and_then(Amf0Value::as_str)
593            .ok_or_else(|| StreamError::protocol("missing stream name"))?
594            .split('?')
595            .next()
596            .unwrap_or("")
597            .to_string();
598        Ok(StreamKey::new(app, stream))
599    }
600
601    async fn send_control(&mut self, type_id: u8, payload: &[u8]) -> Result<()> {
602        self.writer
603            .write_message(CSID_CONTROL, type_id, 0, 0, payload)
604            .await
605    }
606
607    async fn send_command(&mut self, msg_stream_id: u32, values: &[Amf0Value]) -> Result<()> {
608        let mut buf = bytes::BytesMut::new();
609        for v in values {
610            v.encode(&mut buf);
611        }
612        self.writer
613            .write_message(CSID_COMMAND, MSG_COMMAND_AMF0, 0, msg_stream_id, &buf)
614            .await
615    }
616
617    async fn send_status(&mut self, level: &str, code: &str, description: &str) -> Result<()> {
618        let info = amf::object(vec![
619            ("level", amf::string(level)),
620            ("code", amf::string(code)),
621            ("description", amf::string(description)),
622        ]);
623        self.send_command(
624            STREAM_ID,
625            &[
626                amf::string("onStatus"),
627                Amf0Value::Number(0.0),
628                Amf0Value::Null,
629                info,
630            ],
631        )
632        .await
633    }
634}
635
636/// Split an Annex-B SPS/PPS access unit into an [`flv::AvcConfig`] for egress.
637fn annexb_to_avc_config(annexb: &[u8]) -> flv::AvcConfig {
638    let mut cfg = flv::AvcConfig {
639        nal_length_size: 4,
640        ..Default::default()
641    };
642    for nal in crate::codec::h264::iter_nals_annexb(annexb) {
643        match nal.first().map(|b| b & 0x1F) {
644            Some(7) => cfg.sps.push(nal.to_vec()),
645            Some(8) => cfg.pps.push(nal.to_vec()),
646            _ => {}
647        }
648    }
649    cfg
650}
651
652/// Whether an error is a benign peer disconnect (vs. a real protocol fault).
653fn is_disconnect(e: &StreamError) -> bool {
654    matches!(e, StreamError::Io(io) if matches!(
655        io.kind(),
656        std::io::ErrorKind::UnexpectedEof
657            | std::io::ErrorKind::ConnectionReset
658            | std::io::ErrorKind::BrokenPipe
659            | std::io::ErrorKind::ConnectionAborted
660    ))
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666    use crate::{AppSpec, Engine};
667    use bytes::Bytes;
668
669    /// A keyframe AVCC NAL: 4-byte length + IDR slice bytes.
670    fn avcc_idr() -> Vec<u8> {
671        let slice = [0x65u8, 0x88, 0x84, 0x00];
672        let mut v = (slice.len() as u32).to_be_bytes().to_vec();
673        v.extend_from_slice(&slice);
674        v
675    }
676
677    fn avc_decoder_config() -> Vec<u8> {
678        let sps = [0x67u8, 0x42, 0x00, 0x1F, 0xAA];
679        let pps = [0x68u8, 0xCE, 0x3C, 0x80];
680        let mut r = vec![1, sps[1], sps[2], sps[3], 0xFF, 0xE1];
681        r.extend_from_slice(&(sps.len() as u16).to_be_bytes());
682        r.extend_from_slice(&sps);
683        r.push(1);
684        r.extend_from_slice(&(pps.len() as u16).to_be_bytes());
685        r.extend_from_slice(&pps);
686        r
687    }
688
689    /// Build a session whose writer discards output and whose reader is empty,
690    /// publishing into a freshly registered engine stream.
691    async fn publishing_session() -> (Session<tokio::io::Empty, tokio::io::Sink>, Arc<Engine>) {
692        let engine = Engine::builder()
693            .application(AppSpec::new("live").gop_cache(64))
694            .build();
695        let key = StreamKey::new("live", "cam");
696        let handle = engine.start_publish(&key).await.unwrap();
697
698        let mut session = Session::new(
699            ChunkReader::new(tokio::io::empty()),
700            ChunkWriter::new(tokio::io::sink()),
701            engine.clone(),
702            Some(engine.clone()),
703            None,
704            None,
705        );
706        session.app = Some("live".into());
707        session.handle = Some(handle);
708        session.role = Role::Publishing;
709        (session, engine)
710    }
711
712    #[tokio::test]
713    async fn publish_video_seq_header_then_keyframe_reaches_gop_cache() {
714        let (mut session, _engine) = publishing_session().await;
715
716        // 1) AVC sequence header → cached video config (Annex-B SPS/PPS).
717        let seq = flv::build_video_tag(false, flv::PKT_SEQUENCE_HEADER, 0, &avc_decoder_config());
718        session
719            .on_video(&RtmpMessage {
720                type_id: MSG_VIDEO,
721                timestamp: 0,
722                msg_stream_id: STREAM_ID,
723                payload: seq,
724            })
725            .unwrap();
726
727        // 2) Keyframe NALU → GOP-anchoring frame, self-contained with SPS/PPS.
728        let kf = flv::build_video_tag(true, flv::PKT_RAW, 0, &avcc_idr());
729        session
730            .on_video(&RtmpMessage {
731                type_id: MSG_VIDEO,
732                timestamp: 40,
733                msg_stream_id: STREAM_ID,
734                payload: kf,
735            })
736            .unwrap();
737
738        let handle = session.handle.as_ref().unwrap();
739        let (vcfg, _) = handle.cached_configs();
740        let vcfg = vcfg.expect("video config cached");
741        assert!(vcfg.flags.contains(crate::FrameFlags::CONFIG));
742        // Config is Annex-B SPS then PPS.
743        assert_eq!(&vcfg.data[..5], &[0, 0, 0, 1, 0x67]);
744
745        // The keyframe is present in the replay buffer and starts with SPS (params
746        // were prepended so a player can start decoding immediately).
747        let replay = handle.replay_buffer();
748        let key = replay
749            .iter()
750            .find(|f| f.is_keyframe())
751            .expect("keyframe in GOP");
752        assert_eq!(&key.data[..5], &[0, 0, 0, 1, 0x67]);
753    }
754
755    #[tokio::test]
756    async fn publish_aac_builds_adts_frames() {
757        let (mut session, _engine) = publishing_session().await;
758        // AudioSpecificConfig: AAC-LC, 44.1 kHz, stereo → bytes 0x12 0x10.
759        let asc = [0x12u8, 0x10];
760        let seq = flv::build_audio_tag(flv::PKT_SEQUENCE_HEADER, &asc);
761        session
762            .on_audio(&RtmpMessage {
763                type_id: MSG_AUDIO,
764                timestamp: 0,
765                msg_stream_id: STREAM_ID,
766                payload: seq,
767            })
768            .unwrap();
769
770        let raw = flv::build_audio_tag(flv::PKT_RAW, &[0x01, 0x02, 0x03]);
771        session
772            .on_audio(&RtmpMessage {
773                type_id: MSG_AUDIO,
774                timestamp: 23,
775                msg_stream_id: STREAM_ID,
776                payload: raw,
777            })
778            .unwrap();
779
780        // Subscribe after the fact: the cached audio config is the raw ASC.
781        let handle = session.handle.as_ref().unwrap();
782        let (_, acfg) = handle.cached_configs();
783        assert_eq!(&acfg.unwrap().data[..], &asc);
784    }
785
786    #[tokio::test]
787    async fn full_publish_session_drives_real_chunk_reader() {
788        use tokio::io::AsyncReadExt;
789
790        let engine = Engine::builder()
791            .application(AppSpec::new("live").gop_cache(64))
792            .build();
793
794        // Real TCP loopback so the client can half-close (FIN) its write side while
795        // still draining the server's control responses — exactly how OBS/FFmpeg
796        // behave. The session's post-handshake event loop is driven directly.
797        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
798        let addr = listener.local_addr().unwrap();
799        let engine_for_server = engine.clone();
800        let server = tokio::spawn(async move {
801            let (sock, _) = listener.accept().await.unwrap();
802            let (r, w) = sock.into_split();
803            let mut session = Session::new(
804                ChunkReader::new(r),
805                ChunkWriter::new(w),
806                engine_for_server,
807                None,
808                None,
809                None,
810            );
811            session.run().await.unwrap(); // EOF on client FIN → Ok
812            let handle = session.handle.as_ref().expect("publish handle");
813            let has_config = handle.cached_configs().0.is_some();
814            let has_keyframe = handle.replay_buffer().iter().any(|f| f.is_keyframe());
815            (has_config, has_keyframe)
816        });
817
818        let client = tokio::spawn(async move {
819            let stream = tokio::net::TcpStream::connect(addr).await.unwrap();
820            let (mut read_half, write_half) = stream.into_split();
821            // Drain server→client responses so its writes never fail.
822            tokio::spawn(async move {
823                let mut buf = [0u8; 4096];
824                while let Ok(n) = read_half.read(&mut buf).await {
825                    if n == 0 {
826                        break;
827                    }
828                }
829            });
830
831            let cmd = |values: &[Amf0Value]| {
832                let mut b = bytes::BytesMut::new();
833                for v in values {
834                    v.encode(&mut b);
835                }
836                b
837            };
838            let mut w = ChunkWriter::new(write_half);
839            let connect = cmd(&[
840                amf::string("connect"),
841                Amf0Value::Number(1.0),
842                amf::object(vec![("app", amf::string("live"))]),
843            ]);
844            w.write_message(CSID_COMMAND, MSG_COMMAND_AMF0, 0, 0, &connect)
845                .await
846                .unwrap();
847            let create = cmd(&[
848                amf::string("createStream"),
849                Amf0Value::Number(2.0),
850                Amf0Value::Null,
851            ]);
852            w.write_message(CSID_COMMAND, MSG_COMMAND_AMF0, 0, 0, &create)
853                .await
854                .unwrap();
855            let publish = cmd(&[
856                amf::string("publish"),
857                Amf0Value::Number(3.0),
858                Amf0Value::Null,
859                amf::string("cam"),
860                amf::string("live"),
861            ]);
862            w.write_message(CSID_COMMAND, MSG_COMMAND_AMF0, 0, 0, &publish)
863                .await
864                .unwrap();
865
866            // Media: AVC sequence header then a keyframe.
867            let seq =
868                flv::build_video_tag(false, flv::PKT_SEQUENCE_HEADER, 0, &avc_decoder_config());
869            w.write_message(CSID_VIDEO, MSG_VIDEO, 0, STREAM_ID, &seq)
870                .await
871                .unwrap();
872            let kf = flv::build_video_tag(true, flv::PKT_RAW, 0, &avcc_idr());
873            w.write_message(CSID_VIDEO, MSG_VIDEO, 40, STREAM_ID, &kf)
874                .await
875                .unwrap();
876            // Dropping the OwnedWriteHalf sends FIN — the server reader sees EOF.
877        });
878
879        client.await.unwrap();
880        let (has_config, has_keyframe) = server.await.unwrap();
881        assert!(has_config, "video config reached the engine over the wire");
882        assert!(has_keyframe, "keyframe reached the GOP cache over the wire");
883    }
884
885    #[test]
886    fn annexb_config_splits_sps_and_pps() {
887        let annexb = Bytes::from_static(&[0, 0, 0, 1, 0x67, 0x42, 0x00, 1, 0, 0, 0, 1, 0x68, 0xCE]);
888        let cfg = annexb_to_avc_config(&annexb);
889        assert_eq!(cfg.sps.len(), 1);
890        assert_eq!(cfg.pps.len(), 1);
891        assert_eq!(cfg.sps[0][0] & 0x1F, 7);
892        assert_eq!(cfg.pps[0][0] & 0x1F, 8);
893    }
894}