1#![doc = include_str!("../README.md")]
2
3use std::ffi::OsString;
4use std::fmt;
5use std::io::{BufRead, BufReader, Read};
6use std::path::{Path, PathBuf};
7use std::process::{Child, Command, Stdio};
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::mpsc::{self, Receiver, Sender};
10use std::sync::Arc;
11use std::thread;
12use std::time::{Duration, Instant};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ConversionMode {
16 ExtractAudio,
17 TranscodeVideo,
18 TranscodeAudio,
19}
20
21impl ConversionMode {
22 pub const ALL: [ConversionMode; 3] = [
23 ConversionMode::ExtractAudio,
24 ConversionMode::TranscodeVideo,
25 ConversionMode::TranscodeAudio,
26 ];
27
28 pub fn title(self) -> &'static str {
29 match self {
30 ConversionMode::ExtractAudio => "Video -> Audio",
31 ConversionMode::TranscodeVideo => "Video -> Video",
32 ConversionMode::TranscodeAudio => "Audio -> Audio",
33 }
34 }
35
36 pub fn description(self) -> &'static str {
37 match self {
38 ConversionMode::ExtractAudio => "Strip video and encode the audio track only.",
39 ConversionMode::TranscodeVideo => "Re-encode video into another container/codec route.",
40 ConversionMode::TranscodeAudio => "Convert one audio file into another audio format.",
41 }
42 }
43
44 pub fn source_label(self) -> &'static str {
45 match self {
46 ConversionMode::ExtractAudio | ConversionMode::TranscodeVideo => "VIDEO SOURCE",
47 ConversionMode::TranscodeAudio => "AUDIO SOURCE",
48 }
49 }
50
51 pub fn note_text(self) -> &'static str {
52 match self {
53 ConversionMode::ExtractAudio => {
54 "EXTRACT ROUTE // SOURCE VIDEO -> AUDIO CODEC -> OUTPUT CONTAINER"
55 }
56 ConversionMode::TranscodeVideo => {
57 "VIDEO ROUTE // SOURCE STREAMS -> TRANSCODE MATRIX -> OUTPUT CONTAINER"
58 }
59 ConversionMode::TranscodeAudio => {
60 "AUDIO ROUTE // SOURCE AUDIO -> CODEC CONVERSION -> OUTPUT FILE"
61 }
62 }
63 }
64
65 pub fn expects_video_input(self) -> bool {
66 matches!(
67 self,
68 ConversionMode::ExtractAudio | ConversionMode::TranscodeVideo
69 )
70 }
71
72 pub fn output_extension(self, audio: AudioFormat, video: VideoFormat) -> &'static str {
73 match self {
74 ConversionMode::ExtractAudio | ConversionMode::TranscodeAudio => audio.extension(),
75 ConversionMode::TranscodeVideo => video.extension(),
76 }
77 }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum AudioFormat {
82 Mp3,
83 M4a,
84 Flac,
85 Wav,
86 Ogg,
87 Opus,
88}
89
90impl AudioFormat {
91 pub const ALL: [AudioFormat; 6] = [
92 AudioFormat::Mp3,
93 AudioFormat::M4a,
94 AudioFormat::Flac,
95 AudioFormat::Wav,
96 AudioFormat::Ogg,
97 AudioFormat::Opus,
98 ];
99
100 pub fn label(self) -> &'static str {
101 match self {
102 AudioFormat::Mp3 => "MP3",
103 AudioFormat::M4a => "M4A/AAC",
104 AudioFormat::Flac => "FLAC",
105 AudioFormat::Wav => "WAV",
106 AudioFormat::Ogg => "OGG",
107 AudioFormat::Opus => "OPUS",
108 }
109 }
110
111 pub fn extension(self) -> &'static str {
112 match self {
113 AudioFormat::Mp3 => "mp3",
114 AudioFormat::M4a => "m4a",
115 AudioFormat::Flac => "flac",
116 AudioFormat::Wav => "wav",
117 AudioFormat::Ogg => "ogg",
118 AudioFormat::Opus => "opus",
119 }
120 }
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum VideoFormat {
125 Mp4,
126 Mkv,
127 Webm,
128 Mov,
129}
130
131impl VideoFormat {
132 pub const ALL: [VideoFormat; 4] = [
133 VideoFormat::Mp4,
134 VideoFormat::Mkv,
135 VideoFormat::Webm,
136 VideoFormat::Mov,
137 ];
138
139 pub fn label(self) -> &'static str {
140 match self {
141 VideoFormat::Mp4 => "MP4",
142 VideoFormat::Mkv => "MKV",
143 VideoFormat::Webm => "WEBM",
144 VideoFormat::Mov => "MOV",
145 }
146 }
147
148 pub fn extension(self) -> &'static str {
149 match self {
150 VideoFormat::Mp4 => "mp4",
151 VideoFormat::Mkv => "mkv",
152 VideoFormat::Webm => "webm",
153 VideoFormat::Mov => "mov",
154 }
155 }
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub enum QualityPreset {
160 Compact,
161 Balanced,
162 Archive,
163}
164
165impl QualityPreset {
166 pub const ALL: [QualityPreset; 3] = [
167 QualityPreset::Compact,
168 QualityPreset::Balanced,
169 QualityPreset::Archive,
170 ];
171
172 pub fn label(self) -> &'static str {
173 match self {
174 QualityPreset::Compact => "Compact",
175 QualityPreset::Balanced => "Balanced",
176 QualityPreset::Archive => "Archive",
177 }
178 }
179
180 pub fn description(self) -> &'static str {
181 match self {
182 QualityPreset::Compact => "smaller file",
183 QualityPreset::Balanced => "general use",
184 QualityPreset::Archive => "higher fidelity",
185 }
186 }
187
188 fn x264_crf(self) -> &'static str {
189 match self {
190 QualityPreset::Compact => "28",
191 QualityPreset::Balanced => "23",
192 QualityPreset::Archive => "18",
193 }
194 }
195
196 fn vp9_crf(self) -> &'static str {
197 match self {
198 QualityPreset::Compact => "38",
199 QualityPreset::Balanced => "32",
200 QualityPreset::Archive => "24",
201 }
202 }
203
204 fn audio_bitrate(self) -> &'static str {
205 match self {
206 QualityPreset::Compact => "128k",
207 QualityPreset::Balanced => "192k",
208 QualityPreset::Archive => "256k",
209 }
210 }
211
212 fn vorbis_quality(self) -> &'static str {
213 match self {
214 QualityPreset::Compact => "3",
215 QualityPreset::Balanced => "5",
216 QualityPreset::Archive => "8",
217 }
218 }
219}
220
221#[derive(Debug, Clone, PartialEq, Eq)]
222pub struct ConversionRequest {
223 pub mode: ConversionMode,
224 pub input_path: PathBuf,
225 pub output_path: PathBuf,
226 pub audio_format: AudioFormat,
227 pub video_format: VideoFormat,
228 pub quality: QualityPreset,
229 pub overwrite: bool,
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234#[non_exhaustive]
235pub enum ConversionRoute {
236 ExtractAudio(AudioFormat),
237 TranscodeVideo(VideoFormat),
238 TranscodeAudio(AudioFormat),
239}
240
241impl ConversionRoute {
242 pub fn mode(self) -> ConversionMode {
243 match self {
244 ConversionRoute::ExtractAudio(_) => ConversionMode::ExtractAudio,
245 ConversionRoute::TranscodeVideo(_) => ConversionMode::TranscodeVideo,
246 ConversionRoute::TranscodeAudio(_) => ConversionMode::TranscodeAudio,
247 }
248 }
249
250 pub fn audio_format(self) -> Option<AudioFormat> {
251 match self {
252 ConversionRoute::ExtractAudio(format) | ConversionRoute::TranscodeAudio(format) => {
253 Some(format)
254 }
255 ConversionRoute::TranscodeVideo(_) => None,
256 }
257 }
258
259 pub fn video_format(self) -> Option<VideoFormat> {
260 match self {
261 ConversionRoute::TranscodeVideo(format) => Some(format),
262 ConversionRoute::ExtractAudio(_) | ConversionRoute::TranscodeAudio(_) => None,
263 }
264 }
265
266 pub fn output_extension(self) -> &'static str {
267 match self {
268 ConversionRoute::ExtractAudio(format) | ConversionRoute::TranscodeAudio(format) => {
269 format.extension()
270 }
271 ConversionRoute::TranscodeVideo(format) => format.extension(),
272 }
273 }
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
278#[non_exhaustive]
279pub enum RouteInferenceError {
280 #[error("unsupported input extension for {0}")]
281 UnsupportedInput(PathBuf),
282 #[error("unsupported output extension for {0}")]
283 UnsupportedOutput(PathBuf),
284 #[error("audio input cannot be converted into a video output")]
285 AudioToVideo,
286}
287
288pub fn infer_route(input: &Path, output: &Path) -> Result<ConversionRoute, RouteInferenceError> {
290 let input_is_audio = supported_audio_extension(input);
291 let input_is_video = supported_video_extension(input);
292
293 if !input_is_audio && !input_is_video {
294 return Err(RouteInferenceError::UnsupportedInput(input.to_path_buf()));
295 }
296
297 if let Some(format) = audio_format_from_path(output) {
298 return if input_is_audio {
299 Ok(ConversionRoute::TranscodeAudio(format))
300 } else {
301 Ok(ConversionRoute::ExtractAudio(format))
302 };
303 }
304
305 if let Some(format) = video_format_from_path(output) {
306 return if input_is_audio {
307 Err(RouteInferenceError::AudioToVideo)
308 } else {
309 Ok(ConversionRoute::TranscodeVideo(format))
310 };
311 }
312
313 Err(RouteInferenceError::UnsupportedOutput(output.to_path_buf()))
314}
315
316pub fn request_from_paths(
320 input: impl AsRef<Path>,
321 output: impl AsRef<Path>,
322) -> Result<ConversionRequest, RouteInferenceError> {
323 let input = input.as_ref();
324 let output = output.as_ref();
325 let route = infer_route(input, output)?;
326 let (audio_format, video_format) = match route {
327 ConversionRoute::ExtractAudio(format) | ConversionRoute::TranscodeAudio(format) => {
328 (format, VideoFormat::Mp4)
329 }
330 ConversionRoute::TranscodeVideo(format) => (AudioFormat::Mp3, format),
331 };
332
333 Ok(ConversionRequest {
334 mode: route.mode(),
335 input_path: input.to_path_buf(),
336 output_path: output.to_path_buf(),
337 audio_format,
338 video_format,
339 quality: QualityPreset::Balanced,
340 overwrite: false,
341 })
342}
343
344#[derive(Debug, Clone, PartialEq, Eq)]
345pub struct CommandSpec {
346 pub program: String,
347 pub args: Vec<String>,
348}
349
350impl fmt::Display for CommandSpec {
351 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
352 write!(formatter, "{}", self.program)?;
353 for arg in &self.args {
354 write!(formatter, " {}", shell_quote(arg))?;
355 }
356 Ok(())
357 }
358}
359
360#[derive(Debug, thiserror::Error)]
361pub enum ConversionError {
362 #[error("select a source media file first")]
363 MissingInput,
364 #[error("source file does not exist: {0}")]
365 MissingInputFile(String),
366 #[error("source path must be a regular file: {0}")]
367 InputNotFile(String),
368 #[error("selected route expects a video source: {0}")]
369 ExpectedVideoInput(String),
370 #[error("selected route expects an audio source: {0}")]
371 ExpectedAudioInput(String),
372 #[error("choose an output file path first")]
373 MissingOutput,
374 #[error("output parent folder does not exist: {0}")]
375 MissingOutputFolder(String),
376 #[error("output cannot be the same file as the source")]
377 SameInputOutput,
378 #[error("output extension should be .{expected} for the selected route")]
379 OutputExtensionMismatch { expected: &'static str },
380 #[error("output already exists; enable overwrite or choose another path: {0}")]
381 OutputExists(String),
382}
383
384pub fn build_command(request: &ConversionRequest) -> Result<CommandSpec, ConversionError> {
385 validate_request(request)?;
386
387 let args = ffmpeg_arguments(request)
388 .into_iter()
389 .map(|arg| arg.to_string_lossy().into_owned())
390 .collect();
391
392 Ok(CommandSpec {
393 program: "ffmpeg".to_owned(),
394 args,
395 })
396}
397
398fn ffmpeg_arguments(request: &ConversionRequest) -> Vec<OsString> {
399 let mut args = [
400 "-hide_banner",
401 "-nostdin",
402 "-progress",
403 "pipe:1",
404 "-nostats",
405 if request.overwrite { "-y" } else { "-n" },
406 "-i",
407 ]
408 .into_iter()
409 .map(OsString::from)
410 .collect::<Vec<_>>();
411 args.push(request.input_path.as_os_str().to_owned());
412
413 match request.mode {
414 ConversionMode::ExtractAudio | ConversionMode::TranscodeAudio => {
415 push_args(&mut args, ["-map", "0:a:0", "-vn"]);
416 apply_audio_args(&mut args, request.audio_format, request.quality);
417 }
418 ConversionMode::TranscodeVideo => {
419 push_args(&mut args, ["-map", "0:v:0", "-map", "0:a?", "-sn"]);
420 apply_video_args(&mut args, request.video_format, request.quality);
421 }
422 }
423
424 args.push(request.output_path.as_os_str().to_owned());
425 args
426}
427
428pub fn validate_request(request: &ConversionRequest) -> Result<(), ConversionError> {
429 if request.input_path.as_os_str().is_empty() {
430 return Err(ConversionError::MissingInput);
431 }
432
433 let input_display = request.input_path.display().to_string();
434 let metadata = std::fs::metadata(&request.input_path)
435 .map_err(|_| ConversionError::MissingInputFile(input_display.clone()))?;
436 if !metadata.is_file() {
437 return Err(ConversionError::InputNotFile(input_display));
438 }
439
440 if request.mode.expects_video_input() && !supported_video_extension(&request.input_path) {
441 return Err(ConversionError::ExpectedVideoInput(
442 request.input_path.display().to_string(),
443 ));
444 }
445 if matches!(request.mode, ConversionMode::TranscodeAudio)
446 && !supported_audio_extension(&request.input_path)
447 {
448 return Err(ConversionError::ExpectedAudioInput(
449 request.input_path.display().to_string(),
450 ));
451 }
452
453 if request.output_path.as_os_str().is_empty() {
454 return Err(ConversionError::MissingOutput);
455 }
456
457 if request.input_path == request.output_path
458 || canonical_match(&request.input_path, &request.output_path)
459 {
460 return Err(ConversionError::SameInputOutput);
461 }
462
463 if let Some(parent) = request.output_path.parent() {
464 if !parent.as_os_str().is_empty() && !parent.is_dir() {
465 return Err(ConversionError::MissingOutputFolder(
466 parent.display().to_string(),
467 ));
468 }
469 }
470
471 let expected = request
472 .mode
473 .output_extension(request.audio_format, request.video_format);
474 if path_extension_lower(&request.output_path).as_deref() != Some(expected) {
475 return Err(ConversionError::OutputExtensionMismatch { expected });
476 }
477
478 if request.output_path.exists() && !request.overwrite {
479 return Err(ConversionError::OutputExists(
480 request.output_path.display().to_string(),
481 ));
482 }
483
484 Ok(())
485}
486
487fn canonical_match(left: &Path, right: &Path) -> bool {
488 match (std::fs::canonicalize(left), std::fs::canonicalize(right)) {
489 (Ok(left), Ok(right)) => left == right,
490 _ => false,
491 }
492}
493
494fn apply_audio_args(args: &mut Vec<OsString>, format: AudioFormat, quality: QualityPreset) {
495 match format {
496 AudioFormat::Mp3 => {
497 push_args(
498 args,
499 ["-c:a", "libmp3lame", "-b:a", quality.audio_bitrate()],
500 );
501 }
502 AudioFormat::M4a => {
503 push_args(args, ["-c:a", "aac", "-b:a", quality.audio_bitrate()]);
504 push_args(args, ["-movflags", "+faststart"]);
505 }
506 AudioFormat::Flac => push_args(args, ["-c:a", "flac"]),
507 AudioFormat::Wav => push_args(args, ["-c:a", "pcm_s16le"]),
508 AudioFormat::Ogg => {
509 push_args(
510 args,
511 ["-c:a", "libvorbis", "-q:a", quality.vorbis_quality()],
512 );
513 }
514 AudioFormat::Opus => {
515 push_args(args, ["-c:a", "libopus", "-b:a", quality.audio_bitrate()]);
516 }
517 }
518}
519
520fn apply_video_args(args: &mut Vec<OsString>, format: VideoFormat, quality: QualityPreset) {
521 match format {
522 VideoFormat::Mp4 | VideoFormat::Mkv | VideoFormat::Mov => {
523 push_args(
524 args,
525 [
526 "-c:v",
527 "libx264",
528 "-preset",
529 "medium",
530 "-crf",
531 quality.x264_crf(),
532 "-pix_fmt",
533 "yuv420p",
534 "-c:a",
535 "aac",
536 "-b:a",
537 quality.audio_bitrate(),
538 ],
539 );
540 if matches!(format, VideoFormat::Mp4 | VideoFormat::Mov) {
541 push_args(args, ["-movflags", "+faststart"]);
542 }
543 }
544 VideoFormat::Webm => {
545 push_args(
546 args,
547 [
548 "-c:v",
549 "libvpx-vp9",
550 "-b:v",
551 "0",
552 "-crf",
553 quality.vp9_crf(),
554 "-c:a",
555 "libopus",
556 "-b:a",
557 quality.audio_bitrate(),
558 ],
559 );
560 }
561 }
562}
563
564fn push_args<const N: usize>(args: &mut Vec<OsString>, values: [&str; N]) {
565 args.extend(values.into_iter().map(OsString::from));
566}
567
568pub fn probe_duration(path: &Path) -> Option<f64> {
569 let output = Command::new("ffprobe")
570 .args([
571 "-v",
572 "error",
573 "-show_entries",
574 "format=duration",
575 "-of",
576 "default=noprint_wrappers=1:nokey=1",
577 ])
578 .arg(path)
579 .output()
580 .ok()?;
581
582 if !output.status.success() {
583 return None;
584 }
585
586 let text = String::from_utf8_lossy(&output.stdout);
587 text.lines()
588 .find_map(|line| line.trim().parse::<f64>().ok())
589 .filter(|duration| *duration > 0.0 && duration.is_finite())
590}
591
592#[derive(Debug, Clone, PartialEq)]
593pub enum OperationEvent {
594 Started(String),
595 Log(String),
596 Progress {
597 current_seconds: f64,
598 total_seconds: f64,
599 },
600 Finished(Result<(), String>),
601}
602
603pub fn start_operation(
604 command: CommandSpec,
605 expected_duration: Option<f64>,
606) -> Receiver<OperationEvent> {
607 let (tx, rx) = mpsc::channel();
608 thread::spawn(move || run_operation(command, expected_duration, tx));
609 rx
610}
611
612pub fn start_conversion(
617 request: &ConversionRequest,
618) -> Result<Receiver<OperationEvent>, ConversionError> {
619 let command = build_command(request)?;
620 let expected_duration = probe_duration(&request.input_path);
621 Ok(start_operation(command, expected_duration))
622}
623
624const DEFAULT_TOOL_CHECK_TIMEOUT: Duration = Duration::from_secs(2);
625const DEFAULT_PROBE_TIMEOUT: Duration = Duration::from_secs(10);
626const PROCESS_POLL_INTERVAL: Duration = Duration::from_millis(20);
627
628#[derive(Debug, Clone, PartialEq, Eq)]
630pub struct Converter {
631 ffmpeg_path: PathBuf,
632 ffprobe_path: PathBuf,
633 tool_check_timeout: Duration,
634 probe_timeout: Duration,
635}
636
637impl Default for Converter {
638 fn default() -> Self {
639 Self {
640 ffmpeg_path: PathBuf::from("ffmpeg"),
641 ffprobe_path: PathBuf::from("ffprobe"),
642 tool_check_timeout: DEFAULT_TOOL_CHECK_TIMEOUT,
643 probe_timeout: DEFAULT_PROBE_TIMEOUT,
644 }
645 }
646}
647
648impl Converter {
649 pub fn new() -> Self {
651 Self::default()
652 }
653
654 pub fn with_programs(
656 ffmpeg_path: impl Into<PathBuf>,
657 ffprobe_path: impl Into<PathBuf>,
658 ) -> Self {
659 Self {
660 ffmpeg_path: ffmpeg_path.into(),
661 ffprobe_path: ffprobe_path.into(),
662 ..Self::default()
663 }
664 }
665
666 pub fn with_tool_check_timeout(mut self, timeout: Duration) -> Self {
668 self.tool_check_timeout = timeout;
669 self
670 }
671
672 pub fn with_probe_timeout(mut self, timeout: Duration) -> Self {
674 self.probe_timeout = timeout;
675 self
676 }
677
678 pub fn ffmpeg_path(&self) -> &Path {
679 &self.ffmpeg_path
680 }
681
682 pub fn ffprobe_path(&self) -> &Path {
683 &self.ffprobe_path
684 }
685
686 pub fn check_tools(&self) -> ToolAvailability {
691 ToolAvailability {
692 ffmpeg: check_tool(&self.ffmpeg_path, self.tool_check_timeout),
693 ffprobe: check_tool(&self.ffprobe_path, self.tool_check_timeout),
694 }
695 }
696
697 pub fn spawn(&self, request: ConversionRequest) -> ConversionJob {
701 let (tx, events) = mpsc::channel();
702 let cancellation = Arc::new(AtomicBool::new(false));
703 let worker_cancellation = Arc::clone(&cancellation);
704 let converter = self.clone();
705 let input_path = request.input_path.clone();
706 let output_path = request.output_path.clone();
707 thread::spawn(move || {
708 run_conversion_job(converter, request, worker_cancellation, tx);
709 });
710
711 ConversionJob {
712 events,
713 cancellation,
714 input_path,
715 output_path,
716 }
717 }
718}
719
720#[derive(Debug, Clone, PartialEq, Eq)]
722#[non_exhaustive]
723pub enum ToolStatus {
724 Available,
725 Unavailable(String),
726 TimedOut,
727}
728
729impl ToolStatus {
730 pub fn is_available(&self) -> bool {
731 matches!(self, ToolStatus::Available)
732 }
733}
734
735impl fmt::Display for ToolStatus {
736 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
737 match self {
738 ToolStatus::Available => formatter.write_str("available"),
739 ToolStatus::Unavailable(error) => write!(formatter, "unavailable: {error}"),
740 ToolStatus::TimedOut => formatter.write_str("timed out"),
741 }
742 }
743}
744
745#[derive(Debug, Clone, PartialEq, Eq)]
747pub struct ToolAvailability {
748 ffmpeg: ToolStatus,
749 ffprobe: ToolStatus,
750}
751
752impl ToolAvailability {
753 pub fn ffmpeg(&self) -> &ToolStatus {
754 &self.ffmpeg
755 }
756
757 pub fn ffprobe(&self) -> &ToolStatus {
758 &self.ffprobe
759 }
760
761 pub fn conversion_ready(&self) -> bool {
763 self.ffmpeg.is_available()
764 }
765
766 pub fn duration_progress_available(&self) -> bool {
767 self.ffprobe.is_available()
768 }
769}
770
771pub fn check_tools() -> ToolAvailability {
773 Converter::new().check_tools()
774}
775
776fn check_tool(program: &Path, timeout: Duration) -> ToolStatus {
777 let mut command = Command::new(program);
778 command
779 .arg("-version")
780 .stdin(Stdio::null())
781 .stdout(Stdio::null())
782 .stderr(Stdio::null());
783 configure_process_group(&mut command);
784 let mut child = match command.spawn() {
785 Ok(child) => child,
786 Err(error) => return ToolStatus::Unavailable(error.to_string()),
787 };
788 let started = Instant::now();
789
790 loop {
791 match child.try_wait() {
792 Ok(Some(status)) if status.success() => return ToolStatus::Available,
793 Ok(Some(status)) => return ToolStatus::Unavailable(format!("exited with {status}")),
794 Ok(None) if started.elapsed() >= timeout => {
795 terminate_child(&mut child);
796 return ToolStatus::TimedOut;
797 }
798 Ok(None) => thread::sleep(PROCESS_POLL_INTERVAL),
799 Err(error) => {
800 terminate_child(&mut child);
801 return ToolStatus::Unavailable(error.to_string());
802 }
803 }
804 }
805}
806
807fn configure_process_group(command: &mut Command) {
808 #[cfg(unix)]
809 {
810 use std::os::unix::process::CommandExt;
811
812 command.process_group(0);
813 }
814}
815
816fn terminate_child(child: &mut Child) {
817 #[cfg(unix)]
818 if let Ok(process_group) = i32::try_from(child.id()) {
819 unsafe {
821 libc::kill(-process_group, libc::SIGKILL);
822 }
823 }
824
825 let _ = child.kill();
826 let _ = child.wait();
827}
828
829#[derive(Debug, Clone, PartialEq, Eq)]
831#[non_exhaustive]
832pub enum ConversionOutcome {
833 Succeeded,
834 Cancelled,
835 Failed(String),
836}
837
838impl ConversionOutcome {
839 pub fn is_success(&self) -> bool {
840 matches!(self, ConversionOutcome::Succeeded)
841 }
842
843 pub fn is_cancelled(&self) -> bool {
844 matches!(self, ConversionOutcome::Cancelled)
845 }
846}
847
848#[derive(Debug, Clone, PartialEq)]
850#[non_exhaustive]
851pub enum ConversionEvent {
852 Started(String),
853 Log(String),
854 #[non_exhaustive]
855 Progress {
856 current_seconds: f64,
857 total_seconds: Option<f64>,
858 },
859 Finished(ConversionOutcome),
860}
861
862#[must_use = "dropping a conversion job requests cancellation"]
864pub struct ConversionJob {
865 events: Receiver<ConversionEvent>,
866 cancellation: Arc<AtomicBool>,
867 input_path: PathBuf,
868 output_path: PathBuf,
869}
870
871impl ConversionJob {
872 pub fn input_path(&self) -> &Path {
873 &self.input_path
874 }
875
876 pub fn output_path(&self) -> &Path {
877 &self.output_path
878 }
879
880 pub fn try_recv(&self) -> Result<ConversionEvent, mpsc::TryRecvError> {
881 self.events.try_recv()
882 }
883
884 pub fn recv(&self) -> Result<ConversionEvent, mpsc::RecvError> {
885 self.events.recv()
886 }
887
888 pub fn recv_timeout(
889 &self,
890 timeout: Duration,
891 ) -> Result<ConversionEvent, mpsc::RecvTimeoutError> {
892 self.events.recv_timeout(timeout)
893 }
894
895 pub fn cancel(&self) {
897 self.cancellation.store(true, Ordering::Release);
898 }
899
900 pub fn cancellation_requested(&self) -> bool {
901 self.cancellation.load(Ordering::Acquire)
902 }
903}
904
905impl Drop for ConversionJob {
906 fn drop(&mut self) {
907 self.cancel();
908 }
909}
910
911pub fn spawn_conversion(request: ConversionRequest) -> ConversionJob {
913 Converter::new().spawn(request)
914}
915
916fn run_conversion_job(
917 converter: Converter,
918 request: ConversionRequest,
919 cancellation: Arc<AtomicBool>,
920 tx: Sender<ConversionEvent>,
921) {
922 if cancellation.load(Ordering::Acquire) {
923 let _ = tx.send(ConversionEvent::Finished(ConversionOutcome::Cancelled));
924 return;
925 }
926
927 if let Err(error) = validate_request(&request) {
928 let outcome = if cancellation.load(Ordering::Acquire) {
929 ConversionOutcome::Cancelled
930 } else {
931 ConversionOutcome::Failed(error.to_string())
932 };
933 let _ = tx.send(ConversionEvent::Finished(outcome));
934 return;
935 }
936
937 let expected_duration = match probe_duration_for_job(
938 &converter.ffprobe_path,
939 &request.input_path,
940 converter.probe_timeout,
941 &cancellation,
942 ) {
943 ProbeOutcome::Duration(duration) => duration,
944 ProbeOutcome::Cancelled => {
945 let _ = tx.send(ConversionEvent::Finished(ConversionOutcome::Cancelled));
946 return;
947 }
948 };
949
950 if cancellation.load(Ordering::Acquire) {
951 let _ = tx.send(ConversionEvent::Finished(ConversionOutcome::Cancelled));
952 return;
953 }
954
955 run_job_operation(
956 &converter.ffmpeg_path,
957 ffmpeg_arguments(&request),
958 expected_duration,
959 cancellation,
960 tx,
961 );
962}
963
964enum ProbeOutcome {
965 Duration(Option<f64>),
966 Cancelled,
967}
968
969fn probe_duration_for_job(
970 program: &Path,
971 path: &Path,
972 timeout: Duration,
973 cancellation: &AtomicBool,
974) -> ProbeOutcome {
975 if cancellation.load(Ordering::Acquire) {
976 return ProbeOutcome::Cancelled;
977 }
978
979 let mut command = Command::new(program);
980 command
981 .args([
982 "-v",
983 "error",
984 "-show_entries",
985 "format=duration",
986 "-of",
987 "default=noprint_wrappers=1:nokey=1",
988 ])
989 .arg(path)
990 .stdin(Stdio::null())
991 .stdout(Stdio::piped())
992 .stderr(Stdio::null());
993 configure_process_group(&mut command);
994 let mut child = match command.spawn() {
995 Ok(child) => child,
996 Err(_) => return ProbeOutcome::Duration(None),
997 };
998 let started = Instant::now();
999
1000 let status = loop {
1001 match child.try_wait() {
1002 Ok(Some(status)) => break Some(status),
1003 Ok(None) if cancellation.load(Ordering::Acquire) => {
1004 terminate_child(&mut child);
1005 return ProbeOutcome::Cancelled;
1006 }
1007 Ok(None) if started.elapsed() >= timeout => {
1008 terminate_child(&mut child);
1009 break None;
1010 }
1011 Ok(None) => thread::sleep(PROCESS_POLL_INTERVAL),
1012 Err(_) => {
1013 terminate_child(&mut child);
1014 break None;
1015 }
1016 }
1017 };
1018
1019 if !matches!(status, Some(status) if status.success()) {
1020 return ProbeOutcome::Duration(None);
1021 }
1022 let mut stdout = String::new();
1023 let duration = child
1024 .stdout
1025 .take()
1026 .and_then(|mut stream| stream.read_to_string(&mut stdout).ok())
1027 .and_then(|_| {
1028 stdout
1029 .lines()
1030 .find_map(|line| line.trim().parse::<f64>().ok())
1031 })
1032 .filter(|duration| *duration > 0.0 && duration.is_finite());
1033 ProbeOutcome::Duration(duration)
1034}
1035
1036fn run_job_operation(
1037 program: &Path,
1038 args: Vec<OsString>,
1039 expected_duration: Option<f64>,
1040 cancellation: Arc<AtomicBool>,
1041 tx: Sender<ConversionEvent>,
1042) {
1043 if cancellation.load(Ordering::Acquire) {
1044 let _ = tx.send(ConversionEvent::Finished(ConversionOutcome::Cancelled));
1045 return;
1046 }
1047
1048 let mut command = Command::new(program);
1049 command
1050 .args(&args)
1051 .stdin(Stdio::null())
1052 .stdout(Stdio::piped())
1053 .stderr(Stdio::piped());
1054 configure_process_group(&mut command);
1055 let mut child = match command.spawn() {
1056 Ok(child) => child,
1057 Err(error) => {
1058 let _ = tx.send(ConversionEvent::Finished(ConversionOutcome::Failed(
1059 format!("failed to start ffmpeg: {error}"),
1060 )));
1061 return;
1062 }
1063 };
1064 if cancellation.load(Ordering::Acquire) {
1065 terminate_child(&mut child);
1066 let _ = tx.send(ConversionEvent::Finished(ConversionOutcome::Cancelled));
1067 return;
1068 }
1069 let _ = tx.send(ConversionEvent::Started(display_os_command(program, &args)));
1070
1071 let stdout = child.stdout.take();
1072 let stderr = child.stderr.take();
1073 let stdout_thread =
1074 stdout.map(|stream| forward_conversion_stream(stream, tx.clone(), expected_duration));
1075 let stderr_thread =
1076 stderr.map(|stream| forward_conversion_stream(stream, tx.clone(), expected_duration));
1077
1078 let outcome = loop {
1079 match child.try_wait() {
1080 Ok(Some(status)) if status.success() => break ConversionOutcome::Succeeded,
1081 Ok(Some(status)) => {
1082 break ConversionOutcome::Failed(format!("ffmpeg exited with status {status}"));
1083 }
1084 Ok(None) if cancellation.load(Ordering::Acquire) => {
1085 terminate_child(&mut child);
1086 break ConversionOutcome::Cancelled;
1087 }
1088 Ok(None) => thread::sleep(PROCESS_POLL_INTERVAL),
1089 Err(error) => {
1090 terminate_child(&mut child);
1091 break ConversionOutcome::Failed(format!("failed to wait for ffmpeg: {error}"));
1092 }
1093 }
1094 };
1095
1096 if let Some(thread) = stdout_thread {
1097 let _ = thread.join();
1098 }
1099 if let Some(thread) = stderr_thread {
1100 let _ = thread.join();
1101 }
1102
1103 let _ = tx.send(ConversionEvent::Finished(outcome));
1104}
1105
1106fn display_os_command(program: &Path, args: &[OsString]) -> String {
1107 let command = CommandSpec {
1108 program: program.display().to_string(),
1109 args: args
1110 .iter()
1111 .map(|arg| arg.to_string_lossy().into_owned())
1112 .collect(),
1113 };
1114 command.to_string()
1115}
1116
1117fn forward_conversion_stream<R>(
1118 stream: R,
1119 tx: Sender<ConversionEvent>,
1120 expected_duration: Option<f64>,
1121) -> thread::JoinHandle<()>
1122where
1123 R: Read + Send + 'static,
1124{
1125 thread::spawn(move || {
1126 let reader = BufReader::new(stream);
1127 for line in reader.lines() {
1128 let line = match line {
1129 Ok(line) => line.trim().to_owned(),
1130 Err(error) => {
1131 let _ = tx.send(ConversionEvent::Log(format!("stream read failed: {error}")));
1132 break;
1133 }
1134 };
1135
1136 if line.is_empty() {
1137 continue;
1138 }
1139
1140 if let Some(current_seconds) = parse_ffmpeg_progress_seconds(&line) {
1141 let current_seconds = expected_duration
1142 .map(|total| current_seconds.min(total))
1143 .unwrap_or(current_seconds);
1144 let _ = tx.send(ConversionEvent::Progress {
1145 current_seconds,
1146 total_seconds: expected_duration,
1147 });
1148 continue;
1149 }
1150
1151 if is_ffmpeg_progress_metadata(&line) {
1152 continue;
1153 }
1154
1155 let _ = tx.send(ConversionEvent::Log(line));
1156 }
1157 })
1158}
1159
1160fn run_operation(command: CommandSpec, expected_duration: Option<f64>, tx: Sender<OperationEvent>) {
1161 let _ = tx.send(OperationEvent::Started(command.to_string()));
1162
1163 let mut child = match Command::new(&command.program)
1164 .args(&command.args)
1165 .stdout(Stdio::piped())
1166 .stderr(Stdio::piped())
1167 .spawn()
1168 {
1169 Ok(child) => child,
1170 Err(error) => {
1171 let _ = tx.send(OperationEvent::Finished(Err(format!(
1172 "failed to start ffmpeg: {error}"
1173 ))));
1174 return;
1175 }
1176 };
1177
1178 let stdout = child.stdout.take();
1179 let stderr = child.stderr.take();
1180 let stdout_thread = stdout.map(|stream| forward_stream(stream, tx.clone(), expected_duration));
1181 let stderr_thread = stderr.map(|stream| forward_stream(stream, tx.clone(), expected_duration));
1182
1183 let result = match child.wait() {
1184 Ok(status) if status.success() => Ok(()),
1185 Ok(status) => Err(format!("ffmpeg exited with status {status}")),
1186 Err(error) => Err(format!("failed to wait for ffmpeg: {error}")),
1187 };
1188
1189 if let Some(thread) = stdout_thread {
1190 let _ = thread.join();
1191 }
1192 if let Some(thread) = stderr_thread {
1193 let _ = thread.join();
1194 }
1195
1196 let _ = tx.send(OperationEvent::Finished(result));
1197}
1198
1199fn forward_stream<R>(
1200 stream: R,
1201 tx: Sender<OperationEvent>,
1202 expected_duration: Option<f64>,
1203) -> thread::JoinHandle<()>
1204where
1205 R: Read + Send + 'static,
1206{
1207 thread::spawn(move || {
1208 let reader = BufReader::new(stream);
1209 for line in reader.lines() {
1210 let line = match line {
1211 Ok(line) => line.trim().to_owned(),
1212 Err(error) => {
1213 let _ = tx.send(OperationEvent::Log(format!("stream read failed: {error}")));
1214 break;
1215 }
1216 };
1217
1218 if line.is_empty() {
1219 continue;
1220 }
1221
1222 if let Some(current_seconds) = parse_ffmpeg_progress_seconds(&line) {
1223 if let Some(total_seconds) = expected_duration {
1224 let _ = tx.send(OperationEvent::Progress {
1225 current_seconds: current_seconds.min(total_seconds),
1226 total_seconds,
1227 });
1228 }
1229 continue;
1230 }
1231
1232 if is_ffmpeg_progress_metadata(&line) {
1233 continue;
1234 }
1235
1236 let _ = tx.send(OperationEvent::Log(line));
1237 }
1238 })
1239}
1240
1241pub fn parse_ffmpeg_progress_seconds(line: &str) -> Option<f64> {
1242 let (key, value) = line.trim().split_once('=')?;
1243 match key {
1244 "out_time_us" | "out_time_ms" => value
1245 .trim()
1246 .parse::<f64>()
1247 .ok()
1248 .map(|microseconds| microseconds / 1_000_000.0),
1249 "out_time" => parse_ffmpeg_timestamp(value.trim()),
1250 _ => None,
1251 }
1252}
1253
1254fn parse_ffmpeg_timestamp(value: &str) -> Option<f64> {
1255 let mut parts = value.split(':');
1256 let hours = parts.next()?.parse::<f64>().ok()?;
1257 let minutes = parts.next()?.parse::<f64>().ok()?;
1258 let seconds = parts.next()?.parse::<f64>().ok()?;
1259 if parts.next().is_some() {
1260 return None;
1261 }
1262 Some(hours * 3600.0 + minutes * 60.0 + seconds)
1263}
1264
1265fn is_ffmpeg_progress_metadata(line: &str) -> bool {
1266 let Some((key, _)) = line.split_once('=') else {
1267 return false;
1268 };
1269
1270 matches!(
1271 key,
1272 "bitrate"
1273 | "drop_frames"
1274 | "dup_frames"
1275 | "fps"
1276 | "frame"
1277 | "progress"
1278 | "speed"
1279 | "stream_0_0_q"
1280 | "total_size"
1281 )
1282}
1283
1284pub fn duration_progress_label(current_seconds: f64, total_seconds: f64) -> String {
1285 let percent = if total_seconds <= 0.0 {
1286 0.0
1287 } else {
1288 current_seconds / total_seconds * 100.0
1289 }
1290 .clamp(0.0, 100.0);
1291
1292 format!(
1293 "{} / {} ({percent:.1}%)",
1294 format_duration(current_seconds),
1295 format_duration(total_seconds)
1296 )
1297}
1298
1299pub fn format_duration(seconds: f64) -> String {
1300 let seconds = seconds.max(0.0).round() as u64;
1301 let hours = seconds / 3600;
1302 let minutes = (seconds % 3600) / 60;
1303 let seconds = seconds % 60;
1304
1305 if hours > 0 {
1306 format!("{hours}:{minutes:02}:{seconds:02}")
1307 } else {
1308 format!("{minutes}:{seconds:02}")
1309 }
1310}
1311
1312pub fn suggest_output_path(
1313 input_path: &Path,
1314 mode: ConversionMode,
1315 audio_format: AudioFormat,
1316 video_format: VideoFormat,
1317) -> Option<PathBuf> {
1318 if input_path.as_os_str().is_empty() {
1319 return None;
1320 }
1321
1322 let mut filename = input_path.file_stem()?.to_os_string();
1323 let extension = mode.output_extension(audio_format, video_format);
1324 filename.push("-caery.");
1325 filename.push(extension);
1326 let mut output = input_path.parent().map(PathBuf::from).unwrap_or_default();
1327 output.push(filename);
1328 Some(output)
1329}
1330
1331pub fn supported_media_extension(path: &Path) -> bool {
1332 supported_audio_extension(path) || supported_video_extension(path)
1333}
1334
1335pub fn audio_format_from_path(path: &Path) -> Option<AudioFormat> {
1337 match path_extension_lower(path).as_deref()? {
1338 "mp3" => Some(AudioFormat::Mp3),
1339 "m4a" => Some(AudioFormat::M4a),
1340 "flac" => Some(AudioFormat::Flac),
1341 "wav" => Some(AudioFormat::Wav),
1342 "ogg" => Some(AudioFormat::Ogg),
1343 "opus" => Some(AudioFormat::Opus),
1344 _ => None,
1345 }
1346}
1347
1348pub fn video_format_from_path(path: &Path) -> Option<VideoFormat> {
1350 match path_extension_lower(path).as_deref()? {
1351 "mp4" => Some(VideoFormat::Mp4),
1352 "mkv" => Some(VideoFormat::Mkv),
1353 "webm" => Some(VideoFormat::Webm),
1354 "mov" => Some(VideoFormat::Mov),
1355 _ => None,
1356 }
1357}
1358
1359pub fn supported_audio_extension(path: &Path) -> bool {
1360 matches!(
1361 path_extension_lower(path).as_deref(),
1362 Some(
1363 "aac"
1364 | "aif"
1365 | "aiff"
1366 | "alac"
1367 | "flac"
1368 | "m4a"
1369 | "mp3"
1370 | "oga"
1371 | "ogg"
1372 | "opus"
1373 | "wav"
1374 | "wma"
1375 )
1376 )
1377}
1378
1379pub fn supported_video_extension(path: &Path) -> bool {
1380 matches!(
1381 path_extension_lower(path).as_deref(),
1382 Some(
1383 "3gp"
1384 | "avi"
1385 | "flv"
1386 | "m2ts"
1387 | "m4v"
1388 | "mkv"
1389 | "mov"
1390 | "mp4"
1391 | "mpeg"
1392 | "mpg"
1393 | "ogv"
1394 | "ts"
1395 | "webm"
1396 | "wmv"
1397 )
1398 )
1399}
1400
1401pub fn media_kind_label(path: &Path) -> &'static str {
1402 if supported_video_extension(path) {
1403 "VIDEO"
1404 } else if supported_audio_extension(path) {
1405 "AUDIO"
1406 } else {
1407 "FILE"
1408 }
1409}
1410
1411pub fn path_extension_lower(path: &Path) -> Option<String> {
1412 path.extension()
1413 .and_then(|extension| extension.to_str())
1414 .map(|extension| extension.to_ascii_lowercase())
1415}
1416
1417pub fn human_size(bytes: u64) -> String {
1418 const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
1419 let mut value = bytes as f64;
1420 let mut unit = 0;
1421
1422 while value >= 1024.0 && unit < UNITS.len() - 1 {
1423 value /= 1024.0;
1424 unit += 1;
1425 }
1426
1427 if unit == 0 {
1428 format!("{} {}", bytes, UNITS[unit])
1429 } else {
1430 format!("{value:.1} {}", UNITS[unit])
1431 }
1432}
1433
1434pub fn shell_quote(value: &str) -> String {
1435 if value.is_empty() {
1436 return "''".to_owned();
1437 }
1438
1439 format!("'{}'", value.replace('\'', "'\"'\"'"))
1440}
1441
1442#[cfg(test)]
1443mod tests {
1444 use super::*;
1445 use std::fs;
1446 use std::io::Write;
1447 use tempfile::{tempdir, NamedTempFile};
1448
1449 fn request(input: &Path, output: &Path, mode: ConversionMode) -> ConversionRequest {
1450 ConversionRequest {
1451 mode,
1452 input_path: input.to_path_buf(),
1453 output_path: output.to_path_buf(),
1454 audio_format: AudioFormat::Mp3,
1455 video_format: VideoFormat::Mp4,
1456 quality: QualityPreset::Balanced,
1457 overwrite: true,
1458 }
1459 }
1460
1461 #[cfg(unix)]
1462 fn executable_script(directory: &Path, name: &str, body: &str) -> PathBuf {
1463 use std::os::unix::fs::PermissionsExt;
1464
1465 let path = directory.join(name);
1466 fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("write script");
1467 let mut permissions = fs::metadata(&path).expect("script metadata").permissions();
1468 permissions.set_mode(0o755);
1469 fs::set_permissions(&path, permissions).expect("make script executable");
1470 path
1471 }
1472
1473 fn terminal_outcome(job: &ConversionJob) -> ConversionOutcome {
1474 loop {
1475 let event = job
1476 .recv_timeout(Duration::from_secs(2))
1477 .expect("conversion event");
1478 if let ConversionEvent::Finished(outcome) = event {
1479 return outcome;
1480 }
1481 }
1482 }
1483
1484 #[test]
1485 fn infers_routes_without_accessing_the_filesystem() {
1486 assert_eq!(
1487 infer_route(Path::new("song.WAV"), Path::new("song.flac")),
1488 Ok(ConversionRoute::TranscodeAudio(AudioFormat::Flac))
1489 );
1490 assert_eq!(
1491 infer_route(Path::new("clip.mp4"), Path::new("clip.opus")),
1492 Ok(ConversionRoute::ExtractAudio(AudioFormat::Opus))
1493 );
1494 assert_eq!(
1495 infer_route(Path::new("clip.mkv"), Path::new("clip.WEBM")),
1496 Ok(ConversionRoute::TranscodeVideo(VideoFormat::Webm))
1497 );
1498 }
1499
1500 #[test]
1501 fn infers_every_selectable_output_format() {
1502 for format in AudioFormat::ALL {
1503 let output = PathBuf::from(format!("output.{}", format.extension()));
1504 assert_eq!(
1505 infer_route(Path::new("song.wav"), &output),
1506 Ok(ConversionRoute::TranscodeAudio(format))
1507 );
1508 assert_eq!(
1509 infer_route(Path::new("clip.mp4"), &output),
1510 Ok(ConversionRoute::ExtractAudio(format))
1511 );
1512 }
1513
1514 for format in VideoFormat::ALL {
1515 let output = PathBuf::from(format!("output.{}", format.extension()));
1516 assert_eq!(
1517 infer_route(Path::new("clip.mp4"), &output),
1518 Ok(ConversionRoute::TranscodeVideo(format))
1519 );
1520 }
1521 }
1522
1523 #[test]
1524 fn route_inference_rejects_unsupported_pairs_and_extensions() {
1525 assert_eq!(
1526 infer_route(Path::new("song.wav"), Path::new("song.mp4")),
1527 Err(RouteInferenceError::AudioToVideo)
1528 );
1529 assert!(matches!(
1530 infer_route(Path::new("notes.txt"), Path::new("notes.mp3")),
1531 Err(RouteInferenceError::UnsupportedInput(_))
1532 ));
1533 assert!(matches!(
1534 infer_route(Path::new("song.wav"), Path::new("song.aac")),
1535 Err(RouteInferenceError::UnsupportedOutput(_))
1536 ));
1537 }
1538
1539 #[test]
1540 fn inferred_request_uses_embedding_defaults() {
1541 let request = request_from_paths("clip.mp4", "clip.flac").expect("infer request");
1542
1543 assert_eq!(request.mode, ConversionMode::ExtractAudio);
1544 assert_eq!(request.audio_format, AudioFormat::Flac);
1545 assert_eq!(request.video_format, VideoFormat::Mp4);
1546 assert_eq!(request.quality, QualityPreset::Balanced);
1547 assert!(!request.overwrite);
1548 }
1549
1550 #[cfg(unix)]
1551 #[test]
1552 fn tool_checks_report_available_missing_and_timed_out_programs() {
1553 let directory = tempdir().expect("temp dir");
1554 let available = executable_script(directory.path(), "available", "exit 0");
1555 let slow = executable_script(directory.path(), "slow", "sleep 5");
1556 let missing = directory.path().join("missing");
1557
1558 let available_tools = Converter::with_programs(&available, &available).check_tools();
1559 assert!(available_tools.conversion_ready());
1560 assert!(available_tools.duration_progress_available());
1561
1562 let unavailable_tools = Converter::with_programs(&missing, &slow)
1563 .with_tool_check_timeout(Duration::from_millis(100))
1564 .check_tools();
1565 assert!(matches!(
1566 unavailable_tools.ffmpeg(),
1567 ToolStatus::Unavailable(_)
1568 ));
1569 assert_eq!(unavailable_tools.ffprobe(), &ToolStatus::TimedOut);
1570 }
1571
1572 #[cfg(unix)]
1573 #[test]
1574 fn spawned_conversion_returns_immediately_and_cancels_during_probe() {
1575 let directory = tempdir().expect("temp dir");
1576 let input = directory.path().join("clip.mp4");
1577 let output = directory.path().join("clip.mp3");
1578 fs::write(&input, "fake video").expect("write input");
1579 let ffmpeg = executable_script(directory.path(), "ffmpeg", "exit 0");
1580 let ffprobe = executable_script(directory.path(), "ffprobe", "sleep 5");
1581 let request = request(&input, &output, ConversionMode::ExtractAudio);
1582 let converter =
1583 Converter::with_programs(ffmpeg, ffprobe).with_probe_timeout(Duration::from_secs(5));
1584
1585 let started = Instant::now();
1586 let job = converter.spawn(request);
1587 assert!(started.elapsed() < Duration::from_millis(250));
1588 assert_eq!(job.input_path(), input);
1589 assert_eq!(job.output_path(), output);
1590 thread::sleep(Duration::from_millis(80));
1591 job.cancel();
1592
1593 assert_eq!(terminal_outcome(&job), ConversionOutcome::Cancelled);
1594 }
1595
1596 #[cfg(unix)]
1597 #[test]
1598 fn spawned_conversion_cancels_running_ffmpeg() {
1599 let directory = tempdir().expect("temp dir");
1600 let input = directory.path().join("clip.mp4");
1601 let output = directory.path().join("clip.mp3");
1602 fs::write(&input, "fake video").expect("write input");
1603 let ffprobe = executable_script(directory.path(), "ffprobe", "printf '10.0\\n'");
1604 let ffmpeg = executable_script(
1605 directory.path(),
1606 "ffmpeg",
1607 "printf 'out_time_us=1000000\\n'\nsleep 5",
1608 );
1609 let request = request(&input, &output, ConversionMode::ExtractAudio);
1610 let job = Converter::with_programs(ffmpeg, ffprobe).spawn(request);
1611
1612 loop {
1613 if matches!(
1614 job.recv_timeout(Duration::from_secs(2))
1615 .expect("conversion event"),
1616 ConversionEvent::Started(_)
1617 ) {
1618 break;
1619 }
1620 }
1621 job.cancel();
1622
1623 assert_eq!(terminal_outcome(&job), ConversionOutcome::Cancelled);
1624 }
1625
1626 #[cfg(unix)]
1627 #[test]
1628 fn missing_ffprobe_still_reports_timestamp_progress() {
1629 let directory = tempdir().expect("temp dir");
1630 let input = directory.path().join("clip.mp4");
1631 let output = directory.path().join("clip.mp3");
1632 fs::write(&input, "fake video").expect("write input");
1633 let ffmpeg = executable_script(
1634 directory.path(),
1635 "ffmpeg",
1636 "printf 'out_time_us=1250000\\n'",
1637 );
1638 let request = request(&input, &output, ConversionMode::ExtractAudio);
1639 let job = Converter::with_programs(ffmpeg, directory.path().join("missing")).spawn(request);
1640 let mut progress = None;
1641
1642 loop {
1643 match job
1644 .recv_timeout(Duration::from_secs(2))
1645 .expect("conversion event")
1646 {
1647 ConversionEvent::Progress {
1648 current_seconds,
1649 total_seconds,
1650 } => progress = Some((current_seconds, total_seconds)),
1651 ConversionEvent::Finished(outcome) => {
1652 assert_eq!(outcome, ConversionOutcome::Succeeded);
1653 break;
1654 }
1655 _ => {}
1656 }
1657 }
1658
1659 assert_eq!(progress, Some((1.25, None)));
1660 }
1661
1662 #[cfg(unix)]
1663 #[test]
1664 fn embedded_runner_preserves_non_utf8_paths() {
1665 use std::os::unix::ffi::OsStringExt;
1666
1667 let directory = tempdir().expect("temp dir");
1668 let input = directory
1669 .path()
1670 .join(OsString::from_vec(b"clip-\xff.mp4".to_vec()));
1671 let output = directory
1672 .path()
1673 .join(OsString::from_vec(b"clip-\xfe.mp3".to_vec()));
1674 fs::write(&input, "fake video").expect("write input");
1675 let ffmpeg = executable_script(
1676 directory.path(),
1677 "ffmpeg",
1678 "for arg in \"$@\"; do last=$arg; done\nprintf converted > \"$last\"",
1679 );
1680 let request = request(&input, &output, ConversionMode::ExtractAudio);
1681 let job = Converter::with_programs(ffmpeg, directory.path().join("missing")).spawn(request);
1682
1683 assert_eq!(terminal_outcome(&job), ConversionOutcome::Succeeded);
1684 assert_eq!(
1685 fs::read_to_string(output).expect("read output"),
1686 "converted"
1687 );
1688 }
1689
1690 #[test]
1691 fn quotes_shell_values_safely() {
1692 assert_eq!(shell_quote(""), "''");
1693 assert_eq!(shell_quote("clip.mp4"), "'clip.mp4'");
1694 assert_eq!(
1695 shell_quote("artist's clip.mp4"),
1696 "'artist'\"'\"'s clip.mp4'"
1697 );
1698 }
1699
1700 #[test]
1701 fn suggests_caery_output_name() {
1702 let path = Path::new("/tmp/source.video.mp4");
1703
1704 let output = suggest_output_path(
1705 path,
1706 ConversionMode::ExtractAudio,
1707 AudioFormat::Flac,
1708 VideoFormat::Mp4,
1709 )
1710 .expect("suggest output");
1711
1712 assert_eq!(output, PathBuf::from("/tmp/source.video-caery.flac"));
1713 }
1714
1715 #[test]
1716 fn builds_audio_extraction_command() {
1717 let mut input = NamedTempFile::with_suffix(".mp4").expect("temp video");
1718 writeln!(input, "fake video").expect("write temp video");
1719 let output = input.path().with_file_name("clip-caery.mp3");
1720 let request = request(input.path(), &output, ConversionMode::ExtractAudio);
1721
1722 let command = build_command(&request).expect("build command");
1723
1724 assert_eq!(command.program, "ffmpeg");
1725 assert!(command.args.contains(&"-vn".to_owned()));
1726 assert!(command.args.contains(&"libmp3lame".to_owned()));
1727 assert!(command.args.contains(&output.display().to_string()));
1728 }
1729
1730 #[test]
1731 fn builds_webm_transcode_command() {
1732 let mut input = NamedTempFile::with_suffix(".mkv").expect("temp video");
1733 writeln!(input, "fake video").expect("write temp video");
1734 let output = input.path().with_file_name("clip-caery.webm");
1735 let mut request = request(input.path(), &output, ConversionMode::TranscodeVideo);
1736 request.video_format = VideoFormat::Webm;
1737
1738 let command = build_command(&request).expect("build command");
1739
1740 assert!(command.args.contains(&"libvpx-vp9".to_owned()));
1741 assert!(command.args.contains(&"libopus".to_owned()));
1742 assert!(command.args.contains(&"0:a?".to_owned()));
1743 }
1744
1745 #[test]
1746 fn high_level_conversion_rejects_invalid_requests_before_spawning() {
1747 let request = ConversionRequest {
1748 mode: ConversionMode::ExtractAudio,
1749 input_path: PathBuf::new(),
1750 output_path: PathBuf::from("output.mp3"),
1751 audio_format: AudioFormat::Mp3,
1752 video_format: VideoFormat::Mp4,
1753 quality: QualityPreset::Balanced,
1754 overwrite: false,
1755 };
1756
1757 let error = start_conversion(&request).expect_err("reject invalid request");
1758
1759 assert!(matches!(error, ConversionError::MissingInput));
1760 }
1761
1762 #[test]
1763 fn rejects_existing_output_without_overwrite() {
1764 let mut input = NamedTempFile::with_suffix(".wav").expect("temp audio");
1765 writeln!(input, "fake audio").expect("write temp audio");
1766 let output = NamedTempFile::with_suffix(".mp3").expect("temp output");
1767 let mut request = request(input.path(), output.path(), ConversionMode::TranscodeAudio);
1768 request.overwrite = false;
1769
1770 let error = validate_request(&request).expect_err("reject existing output");
1771
1772 assert!(matches!(error, ConversionError::OutputExists(_)));
1773 }
1774
1775 #[test]
1776 fn parses_ffmpeg_progress_lines() {
1777 assert_eq!(
1778 parse_ffmpeg_progress_seconds("out_time_us=1250000"),
1779 Some(1.25)
1780 );
1781 assert_eq!(
1782 parse_ffmpeg_progress_seconds("out_time_ms=2500000"),
1783 Some(2.5)
1784 );
1785 assert_eq!(
1786 parse_ffmpeg_progress_seconds("out_time=00:01:02.500000"),
1787 Some(62.5)
1788 );
1789 assert_eq!(parse_ffmpeg_progress_seconds("frame=20"), None);
1790 }
1791
1792 #[test]
1793 fn labels_duration_progress() {
1794 assert_eq!(duration_progress_label(30.0, 120.0), "0:30 / 2:00 (25.0%)");
1795 }
1796
1797 #[test]
1798 fn formats_human_sizes() {
1799 assert_eq!(human_size(0), "0 B");
1800 assert_eq!(human_size(1024), "1.0 KiB");
1801 assert_eq!(human_size(4 * 1024 * 1024 * 1024), "4.0 GiB");
1802 }
1803}