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}