1#![doc = include_str!("../README.md")]
2
3use std::fmt;
4use std::io::{BufRead, BufReader, Read};
5use std::path::{Path, PathBuf};
6use std::process::{Command, Stdio};
7use std::sync::mpsc::{self, Receiver, Sender};
8use std::thread;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ConversionMode {
12 ExtractAudio,
13 TranscodeVideo,
14 TranscodeAudio,
15}
16
17impl ConversionMode {
18 pub const ALL: [ConversionMode; 3] = [
19 ConversionMode::ExtractAudio,
20 ConversionMode::TranscodeVideo,
21 ConversionMode::TranscodeAudio,
22 ];
23
24 pub fn title(self) -> &'static str {
25 match self {
26 ConversionMode::ExtractAudio => "Video -> Audio",
27 ConversionMode::TranscodeVideo => "Video -> Video",
28 ConversionMode::TranscodeAudio => "Audio -> Audio",
29 }
30 }
31
32 pub fn description(self) -> &'static str {
33 match self {
34 ConversionMode::ExtractAudio => "Strip video and encode the audio track only.",
35 ConversionMode::TranscodeVideo => "Re-encode video into another container/codec route.",
36 ConversionMode::TranscodeAudio => "Convert one audio file into another audio format.",
37 }
38 }
39
40 pub fn source_label(self) -> &'static str {
41 match self {
42 ConversionMode::ExtractAudio | ConversionMode::TranscodeVideo => "VIDEO SOURCE",
43 ConversionMode::TranscodeAudio => "AUDIO SOURCE",
44 }
45 }
46
47 pub fn note_text(self) -> &'static str {
48 match self {
49 ConversionMode::ExtractAudio => {
50 "EXTRACT ROUTE // SOURCE VIDEO -> AUDIO CODEC -> OUTPUT CONTAINER"
51 }
52 ConversionMode::TranscodeVideo => {
53 "VIDEO ROUTE // SOURCE STREAMS -> TRANSCODE MATRIX -> OUTPUT CONTAINER"
54 }
55 ConversionMode::TranscodeAudio => {
56 "AUDIO ROUTE // SOURCE AUDIO -> CODEC CONVERSION -> OUTPUT FILE"
57 }
58 }
59 }
60
61 pub fn expects_video_input(self) -> bool {
62 matches!(
63 self,
64 ConversionMode::ExtractAudio | ConversionMode::TranscodeVideo
65 )
66 }
67
68 pub fn output_extension(self, audio: AudioFormat, video: VideoFormat) -> &'static str {
69 match self {
70 ConversionMode::ExtractAudio | ConversionMode::TranscodeAudio => audio.extension(),
71 ConversionMode::TranscodeVideo => video.extension(),
72 }
73 }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum AudioFormat {
78 Mp3,
79 M4a,
80 Flac,
81 Wav,
82 Ogg,
83 Opus,
84}
85
86impl AudioFormat {
87 pub const ALL: [AudioFormat; 6] = [
88 AudioFormat::Mp3,
89 AudioFormat::M4a,
90 AudioFormat::Flac,
91 AudioFormat::Wav,
92 AudioFormat::Ogg,
93 AudioFormat::Opus,
94 ];
95
96 pub fn label(self) -> &'static str {
97 match self {
98 AudioFormat::Mp3 => "MP3",
99 AudioFormat::M4a => "M4A/AAC",
100 AudioFormat::Flac => "FLAC",
101 AudioFormat::Wav => "WAV",
102 AudioFormat::Ogg => "OGG",
103 AudioFormat::Opus => "OPUS",
104 }
105 }
106
107 pub fn extension(self) -> &'static str {
108 match self {
109 AudioFormat::Mp3 => "mp3",
110 AudioFormat::M4a => "m4a",
111 AudioFormat::Flac => "flac",
112 AudioFormat::Wav => "wav",
113 AudioFormat::Ogg => "ogg",
114 AudioFormat::Opus => "opus",
115 }
116 }
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum VideoFormat {
121 Mp4,
122 Mkv,
123 Webm,
124 Mov,
125}
126
127impl VideoFormat {
128 pub const ALL: [VideoFormat; 4] = [
129 VideoFormat::Mp4,
130 VideoFormat::Mkv,
131 VideoFormat::Webm,
132 VideoFormat::Mov,
133 ];
134
135 pub fn label(self) -> &'static str {
136 match self {
137 VideoFormat::Mp4 => "MP4",
138 VideoFormat::Mkv => "MKV",
139 VideoFormat::Webm => "WEBM",
140 VideoFormat::Mov => "MOV",
141 }
142 }
143
144 pub fn extension(self) -> &'static str {
145 match self {
146 VideoFormat::Mp4 => "mp4",
147 VideoFormat::Mkv => "mkv",
148 VideoFormat::Webm => "webm",
149 VideoFormat::Mov => "mov",
150 }
151 }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum QualityPreset {
156 Compact,
157 Balanced,
158 Archive,
159}
160
161impl QualityPreset {
162 pub const ALL: [QualityPreset; 3] = [
163 QualityPreset::Compact,
164 QualityPreset::Balanced,
165 QualityPreset::Archive,
166 ];
167
168 pub fn label(self) -> &'static str {
169 match self {
170 QualityPreset::Compact => "Compact",
171 QualityPreset::Balanced => "Balanced",
172 QualityPreset::Archive => "Archive",
173 }
174 }
175
176 pub fn description(self) -> &'static str {
177 match self {
178 QualityPreset::Compact => "smaller file",
179 QualityPreset::Balanced => "general use",
180 QualityPreset::Archive => "higher fidelity",
181 }
182 }
183
184 fn x264_crf(self) -> &'static str {
185 match self {
186 QualityPreset::Compact => "28",
187 QualityPreset::Balanced => "23",
188 QualityPreset::Archive => "18",
189 }
190 }
191
192 fn vp9_crf(self) -> &'static str {
193 match self {
194 QualityPreset::Compact => "38",
195 QualityPreset::Balanced => "32",
196 QualityPreset::Archive => "24",
197 }
198 }
199
200 fn audio_bitrate(self) -> &'static str {
201 match self {
202 QualityPreset::Compact => "128k",
203 QualityPreset::Balanced => "192k",
204 QualityPreset::Archive => "256k",
205 }
206 }
207
208 fn vorbis_quality(self) -> &'static str {
209 match self {
210 QualityPreset::Compact => "3",
211 QualityPreset::Balanced => "5",
212 QualityPreset::Archive => "8",
213 }
214 }
215}
216
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct ConversionRequest {
219 pub mode: ConversionMode,
220 pub input_path: PathBuf,
221 pub output_path: PathBuf,
222 pub audio_format: AudioFormat,
223 pub video_format: VideoFormat,
224 pub quality: QualityPreset,
225 pub overwrite: bool,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct CommandSpec {
230 pub program: String,
231 pub args: Vec<String>,
232}
233
234impl fmt::Display for CommandSpec {
235 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
236 write!(formatter, "{}", self.program)?;
237 for arg in &self.args {
238 write!(formatter, " {}", shell_quote(arg))?;
239 }
240 Ok(())
241 }
242}
243
244#[derive(Debug, thiserror::Error)]
245pub enum ConversionError {
246 #[error("select a source media file first")]
247 MissingInput,
248 #[error("source file does not exist: {0}")]
249 MissingInputFile(String),
250 #[error("source path must be a regular file: {0}")]
251 InputNotFile(String),
252 #[error("selected route expects a video source: {0}")]
253 ExpectedVideoInput(String),
254 #[error("selected route expects an audio source: {0}")]
255 ExpectedAudioInput(String),
256 #[error("choose an output file path first")]
257 MissingOutput,
258 #[error("output parent folder does not exist: {0}")]
259 MissingOutputFolder(String),
260 #[error("output cannot be the same file as the source")]
261 SameInputOutput,
262 #[error("output extension should be .{expected} for the selected route")]
263 OutputExtensionMismatch { expected: &'static str },
264 #[error("output already exists; enable overwrite or choose another path: {0}")]
265 OutputExists(String),
266}
267
268pub fn build_command(request: &ConversionRequest) -> Result<CommandSpec, ConversionError> {
269 validate_request(request)?;
270
271 let mut args = vec![
272 "-hide_banner".to_owned(),
273 "-nostdin".to_owned(),
274 "-progress".to_owned(),
275 "pipe:1".to_owned(),
276 "-nostats".to_owned(),
277 if request.overwrite { "-y" } else { "-n" }.to_owned(),
278 "-i".to_owned(),
279 request.input_path.display().to_string(),
280 ];
281
282 match request.mode {
283 ConversionMode::ExtractAudio | ConversionMode::TranscodeAudio => {
284 args.push("-map".to_owned());
285 args.push("0:a:0".to_owned());
286 args.push("-vn".to_owned());
287 apply_audio_args(&mut args, request.audio_format, request.quality);
288 }
289 ConversionMode::TranscodeVideo => {
290 args.push("-map".to_owned());
291 args.push("0:v:0".to_owned());
292 args.push("-map".to_owned());
293 args.push("0:a?".to_owned());
294 args.push("-sn".to_owned());
295 apply_video_args(&mut args, request.video_format, request.quality);
296 }
297 }
298
299 args.push(request.output_path.display().to_string());
300
301 Ok(CommandSpec {
302 program: "ffmpeg".to_owned(),
303 args,
304 })
305}
306
307pub fn validate_request(request: &ConversionRequest) -> Result<(), ConversionError> {
308 if request.input_path.as_os_str().is_empty() {
309 return Err(ConversionError::MissingInput);
310 }
311
312 let input_display = request.input_path.display().to_string();
313 let metadata = std::fs::metadata(&request.input_path)
314 .map_err(|_| ConversionError::MissingInputFile(input_display.clone()))?;
315 if !metadata.is_file() {
316 return Err(ConversionError::InputNotFile(input_display));
317 }
318
319 if request.mode.expects_video_input() && !supported_video_extension(&request.input_path) {
320 return Err(ConversionError::ExpectedVideoInput(
321 request.input_path.display().to_string(),
322 ));
323 }
324 if matches!(request.mode, ConversionMode::TranscodeAudio)
325 && !supported_audio_extension(&request.input_path)
326 {
327 return Err(ConversionError::ExpectedAudioInput(
328 request.input_path.display().to_string(),
329 ));
330 }
331
332 if request.output_path.as_os_str().is_empty() {
333 return Err(ConversionError::MissingOutput);
334 }
335
336 if request.input_path == request.output_path
337 || canonical_match(&request.input_path, &request.output_path)
338 {
339 return Err(ConversionError::SameInputOutput);
340 }
341
342 if let Some(parent) = request.output_path.parent() {
343 if !parent.as_os_str().is_empty() && !parent.is_dir() {
344 return Err(ConversionError::MissingOutputFolder(
345 parent.display().to_string(),
346 ));
347 }
348 }
349
350 let expected = request
351 .mode
352 .output_extension(request.audio_format, request.video_format);
353 if path_extension_lower(&request.output_path).as_deref() != Some(expected) {
354 return Err(ConversionError::OutputExtensionMismatch { expected });
355 }
356
357 if request.output_path.exists() && !request.overwrite {
358 return Err(ConversionError::OutputExists(
359 request.output_path.display().to_string(),
360 ));
361 }
362
363 Ok(())
364}
365
366fn canonical_match(left: &Path, right: &Path) -> bool {
367 match (std::fs::canonicalize(left), std::fs::canonicalize(right)) {
368 (Ok(left), Ok(right)) => left == right,
369 _ => false,
370 }
371}
372
373fn apply_audio_args(args: &mut Vec<String>, format: AudioFormat, quality: QualityPreset) {
374 match format {
375 AudioFormat::Mp3 => {
376 push_args(
377 args,
378 ["-c:a", "libmp3lame", "-b:a", quality.audio_bitrate()],
379 );
380 }
381 AudioFormat::M4a => {
382 push_args(args, ["-c:a", "aac", "-b:a", quality.audio_bitrate()]);
383 push_args(args, ["-movflags", "+faststart"]);
384 }
385 AudioFormat::Flac => push_args(args, ["-c:a", "flac"]),
386 AudioFormat::Wav => push_args(args, ["-c:a", "pcm_s16le"]),
387 AudioFormat::Ogg => {
388 push_args(
389 args,
390 ["-c:a", "libvorbis", "-q:a", quality.vorbis_quality()],
391 );
392 }
393 AudioFormat::Opus => {
394 push_args(args, ["-c:a", "libopus", "-b:a", quality.audio_bitrate()]);
395 }
396 }
397}
398
399fn apply_video_args(args: &mut Vec<String>, format: VideoFormat, quality: QualityPreset) {
400 match format {
401 VideoFormat::Mp4 | VideoFormat::Mkv | VideoFormat::Mov => {
402 push_args(
403 args,
404 [
405 "-c:v",
406 "libx264",
407 "-preset",
408 "medium",
409 "-crf",
410 quality.x264_crf(),
411 "-pix_fmt",
412 "yuv420p",
413 "-c:a",
414 "aac",
415 "-b:a",
416 quality.audio_bitrate(),
417 ],
418 );
419 if matches!(format, VideoFormat::Mp4 | VideoFormat::Mov) {
420 push_args(args, ["-movflags", "+faststart"]);
421 }
422 }
423 VideoFormat::Webm => {
424 push_args(
425 args,
426 [
427 "-c:v",
428 "libvpx-vp9",
429 "-b:v",
430 "0",
431 "-crf",
432 quality.vp9_crf(),
433 "-c:a",
434 "libopus",
435 "-b:a",
436 quality.audio_bitrate(),
437 ],
438 );
439 }
440 }
441}
442
443fn push_args<const N: usize>(args: &mut Vec<String>, values: [&str; N]) {
444 args.extend(values.into_iter().map(str::to_owned));
445}
446
447pub fn probe_duration(path: &Path) -> Option<f64> {
448 let output = Command::new("ffprobe")
449 .args([
450 "-v",
451 "error",
452 "-show_entries",
453 "format=duration",
454 "-of",
455 "default=noprint_wrappers=1:nokey=1",
456 ])
457 .arg(path)
458 .output()
459 .ok()?;
460
461 if !output.status.success() {
462 return None;
463 }
464
465 let text = String::from_utf8_lossy(&output.stdout);
466 text.lines()
467 .find_map(|line| line.trim().parse::<f64>().ok())
468 .filter(|duration| *duration > 0.0 && duration.is_finite())
469}
470
471#[derive(Debug, Clone, PartialEq)]
472pub enum OperationEvent {
473 Started(String),
474 Log(String),
475 Progress {
476 current_seconds: f64,
477 total_seconds: f64,
478 },
479 Finished(Result<(), String>),
480}
481
482pub fn start_operation(
483 command: CommandSpec,
484 expected_duration: Option<f64>,
485) -> Receiver<OperationEvent> {
486 let (tx, rx) = mpsc::channel();
487 thread::spawn(move || run_operation(command, expected_duration, tx));
488 rx
489}
490
491pub fn start_conversion(
496 request: &ConversionRequest,
497) -> Result<Receiver<OperationEvent>, ConversionError> {
498 let command = build_command(request)?;
499 let expected_duration = probe_duration(&request.input_path);
500 Ok(start_operation(command, expected_duration))
501}
502
503fn run_operation(command: CommandSpec, expected_duration: Option<f64>, tx: Sender<OperationEvent>) {
504 let _ = tx.send(OperationEvent::Started(command.to_string()));
505
506 let mut child = match Command::new(&command.program)
507 .args(&command.args)
508 .stdout(Stdio::piped())
509 .stderr(Stdio::piped())
510 .spawn()
511 {
512 Ok(child) => child,
513 Err(error) => {
514 let _ = tx.send(OperationEvent::Finished(Err(format!(
515 "failed to start ffmpeg: {error}"
516 ))));
517 return;
518 }
519 };
520
521 let stdout = child.stdout.take();
522 let stderr = child.stderr.take();
523 let stdout_thread = stdout.map(|stream| forward_stream(stream, tx.clone(), expected_duration));
524 let stderr_thread = stderr.map(|stream| forward_stream(stream, tx.clone(), expected_duration));
525
526 let result = match child.wait() {
527 Ok(status) if status.success() => Ok(()),
528 Ok(status) => Err(format!("ffmpeg exited with status {status}")),
529 Err(error) => Err(format!("failed to wait for ffmpeg: {error}")),
530 };
531
532 if let Some(thread) = stdout_thread {
533 let _ = thread.join();
534 }
535 if let Some(thread) = stderr_thread {
536 let _ = thread.join();
537 }
538
539 let _ = tx.send(OperationEvent::Finished(result));
540}
541
542fn forward_stream<R>(
543 stream: R,
544 tx: Sender<OperationEvent>,
545 expected_duration: Option<f64>,
546) -> thread::JoinHandle<()>
547where
548 R: Read + Send + 'static,
549{
550 thread::spawn(move || {
551 let reader = BufReader::new(stream);
552 for line in reader.lines() {
553 let line = match line {
554 Ok(line) => line.trim().to_owned(),
555 Err(error) => {
556 let _ = tx.send(OperationEvent::Log(format!("stream read failed: {error}")));
557 break;
558 }
559 };
560
561 if line.is_empty() {
562 continue;
563 }
564
565 if let Some(current_seconds) = parse_ffmpeg_progress_seconds(&line) {
566 if let Some(total_seconds) = expected_duration {
567 let _ = tx.send(OperationEvent::Progress {
568 current_seconds: current_seconds.min(total_seconds),
569 total_seconds,
570 });
571 }
572 continue;
573 }
574
575 if is_ffmpeg_progress_metadata(&line) {
576 continue;
577 }
578
579 let _ = tx.send(OperationEvent::Log(line));
580 }
581 })
582}
583
584pub fn parse_ffmpeg_progress_seconds(line: &str) -> Option<f64> {
585 let (key, value) = line.trim().split_once('=')?;
586 match key {
587 "out_time_us" | "out_time_ms" => value
588 .trim()
589 .parse::<f64>()
590 .ok()
591 .map(|microseconds| microseconds / 1_000_000.0),
592 "out_time" => parse_ffmpeg_timestamp(value.trim()),
593 _ => None,
594 }
595}
596
597fn parse_ffmpeg_timestamp(value: &str) -> Option<f64> {
598 let mut parts = value.split(':');
599 let hours = parts.next()?.parse::<f64>().ok()?;
600 let minutes = parts.next()?.parse::<f64>().ok()?;
601 let seconds = parts.next()?.parse::<f64>().ok()?;
602 if parts.next().is_some() {
603 return None;
604 }
605 Some(hours * 3600.0 + minutes * 60.0 + seconds)
606}
607
608fn is_ffmpeg_progress_metadata(line: &str) -> bool {
609 let Some((key, _)) = line.split_once('=') else {
610 return false;
611 };
612
613 matches!(
614 key,
615 "bitrate"
616 | "drop_frames"
617 | "dup_frames"
618 | "fps"
619 | "frame"
620 | "progress"
621 | "speed"
622 | "stream_0_0_q"
623 | "total_size"
624 )
625}
626
627pub fn duration_progress_label(current_seconds: f64, total_seconds: f64) -> String {
628 let percent = if total_seconds <= 0.0 {
629 0.0
630 } else {
631 current_seconds / total_seconds * 100.0
632 }
633 .clamp(0.0, 100.0);
634
635 format!(
636 "{} / {} ({percent:.1}%)",
637 format_duration(current_seconds),
638 format_duration(total_seconds)
639 )
640}
641
642pub fn format_duration(seconds: f64) -> String {
643 let seconds = seconds.max(0.0).round() as u64;
644 let hours = seconds / 3600;
645 let minutes = (seconds % 3600) / 60;
646 let seconds = seconds % 60;
647
648 if hours > 0 {
649 format!("{hours}:{minutes:02}:{seconds:02}")
650 } else {
651 format!("{minutes}:{seconds:02}")
652 }
653}
654
655pub fn suggest_output_path(
656 input_path: &Path,
657 mode: ConversionMode,
658 audio_format: AudioFormat,
659 video_format: VideoFormat,
660) -> Option<PathBuf> {
661 if input_path.as_os_str().is_empty() {
662 return None;
663 }
664
665 let stem = input_path.file_stem()?.to_string_lossy();
666 let extension = mode.output_extension(audio_format, video_format);
667 let filename = format!("{stem}-caery.{extension}");
668 let mut output = input_path.parent().map(PathBuf::from).unwrap_or_default();
669 output.push(filename);
670 Some(output)
671}
672
673pub fn supported_media_extension(path: &Path) -> bool {
674 supported_audio_extension(path) || supported_video_extension(path)
675}
676
677pub fn supported_audio_extension(path: &Path) -> bool {
678 matches!(
679 path_extension_lower(path).as_deref(),
680 Some(
681 "aac"
682 | "aif"
683 | "aiff"
684 | "alac"
685 | "flac"
686 | "m4a"
687 | "mp3"
688 | "oga"
689 | "ogg"
690 | "opus"
691 | "wav"
692 | "wma"
693 )
694 )
695}
696
697pub fn supported_video_extension(path: &Path) -> bool {
698 matches!(
699 path_extension_lower(path).as_deref(),
700 Some(
701 "3gp"
702 | "avi"
703 | "flv"
704 | "m2ts"
705 | "m4v"
706 | "mkv"
707 | "mov"
708 | "mp4"
709 | "mpeg"
710 | "mpg"
711 | "ogv"
712 | "ts"
713 | "webm"
714 | "wmv"
715 )
716 )
717}
718
719pub fn media_kind_label(path: &Path) -> &'static str {
720 if supported_video_extension(path) {
721 "VIDEO"
722 } else if supported_audio_extension(path) {
723 "AUDIO"
724 } else {
725 "FILE"
726 }
727}
728
729pub fn path_extension_lower(path: &Path) -> Option<String> {
730 path.extension()
731 .and_then(|extension| extension.to_str())
732 .map(|extension| extension.to_ascii_lowercase())
733}
734
735pub fn human_size(bytes: u64) -> String {
736 const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
737 let mut value = bytes as f64;
738 let mut unit = 0;
739
740 while value >= 1024.0 && unit < UNITS.len() - 1 {
741 value /= 1024.0;
742 unit += 1;
743 }
744
745 if unit == 0 {
746 format!("{} {}", bytes, UNITS[unit])
747 } else {
748 format!("{value:.1} {}", UNITS[unit])
749 }
750}
751
752pub fn shell_quote(value: &str) -> String {
753 if value.is_empty() {
754 return "''".to_owned();
755 }
756
757 format!("'{}'", value.replace('\'', "'\"'\"'"))
758}
759
760#[cfg(test)]
761mod tests {
762 use super::*;
763 use std::io::Write;
764 use tempfile::NamedTempFile;
765
766 fn request(input: &Path, output: &Path, mode: ConversionMode) -> ConversionRequest {
767 ConversionRequest {
768 mode,
769 input_path: input.to_path_buf(),
770 output_path: output.to_path_buf(),
771 audio_format: AudioFormat::Mp3,
772 video_format: VideoFormat::Mp4,
773 quality: QualityPreset::Balanced,
774 overwrite: true,
775 }
776 }
777
778 #[test]
779 fn quotes_shell_values_safely() {
780 assert_eq!(shell_quote(""), "''");
781 assert_eq!(shell_quote("clip.mp4"), "'clip.mp4'");
782 assert_eq!(
783 shell_quote("artist's clip.mp4"),
784 "'artist'\"'\"'s clip.mp4'"
785 );
786 }
787
788 #[test]
789 fn suggests_caery_output_name() {
790 let path = Path::new("/tmp/source.video.mp4");
791
792 let output = suggest_output_path(
793 path,
794 ConversionMode::ExtractAudio,
795 AudioFormat::Flac,
796 VideoFormat::Mp4,
797 )
798 .expect("suggest output");
799
800 assert_eq!(output, PathBuf::from("/tmp/source.video-caery.flac"));
801 }
802
803 #[test]
804 fn builds_audio_extraction_command() {
805 let mut input = NamedTempFile::with_suffix(".mp4").expect("temp video");
806 writeln!(input, "fake video").expect("write temp video");
807 let output = input.path().with_file_name("clip-caery.mp3");
808 let request = request(input.path(), &output, ConversionMode::ExtractAudio);
809
810 let command = build_command(&request).expect("build command");
811
812 assert_eq!(command.program, "ffmpeg");
813 assert!(command.args.contains(&"-vn".to_owned()));
814 assert!(command.args.contains(&"libmp3lame".to_owned()));
815 assert!(command.args.contains(&output.display().to_string()));
816 }
817
818 #[test]
819 fn builds_webm_transcode_command() {
820 let mut input = NamedTempFile::with_suffix(".mkv").expect("temp video");
821 writeln!(input, "fake video").expect("write temp video");
822 let output = input.path().with_file_name("clip-caery.webm");
823 let mut request = request(input.path(), &output, ConversionMode::TranscodeVideo);
824 request.video_format = VideoFormat::Webm;
825
826 let command = build_command(&request).expect("build command");
827
828 assert!(command.args.contains(&"libvpx-vp9".to_owned()));
829 assert!(command.args.contains(&"libopus".to_owned()));
830 assert!(command.args.contains(&"0:a?".to_owned()));
831 }
832
833 #[test]
834 fn high_level_conversion_rejects_invalid_requests_before_spawning() {
835 let request = ConversionRequest {
836 mode: ConversionMode::ExtractAudio,
837 input_path: PathBuf::new(),
838 output_path: PathBuf::from("output.mp3"),
839 audio_format: AudioFormat::Mp3,
840 video_format: VideoFormat::Mp4,
841 quality: QualityPreset::Balanced,
842 overwrite: false,
843 };
844
845 let error = start_conversion(&request).expect_err("reject invalid request");
846
847 assert!(matches!(error, ConversionError::MissingInput));
848 }
849
850 #[test]
851 fn rejects_existing_output_without_overwrite() {
852 let mut input = NamedTempFile::with_suffix(".wav").expect("temp audio");
853 writeln!(input, "fake audio").expect("write temp audio");
854 let output = NamedTempFile::with_suffix(".mp3").expect("temp output");
855 let mut request = request(input.path(), output.path(), ConversionMode::TranscodeAudio);
856 request.overwrite = false;
857
858 let error = validate_request(&request).expect_err("reject existing output");
859
860 assert!(matches!(error, ConversionError::OutputExists(_)));
861 }
862
863 #[test]
864 fn parses_ffmpeg_progress_lines() {
865 assert_eq!(
866 parse_ffmpeg_progress_seconds("out_time_us=1250000"),
867 Some(1.25)
868 );
869 assert_eq!(
870 parse_ffmpeg_progress_seconds("out_time_ms=2500000"),
871 Some(2.5)
872 );
873 assert_eq!(
874 parse_ffmpeg_progress_seconds("out_time=00:01:02.500000"),
875 Some(62.5)
876 );
877 assert_eq!(parse_ffmpeg_progress_seconds("frame=20"), None);
878 }
879
880 #[test]
881 fn labels_duration_progress() {
882 assert_eq!(duration_progress_label(30.0, 120.0), "0:30 / 2:00 (25.0%)");
883 }
884
885 #[test]
886 fn formats_human_sizes() {
887 assert_eq!(human_size(0), "0 B");
888 assert_eq!(human_size(1024), "1.0 KiB");
889 assert_eq!(human_size(4 * 1024 * 1024 * 1024), "4.0 GiB");
890 }
891}