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}