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 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(())
}
async fn send_media(&mut self, media: &Media) -> Result<u64, 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 RtmpTransport { stream, client, .. } = self;
let stream = stream.as_mut().ok_or_else(|| {
SendMediaError::Transport(Box::new(RtmpPushError::Connect("not connected".into())))
})?;
let mut total = 0u64;
for payload in &payloads {
let sent = match payload.kind {
transmux::FlvPayloadKind::Video => {
Some(client.send_video(payload.timestamp_ms, &payload.body))
}
transmux::FlvPayloadKind::Audio => {
Some(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())))
})?;
stream
.write_all(&bytes)
.await
.map_err(|e| SendMediaError::Transport(Box::new(RtmpPushError::Io(e))))?;
total += payload.body.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),
}