media_pp/elements/source/rtsp_source.rs
1use std::{sync::Arc, time::Duration};
2
3use crate::pp_log::{PpLog, pp_error, pp_info};
4use ffmpeg_next as ffmpeg;
5use thiserror::Error as ThisError;
6
7use crate::{
8 buffer::MediaBuffer,
9 bus::{Bus, BusEvent},
10 contract::{MediaKind, OutputContract, PortContract},
11 control::{ControlReceiver, drain_control},
12 element::{Element, ElementType, Source, SourceElement, element_pp_log},
13 elements::RtspTransport,
14 error::Result,
15 pad::SrcPad,
16};
17
18use super::file_demuxer::StreamInfo;
19
20/// Errors specific to `RtspSource`. Converts into the crate-wide `Error`
21/// via `?` (see [`crate::error::Error`]).
22#[derive(Debug, ThisError)]
23pub enum RtspSourceError {
24 /// FFmpeg rejected connection setup, stream reading, or shutdown.
25 #[error("ffmpeg error: {0}")]
26 Ffmpeg(#[from] ffmpeg::Error),
27 /// Seeking was requested on a live RTSP stream.
28 #[error("RtspSource doesn't support seeking a live stream")]
29 SeekUnsupported,
30}
31
32/// Construction-time options for [`RtspSource::open`].
33#[derive(Debug, Clone, Copy)]
34pub struct RtspOptions {
35 /// Transport used for RTSP media delivery.
36 pub transport: RtspTransport,
37 /// Socket I/O timeout — ffmpeg's own `timeout` RTSP demuxer option,
38 /// which covers the initial connect/handshake reads too, not just
39 /// steady-state ones. Without this, ffmpeg's own default is *no
40 /// timeout at all*, meaning [`RtspSource::open`] can hang forever
41 /// against an unreachable or dead server.
42 pub timeout: Duration,
43}
44
45impl Default for RtspOptions {
46 fn default() -> Self {
47 Self {
48 transport: RtspTransport::Tcp,
49 timeout: Duration::from_secs(5),
50 }
51 }
52}
53
54/// Demuxes a live RTSP stream — the client/receive counterpart to
55/// [`crate::elements::RtspSink`] (which publishes). One src pad per
56/// stream the server advertises, same shape as
57/// [`crate::elements::FileDemuxer`].
58///
59/// Deliberately does **not** retry or reconnect internally: a read failure
60/// (dropped connection, camera reboot, ...) ends this source's thread with
61/// `Err`, the same way any other fatal [`SourceElement::run`] failure
62/// does, instead of looping forever inside `run()`. Reconnecting means
63/// building a fresh `RtspSource`/[`crate::pipeline::Pipeline`] — mirrors
64/// `Pipeline` itself not being reusable once it ends: watch
65/// [`crate::pipeline::Pipeline::bus`], and on error, call
66/// [`RtspSource::open`] again.
67///
68/// Uses `Packet::read` directly instead of `Input::packets()` — the
69/// latter silently retries forever inside its own `next()` on any non-EOF
70/// error (network timeout, connection reset, ...), which would make a
71/// stuck connection un-`Stop`-able (`drain_control` never gets a turn)
72/// and this element's "fail fast, don't retry" contract impossible to
73/// keep.
74pub struct RtspSource {
75 pp_log: PpLog,
76 name: Arc<str>,
77 input: ffmpeg::format::context::Input,
78 pads: Vec<SrcPad>,
79}
80
81impl RtspSource {
82 /// Connects to `url` (e.g. `rtsp://host:port/path`) and returns the
83 /// element alongside every stream the server advertised, so the
84 /// caller can inspect them before deciding which of `src_pads()` to
85 /// link — same pattern as `FileDemuxer::open`.
86 pub fn open(
87 name: impl Into<String>,
88 url: impl AsRef<str>,
89 options: RtspOptions,
90 ) -> std::result::Result<(Self, Vec<StreamInfo>), RtspSourceError> {
91 let mut dict = ffmpeg::Dictionary::new();
92 dict.set("rtsp_transport", options.transport.as_ffmpeg_option());
93 dict.set("timeout", &options.timeout.as_micros().to_string());
94
95 let input = ffmpeg::format::input_with_dictionary(url.as_ref(), dict)?;
96
97 let streams: Vec<StreamInfo> = input
98 .streams()
99 .map(|s| StreamInfo {
100 index: s.index(),
101 kind: s.parameters().medium(),
102 })
103 .collect();
104
105 let pads = streams
106 .iter()
107 .map(|s| {
108 // Per stream, from the medium the session announced: both
109 // pads emit `MediaBuffer::Packet`, so only this tells an
110 // audio stream apart from a video one. A medium this crate
111 // does not model declares nothing and is left to the
112 // runtime check.
113 match MediaKind::packet_for(s.kind) {
114 Some(kind) => SrcPad::with_contract(
115 format!("src_{}", s.index),
116 OutputContract::Fixed(PortContract::packet(kind)),
117 ),
118 None => SrcPad::new(format!("src_{}", s.index)),
119 }
120 })
121 .collect();
122
123 let name: Arc<str> = name.into().into();
124 let pp_log = element_pp_log(ElementType::RtspSource, &name, None);
125 pp_info!(
126 pp_log: &pp_log,
127 "opened: url={}, transport={:?}, {} stream(s)",
128 url.as_ref(),
129 options.transport,
130 streams.len()
131 );
132 Ok((
133 Self {
134 name,
135 pp_log,
136 input,
137 pads,
138 },
139 streams,
140 ))
141 }
142
143 /// Codec parameters for one of this stream's streams — what you need
144 /// to construct a matching [`crate::elements::SwDecoder`] for it.
145 pub fn stream_parameters(&self, index: usize) -> Option<ffmpeg::codec::Parameters> {
146 self.stream(index).map(|s| s.parameters())
147 }
148
149 /// The unit decoded frame timestamps for this stream are expressed in
150 /// — what you need to construct a matching [`crate::elements::Pacer`]
151 /// for it.
152 pub fn stream_time_base(&self, index: usize) -> Option<ffmpeg::Rational> {
153 self.stream(index).map(|s| s.time_base())
154 }
155
156 fn stream(&self, index: usize) -> Option<ffmpeg::format::stream::Stream<'_>> {
157 self.input.streams().find(|s| s.index() == index)
158 }
159}
160
161impl Element for RtspSource {
162 fn name(&self) -> Arc<str> {
163 self.name.clone()
164 }
165
166 fn element_type(&self) -> ElementType {
167 ElementType::RtspSource
168 }
169
170 fn pp_log(&self) -> &PpLog {
171 &self.pp_log
172 }
173
174 fn pp_log_mut(&mut self) -> &mut PpLog {
175 &mut self.pp_log
176 }
177}
178
179impl Source for RtspSource {
180 fn src_pads(&mut self) -> &mut [SrcPad] {
181 &mut self.pads
182 }
183}
184
185impl SourceElement for RtspSource {
186 fn is_live(&self) -> bool {
187 true
188 }
189
190 fn is_seekable(&self) -> bool {
191 false
192 }
193
194 fn run(&mut self, control: &ControlReceiver, bus: &Bus) -> Result<()> {
195 pp_info!(self, "started");
196 loop {
197 if drain_control(control, self, bus)?.stopped {
198 pp_info!(self, "stopped");
199 return Ok(());
200 }
201
202 let mut packet = ffmpeg::Packet::empty();
203 match packet.read(&mut self.input) {
204 Ok(()) => {
205 let index = packet.stream();
206 if let Some(pad) = self.pads.get_mut(index) {
207 // A downstream failure drops just this one packet
208 // — same "report, don't die" contract `Queue`'s
209 // worker gives a failing `Sink` — rather than
210 // ending this whole source thread over it.
211 if let Err(error) = pad.push(MediaBuffer::Packet(Arc::new(packet))) {
212 bus.post(
213 &self.pp_log,
214 BusEvent::Error {
215 element_type: ElementType::RtspSource,
216 name: self.name.clone(),
217 error,
218 },
219 );
220 }
221 }
222 }
223 // A real on-demand RTSP stream can send a clean EOF; a
224 // live camera essentially never will, but treat it the
225 // same way `FileDemuxer` treats running out of packets.
226 Err(ffmpeg::Error::Eof) => break,
227 // Anything else (connection reset, socket timeout, ...) is
228 // fatal — reported and this thread ends, rather than
229 // retried. See this type's own docs on why: retrying
230 // belongs to whoever's watching the bus, building a fresh
231 // `RtspSource` to reconnect with.
232 Err(error) => {
233 pp_error!(self, "read failed: {error}");
234 return Err(RtspSourceError::Ffmpeg(error).into());
235 }
236 }
237 }
238 for pad in self.pads.iter_mut() {
239 pad.push_eos(&self.pp_log)?;
240 }
241 pp_info!(self, "event=eos phase=source_completed outcome=ok");
242 Ok(())
243 }
244
245 fn seek(&mut self, _target: Duration) -> Result<Duration> {
246 Err(RtspSourceError::SeekUnsupported.into())
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use std::time::Instant;
253
254 use super::*;
255
256 /// `RtspOptions::timeout`'s whole reason for existing: without it
257 /// ffmpeg applies *no* timeout at all and `open` blocks forever against
258 /// a server that never answers. `192.0.2.1` is RFC 5737 TEST-NET-1,
259 /// reserved for documentation and guaranteed not to be routed, so this
260 /// exercises the "no answer" path rather than a fast connection refusal.
261 ///
262 /// Only the upper bound is asserted: a network that replies with an ICMP
263 /// unreachable makes this fail even sooner, which is equally correct. The
264 /// bound sits well under the OS-level TCP connect timeout (~21s on
265 /// Windows, far longer on Linux), so a regression that stops passing the
266 /// option through is what actually trips it.
267 #[test]
268 fn open_gives_up_within_the_configured_timeout_instead_of_hanging() {
269 let options = RtspOptions {
270 timeout: Duration::from_millis(500),
271 ..Default::default()
272 };
273
274 let started = Instant::now();
275 let result = RtspSource::open("rtsp", "rtsp://192.0.2.1:554/none", options);
276 let elapsed = started.elapsed();
277
278 assert!(
279 result.is_err(),
280 "opening an unroutable address must not succeed"
281 );
282 assert!(
283 elapsed < Duration::from_secs(10),
284 "open took {elapsed:?} — the configured timeout is not reaching ffmpeg"
285 );
286 }
287
288 /// A caller that never touches `RtspOptions` still has to get a bounded
289 /// `open`, since the default this type supplies is the only thing
290 /// standing between them and ffmpeg's unbounded one.
291 #[test]
292 fn the_default_options_still_bound_the_connection() {
293 let options = RtspOptions::default();
294
295 assert_eq!(options.transport, RtspTransport::Tcp);
296 assert!(
297 options.timeout > Duration::ZERO,
298 "the default timeout must not be ffmpeg's unbounded one"
299 );
300 }
301}