Skip to main content

qubit_mime/classifier/
ffprobe_command_media_stream_classifier.rs

1// =============================================================================
2//    Copyright (c) 2026 Haixing Hu.
3//
4//    SPDX-License-Identifier: Apache-2.0
5//
6//    Licensed under the Apache License, Version 2.0.
7// =============================================================================
8//! FFprobe-backed media stream classifier.
9
10use std::path::Path;
11
12use qubit_command::CommandRunner;
13use qubit_command::{
14    Command,
15    CommandError,
16};
17
18use crate::{
19    FileBasedMediaStreamClassifier,
20    MediaStreamType,
21    MimeConfig,
22    MimeResult,
23};
24
25/// Media stream classifier backed by the `ffprobe` command.
26#[derive(Debug, Clone)]
27pub struct FfprobeCommandMediaStreamClassifier {
28    /// The working directory used to execute FFprobe.
29    working_directory: Option<String>,
30    /// The command runner used to execute FFprobe.
31    command_runner: CommandRunner,
32    /// Maximum bytes staged from reader/content input.
33    max_staging_size: u64,
34}
35
36impl FfprobeCommandMediaStreamClassifier {
37    /// FFprobe executable name.
38    pub const COMMAND: &'static str = "ffprobe";
39    /// FFprobe stream name for video streams.
40    pub const VIDEO_STREAM: &'static str = "video";
41    /// FFprobe stream name for audio streams.
42    pub const AUDIO_STREAM: &'static str = "audio";
43
44    /// Creates a FFprobe-backed classifier.
45    ///
46    /// # Returns
47    /// A classifier using the current process working directory.
48    pub fn new() -> Self {
49        Self::from_mime_config(MimeConfig::default())
50    }
51
52    /// Creates a FFprobe-backed classifier from MIME configuration.
53    ///
54    /// # Parameters
55    /// - `config`: MIME configuration providing reader/content staging limits.
56    ///
57    /// # Returns
58    /// A classifier using the current process working directory.
59    pub fn from_mime_config(config: MimeConfig) -> Self {
60        Self {
61            working_directory: None,
62            command_runner: Self::default_command_runner(),
63            max_staging_size: config.media_stream_max_staging_size(),
64        }
65    }
66
67    /// Gets the command runner used by this classifier.
68    ///
69    /// # Returns
70    /// Runner used for `ffprobe` command executions.
71    pub fn command_runner(&self) -> &CommandRunner {
72        &self.command_runner
73    }
74
75    /// Gets the maximum bytes staged from reader/content input.
76    ///
77    /// # Returns
78    /// Maximum accepted stream size before staging is rejected.
79    pub fn max_staging_size(&self) -> u64 {
80        self.max_staging_size
81    }
82
83    /// Replaces the maximum bytes staged from reader/content input.
84    ///
85    /// # Parameters
86    /// - `max_staging_size`: Maximum accepted stream size.
87    pub fn set_max_staging_size(&mut self, max_staging_size: u64) {
88        self.max_staging_size = max_staging_size;
89    }
90
91    /// Replaces the maximum staging size and returns the updated classifier.
92    ///
93    /// # Parameters
94    /// - `max_staging_size`: Maximum accepted stream size.
95    ///
96    /// # Returns
97    /// The updated classifier.
98    pub fn with_max_staging_size(mut self, max_staging_size: u64) -> Self {
99        self.max_staging_size = max_staging_size;
100        self
101    }
102
103    /// Replaces the command runner used by this classifier.
104    ///
105    /// # Parameters
106    /// - `command_runner`: New runner configuration.
107    pub fn set_command_runner(&mut self, command_runner: CommandRunner) {
108        self.command_runner = command_runner;
109    }
110
111    /// Replaces the command runner and returns the updated classifier.
112    ///
113    /// # Parameters
114    /// - `command_runner`: New runner configuration.
115    ///
116    /// # Returns
117    /// The updated classifier.
118    pub fn with_command_runner(
119        mut self,
120        command_runner: CommandRunner,
121    ) -> Self {
122        self.command_runner = command_runner;
123        self
124    }
125
126    /// Sets the working directory used to execute FFprobe.
127    ///
128    /// # Parameters
129    /// - `working_directory`: Optional working directory path.
130    pub fn set_working_directory(&mut self, working_directory: Option<String>) {
131        self.working_directory = working_directory;
132    }
133
134    /// Gets the configured working directory.
135    ///
136    /// # Returns
137    /// Stored working directory, or `None`.
138    pub fn working_directory(&self) -> Option<&str> {
139        self.working_directory.as_deref()
140    }
141
142    /// Classifies FFprobe `codec_type` output.
143    ///
144    /// # Parameters
145    /// - `output`: Lines printed by `ffprobe -show_entries stream=codec_type`.
146    ///
147    /// # Returns
148    /// Media stream classification.
149    pub fn classify_stream_listing(output: &str) -> MediaStreamType {
150        let has_video =
151            output.lines().any(|line| line.trim() == Self::VIDEO_STREAM);
152        let has_audio =
153            output.lines().any(|line| line.trim() == Self::AUDIO_STREAM);
154        match (has_video, has_audio) {
155            (true, true) => MediaStreamType::VideoWithAudio,
156            (true, false) => MediaStreamType::VideoOnly,
157            (false, true) => MediaStreamType::AudioOnly,
158            (false, false) => MediaStreamType::None,
159        }
160    }
161
162    /// Checks whether the `ffprobe` command is available.
163    ///
164    /// Availability is checked by executing `ffprobe -version` with the default
165    /// quiet command runner. The result only describes whether the command can
166    /// be started successfully; a particular media file may still be unreadable
167    /// or unsupported.
168    ///
169    /// # Returns
170    /// `true` when `ffprobe -version` executes successfully.
171    pub fn is_available() -> bool {
172        Self::default_command_runner()
173            .run(Command::new(Self::COMMAND).arg("-version"))
174            .is_ok()
175    }
176
177    /// Executes FFprobe for one local file.
178    ///
179    /// # Parameters
180    /// - `path`: Local file path.
181    ///
182    /// # Returns
183    /// Media stream classification. Non-zero FFprobe status is treated as
184    /// [`MediaStreamType::None`] because stream refinement is best-effort.
185    ///
186    /// # Errors
187    /// Returns [`MimeError::Command`](crate::MimeError::Command) when process
188    /// execution itself fails.
189    fn classify_with_ffprobe(
190        &self,
191        path: &Path,
192    ) -> MimeResult<MediaStreamType> {
193        let mut command = Self::command_for_path(path);
194        if let Some(working_directory) = &self.working_directory {
195            command = command.working_directory(working_directory);
196        }
197        match self.command_runner.run(command) {
198            Ok(output) => {
199                let stdout = output.stdout_lossy_text();
200                Ok(Self::classify_stream_listing(&stdout))
201            }
202            Err(CommandError::UnexpectedExit { .. }) => {
203                Ok(MediaStreamType::None)
204            }
205            Err(error) => Err(error.into()),
206        }
207    }
208
209    /// Creates the default command runner for FFprobe classification.
210    ///
211    /// # Returns
212    /// Runner used by the default classifier.
213    fn default_command_runner() -> CommandRunner {
214        CommandRunner::new().disable_logging(true)
215    }
216
217    /// Builds the structured `ffprobe` command for one path.
218    ///
219    /// # Parameters
220    /// - `path`: Local file path passed as an argument without shell parsing.
221    ///
222    /// # Returns
223    /// Structured command description.
224    fn command_for_path(path: &Path) -> Command {
225        Command::new(Self::COMMAND)
226            .arg("-v")
227            .arg("error")
228            .arg("-show_entries")
229            .arg("stream=codec_type")
230            .arg("-of")
231            .arg("csv=p=0")
232            .arg_os(path)
233    }
234}
235
236impl Default for FfprobeCommandMediaStreamClassifier {
237    /// Creates the default classifier.
238    fn default() -> Self {
239        Self::new()
240    }
241}
242
243impl FileBasedMediaStreamClassifier for FfprobeCommandMediaStreamClassifier {
244    /// Gets the maximum bytes staged from reader/content input.
245    fn max_staging_size(&self) -> u64 {
246        self.max_staging_size
247    }
248
249    /// Classifies a readable local media file using FFprobe.
250    fn classify_by_local_file(
251        &self,
252        file: &Path,
253    ) -> MimeResult<MediaStreamType> {
254        self.classify_with_ffprobe(file)
255    }
256}