Skip to main content

caery_lib/
lib.rs

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
491/// Validates a request and starts its conversion on a background thread.
492///
493/// Process startup and ffmpeg failures are reported through the returned event
494/// stream as [`OperationEvent::Finished`].
495pub 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}