Skip to main content

rff/
probe.rs

1//! Media inspection — the engine side of `ffprobe`.
2//!
3//! [`probe`] opens an input, identifies its container, reads the stream
4//! headers, and returns a structured [`MediaInfo`]. The CLI's `ffprobe` and the
5//! server's `POST /v1/probe` both call straight into this.
6
7use std::io::Read;
8use std::path::Path;
9
10use rff_core::{CodecId, Error, MediaType, Rational, Result};
11
12use crate::Engine;
13
14/// Everything we learned about an input.
15#[derive(Debug, Clone)]
16pub struct MediaInfo {
17    /// Short name of the detected container format (e.g. `avi`).
18    pub format_name: String,
19    /// One entry per elementary stream.
20    pub streams: Vec<StreamInfo>,
21}
22
23/// Summary of one elementary stream.
24#[derive(Debug, Clone)]
25pub struct StreamInfo {
26    pub index: usize,
27    pub media_type: MediaType,
28    pub codec_id: CodecId,
29    pub time_base: Rational,
30    pub width: u32,
31    pub height: u32,
32    pub sample_rate: u32,
33    pub channels: u16,
34}
35
36/// Probe a media file at `path`.
37///
38/// The container is currently chosen by file extension; content-sniffing
39/// (reading magic bytes) is a planned upgrade. Reading the header itself relies
40/// on the corresponding demuxer, which is still scaffolded — so today this
41/// resolves the format and then surfaces the demuxer's
42/// [`Unimplemented`](rff_core::Error::Unimplemented) state. The plumbing is
43/// real; only the per-format parsing is pending.
44pub fn probe(engine: &Engine, path: impl AsRef<Path>) -> Result<MediaInfo> {
45    let path = path.as_ref();
46    let path_str = path
47        .to_str()
48        .ok_or_else(|| Error::Option("input path is not valid UTF-8".into()))?;
49    let (format_name, reader) = open_source(engine, path_str, None)?;
50    let mut demuxer = engine.formats.open_demuxer(&format_name, reader)?;
51
52    let streams = demuxer.read_header()?;
53    let streams = streams
54        .into_iter()
55        .map(|s| StreamInfo {
56            index: s.index,
57            media_type: s.media_type,
58            codec_id: s.codec_id,
59            time_base: s.time_base,
60            width: s.width,
61            height: s.height,
62            sample_rate: s.sample_rate,
63            channels: s.channels,
64        })
65        .collect();
66
67    Ok(MediaInfo {
68        format_name,
69        streams,
70    })
71}
72
73/// Decide which container a path should be read as: an explicit `-f` override
74/// wins; otherwise sniff the file's leading bytes (content beats filename);
75/// otherwise fall back to the extension. Shared by [`probe`] and the transcode
76/// input path so both detect formats the same way.
77pub(crate) fn detect_input_format(
78    engine: &Engine,
79    path: &Path,
80    forced: Option<&str>,
81) -> Result<String> {
82    if let Some(name) = forced {
83        return Ok(name.to_string());
84    }
85    // Content sniff (best effort — an unreadable head just falls through).
86    if let Ok(head) = read_head(path, 4096) {
87        if let Some(format) = engine.formats.probe(&head) {
88            return Ok(format.name.to_string());
89        }
90    }
91    // Filename fallback.
92    let ext = path
93        .extension()
94        .and_then(|e| e.to_str())
95        .unwrap_or_default();
96    engine
97        .formats
98        .by_extension(ext)
99        .map(|f| f.name.to_string())
100        .ok_or_else(|| Error::DemuxerNotFound(path.display().to_string()))
101}
102
103/// Read up to `max` leading bytes of a file for content sniffing.
104fn read_head(path: &Path, max: usize) -> std::io::Result<Vec<u8>> {
105    let file = std::fs::File::open(path)?;
106    let mut buf = Vec::new();
107    file.take(max as u64).read_to_end(&mut buf)?;
108    Ok(buf)
109}
110
111/// Open a path-or-URL as a streaming reader and decide its container format.
112///
113/// Local files keep the read-twice (sniff then open) path. Streams (URLs,
114/// pipes, UDP) are opened once, their head peeked for content sniffing, then
115/// chained back in front so the demuxer sees the whole stream from byte 0.
116/// HLS playlists (`-f hls`, an `.m3u8` name, or `#EXTM3U` content) expand to
117/// one chained stream over their TS segments first. Shared by [`probe`]
118/// (ffprobe) and the transcode input path (ffmpeg).
119pub(crate) fn open_source(
120    engine: &Engine,
121    path: &str,
122    forced: Option<&str>,
123) -> Result<(String, Box<dyn Read + Send>)> {
124    let stem = path.split(['?', '#']).next().unwrap_or(path);
125    if forced == Some("hls") || stem.to_ascii_lowercase().ends_with(".m3u8") {
126        let reader = rff_format_hls::open_input(path)?;
127        return sniff_stream(engine, reader, None, path);
128    }
129    if !rff_io::is_stream(path) {
130        let format = detect_input_format(engine, Path::new(path), forced)?;
131        return Ok((format, Box::new(std::fs::File::open(path)?)));
132    }
133    // RTP has no container to sniff: the reader classifies the stream by its
134    // payload type on the first packet and names the format itself.
135    if rff_io::is_rtp(path) {
136        let reader = rff_io::RtpReader::bind(path)?;
137        let format = forced
138            .map(str::to_string)
139            .unwrap_or_else(|| reader.format_name().to_string());
140        return Ok((format, Box::new(reader)));
141    }
142    let reader = rff_io::open(path)?;
143    sniff_stream(engine, reader, forced, path)
144}
145
146/// Peek a stream's head, pick its format (forced > content sniff > URL
147/// extension), and hand back head+rest chained. An `#EXTM3U` head re-opens the
148/// source as HLS input (possible for URLs/files; a pipe can't be re-read).
149fn sniff_stream(
150    engine: &Engine,
151    mut reader: Box<dyn Read + Send>,
152    forced: Option<&str>,
153    path: &str,
154) -> Result<(String, Box<dyn Read + Send>)> {
155    let mut head = vec![0u8; 4096];
156    let n = read_some(&mut reader, &mut head)?;
157    head.truncate(n);
158    if forced.is_none() && head.starts_with(b"#EXTM3U") {
159        if rff_io::is_pipe(path) {
160            return Err(Error::unsupported(
161                "hls input from a pipe (the playlist's segments cannot be fetched relative to stdin)",
162            ));
163        }
164        let reader = rff_format_hls::open_input(path)?;
165        return sniff_stream(engine, reader, None, path);
166    }
167    let format = match forced {
168        Some(f) => f.to_string(),
169        None => engine
170            .formats
171            .probe(&head)
172            .map(|f| f.name.to_string())
173            .or_else(|| url_extension_format(engine, path))
174            .ok_or_else(|| Error::DemuxerNotFound(format!("{path} (pass -f)")))?,
175    };
176    let chained: Box<dyn Read + Send> = Box::new(std::io::Cursor::new(head).chain(reader));
177    Ok((format, chained))
178}
179
180/// Read up to `buf.len()` bytes, tolerating short reads (network streams arrive
181/// in pieces); stops at EOF.
182fn read_some(reader: &mut dyn Read, buf: &mut [u8]) -> Result<usize> {
183    let mut filled = 0;
184    while filled < buf.len() {
185        match reader.read(&mut buf[filled..]) {
186            Ok(0) => break,
187            Ok(n) => filled += n,
188            Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
189            Err(e) => return Err(e.into()),
190        }
191    }
192    Ok(filled)
193}
194
195/// Guess a container from a URL's extension (after the last `.`, ignoring any
196/// `?query`/`#fragment`).
197fn url_extension_format(engine: &Engine, url: &str) -> Option<String> {
198    let stem = url.split(['?', '#']).next().unwrap_or(url);
199    let ext = stem.rsplit('.').next().unwrap_or("");
200    engine.formats.by_extension(ext).map(|f| f.name.to_string())
201}