Skip to main content

qubit_mime/classifier/
ffprobe_command_media_stream_classifier.rs

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