use crate::push::{PushTransport, SendMediaError};
use rtmp_runtime::amf0::Amf0Value;
use rtmp_runtime::client::{ClientConfig, ClientSession};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use transmux::CodecConfig;
use transmux::ir::{Media, Track, TrackSpec};
const META_VIDEOCODECID_AVC: f64 = 7.0;
const META_AUDIOCODECID_AAC: f64 = 10.0;
fn is_flv_codec(config: &CodecConfig) -> bool {
matches!(config, CodecConfig::Avc { .. } | CodecConfig::Aac { .. })
}
fn build_metadata(tracks: &[TrackSpec]) -> Vec<(String, Amf0Value)> {
let mut meta = Vec::new();
for t in tracks {
match &t.config {
CodecConfig::Avc { width, height, .. } => {
meta.push(("width".to_string(), Amf0Value::Number(*width as f64)));
meta.push(("height".to_string(), Amf0Value::Number(*height as f64)));
meta.push((
"videocodecid".to_string(),
Amf0Value::Number(META_VIDEOCODECID_AVC),
));
}
CodecConfig::Aac {
sample_rate,
channel_count,
..
} => {
meta.push((
"audiocodecid".to_string(),
Amf0Value::Number(META_AUDIOCODECID_AAC),
));
meta.push((
"audiosamplerate".to_string(),
Amf0Value::Number(*sample_rate as f64),
));
meta.push((
"audiochannels".to_string(),
Amf0Value::Number(*channel_count as f64),
));
}
_ => {}
}
}
meta
}
#[derive(Debug, Clone)]
pub struct RtmpTransportConfig {
pub app: String,
pub stream_key: String,
}
impl Default for RtmpTransportConfig {
fn default() -> Self {
Self {
app: "live".to_string(),
stream_key: String::new(),
}
}
}
pub struct RtmpTransport {
stream: Option<TcpStream>,
client: ClientSession,
warned_refused_tracks: bool,
}
impl std::fmt::Debug for RtmpTransport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RtmpTransport")
.field("connected", &self.stream.is_some())
.finish()
}
}
#[async_trait::async_trait]
impl PushTransport for RtmpTransport {
type Config = RtmpTransportConfig;
type Error = RtmpPushError;
async fn connect(url: &str, config: &Self::Config) -> Result<Self, Self::Error> {
let parsed = url::Url::parse(url).map_err(|e| RtmpPushError::Connect(e.to_string()))?;
let host = parsed.host_str().unwrap_or("127.0.0.1");
let port = parsed.port().unwrap_or(1935);
let addr = format!("{host}:{port}");
let mut stream = TcpStream::connect(&addr)
.await
.map_err(|e| RtmpPushError::Connect(e.to_string()))?;
let tc_url = format!("rtmp://{host}:{port}/{}", config.app);
let mut client_config = ClientConfig::default();
client_config.app = config.app.clone();
client_config.stream_key = config.stream_key.clone();
client_config.tc_url = Some(tc_url);
let mut client = ClientSession::new(client_config);
let c0_c1 = client.start();
stream.write_all(&c0_c1).await.map_err(RtmpPushError::Io)?;
let mut buf = vec![0u8; 8192];
loop {
let n = stream.read(&mut buf).await.map_err(RtmpPushError::Io)?;
if n == 0 {
return Err(RtmpPushError::Connect(
"connection closed during handshake".into(),
));
}
let (reply, events) = client
.handle_data(&buf[..n])
.map_err(|e| RtmpPushError::Protocol(e.to_string()))?;
if !reply.is_empty() {
stream.write_all(&reply).await.map_err(RtmpPushError::Io)?;
}
if client.is_publishing() {
return Ok(Self {
stream: Some(stream),
client,
warned_refused_tracks: false,
});
}
if events
.iter()
.any(|e| matches!(e, rtmp_runtime::client::ClientEvent::Error { .. }))
{
return Err(RtmpPushError::Protocol("server rejected connection".into()));
}
}
}
async fn send(&mut self, data: &[u8]) -> Result<(), Self::Error> {
let stream = self
.stream
.as_mut()
.ok_or_else(|| RtmpPushError::Connect("not connected".into()))?;
let bytes = self
.client
.send_video(0, data)
.map_err(|e| RtmpPushError::Protocol(e.to_string()))?;
stream.write_all(&bytes).await.map_err(RtmpPushError::Io)
}
async fn write_message(&mut self, message: &[u8]) -> Result<(), Self::Error> {
let stream = self
.stream
.as_mut()
.ok_or_else(|| RtmpPushError::Connect("not connected".into()))?;
stream.write_all(message).await.map_err(RtmpPushError::Io)
}
fn supports_codec(&self, config: &CodecConfig) -> bool {
is_flv_codec(config)
}
async fn setup(&mut self, tracks: &[TrackSpec]) -> Result<(), Self::Error> {
let flv_tracks: Vec<TrackSpec> = tracks
.iter()
.filter(|t| is_flv_codec(&t.config))
.cloned()
.collect();
if flv_tracks.is_empty() {
return Err(RtmpPushError::Protocol(
"no AVC video or AAC audio track to publish over RTMP".into(),
));
}
let RtmpTransport { stream, client, .. } = self;
let stream = stream
.as_mut()
.ok_or_else(|| RtmpPushError::Connect("not connected".into()))?;
let metadata = build_metadata(&flv_tracks);
let meta_bytes = client
.send_metadata(&metadata)
.map_err(|e| RtmpPushError::Protocol(e.to_string()))?;
stream
.write_all(&meta_bytes)
.await
.map_err(RtmpPushError::Io)?;
let media = Media::new(
flv_tracks
.into_iter()
.map(|spec| Track::new(spec, Vec::new()))
.collect(),
0,
);
let headers = transmux::flv_sequence_header_payloads(&media)
.map_err(|e| RtmpPushError::Protocol(e.to_string()))?;
for header in &headers {
let sent = match header.kind {
transmux::FlvPayloadKind::Video => Some(client.send_video(0, &header.body)),
transmux::FlvPayloadKind::Audio => Some(client.send_audio(0, &header.body)),
_ => None,
};
let Some(bytes) = sent else { continue };
let bytes = bytes.map_err(|e| RtmpPushError::Protocol(e.to_string()))?;
stream.write_all(&bytes).await.map_err(RtmpPushError::Io)?;
}
Ok(())
}
fn encode_media(&mut self, media: &Media) -> Result<Vec<bytes::Bytes>, SendMediaError> {
let flv_tracks: Vec<Track> = media
.tracks
.iter()
.filter(|t| is_flv_codec(&t.spec.config))
.cloned()
.collect();
let refused = media.tracks.len() - flv_tracks.len();
if refused > 0 && !self.warned_refused_tracks {
self.warned_refused_tracks = true;
let refused_track_ids: Vec<u32> = media
.tracks
.iter()
.filter(|t| !is_flv_codec(&t.spec.config))
.map(|t| t.spec.track_id)
.collect();
tracing::warn!(
refused,
carried = flv_tracks.len(),
?refused_track_ids,
"RTMP push cannot carry these tracks — FLV carries only AVC video \
and AAC audio; they are excluded from this push"
);
}
if flv_tracks.is_empty() {
return Err(SendMediaError::Mux(format!(
"no RTMP-carriable track: FLV carries only AVC video and AAC audio, \
but all {} track(s) in this program are other codecs",
media.tracks.len()
)));
}
let filtered = Media::new(flv_tracks, media.movie_timescale);
let payloads = transmux::flv_frame_payloads(&filtered)
.map_err(|e| SendMediaError::Mux(e.to_string()))?;
let mut messages = Vec::with_capacity(payloads.len());
for payload in &payloads {
let sent = match payload.kind {
transmux::FlvPayloadKind::Video => {
Some(self.client.send_video(payload.timestamp_ms, &payload.body))
}
transmux::FlvPayloadKind::Audio => {
Some(self.client.send_audio(payload.timestamp_ms, &payload.body))
}
_ => None,
};
let Some(bytes) = sent else { continue };
let bytes = bytes.map_err(|e| {
SendMediaError::Transport(Box::new(RtmpPushError::Protocol(e.to_string())))
})?;
messages.push(bytes::Bytes::from(bytes));
}
Ok(messages)
}
async fn send_media(&mut self, media: &Media) -> Result<u64, SendMediaError> {
let messages = self.encode_media(media)?;
let stream = self.stream.as_mut().ok_or_else(|| {
SendMediaError::Transport(Box::new(RtmpPushError::Connect("not connected".into())))
})?;
let mut total = 0u64;
for message in &messages {
stream
.write_all(message)
.await
.map_err(|e| SendMediaError::Transport(Box::new(RtmpPushError::Io(e))))?;
total += message.len() as u64;
}
Ok(total)
}
fn close(&mut self) {
self.stream = None;
}
}
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum RtmpPushError {
#[error("RTMP connect failed: {0}")]
Connect(String),
#[error("RTMP protocol error: {0}")]
Protocol(String),
#[error("RTMP I/O error: {0}")]
Io(#[from] std::io::Error),
}
#[cfg(test)]
mod tests {
use super::*;
fn avc_config() -> CodecConfig {
CodecConfig::Avc {
config: transmux::AVCConfigurationBox::new(transmux::AVCDecoderConfigurationRecord {
configuration_version: 1,
profile_indication: 0x42,
profile_compatibility: 0,
level_indication: 0x1f,
length_size_minus_one: 3,
sps: Vec::new(),
pps: Vec::new(),
chroma_format: None,
bit_depth_luma_minus8: None,
bit_depth_chroma_minus8: None,
sps_ext: Vec::new(),
}),
width: 0,
height: 0,
}
}
fn opaque_config() -> CodecConfig {
CodecConfig::Data {
stream_type: 0x06,
descriptors: Vec::new(),
carriage: transmux::ir::DataCarriage::Pes,
}
}
#[test]
fn supports_codec_matches_is_flv_codec() {
let transport = RtmpTransport {
stream: None,
client: rtmp_runtime::client::ClientSession::new(ClientConfig::default()),
warned_refused_tracks: false,
};
assert!(
transport.supports_codec(&avc_config()),
"AVC must be RTMP-carriable"
);
assert!(
!transport.supports_codec(&opaque_config()),
"opaque PES data must not be RTMP-carriable"
);
}
}