Skip to main content

caery_lib/
lib.rs

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/// A conversion route inferred from input and output file extensions.
233#[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/// An error produced while inferring a conversion from file extensions.
277#[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
288/// Infers a supported route from two paths without accessing the filesystem.
289pub 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
316/// Creates a balanced, no-overwrite request inferred from two paths.
317///
318/// The paths do not need to exist until the request is validated or started.
319pub 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
612/// Validates a request and starts its conversion on a background thread.
613///
614/// Process startup and ffmpeg failures are reported through the returned event
615/// stream as [`OperationEvent::Finished`].
616pub 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/// Configures the executables and timeouts used by embedded conversions.
629#[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    /// Uses `ffmpeg` and `ffprobe` from `PATH`.
650    pub fn new() -> Self {
651        Self::default()
652    }
653
654    /// Uses explicit ffmpeg and ffprobe executable paths.
655    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    /// Sets the maximum time allowed for each executable availability check.
667    pub fn with_tool_check_timeout(mut self, timeout: Duration) -> Self {
668        self.tool_check_timeout = timeout;
669        self
670    }
671
672    /// Sets the maximum duration probe time before conversion continues without a total.
673    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    /// Checks both executables synchronously using their `-version` option.
687    ///
688    /// Run this outside a UI render loop. Each check is bounded by the
689    /// configured tool-check timeout.
690    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    /// Starts a conversion worker and returns immediately.
698    ///
699    /// Validation, probing, and process startup happen on the worker thread.
700    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/// The result of checking one external executable.
721#[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/// Availability information for the external tools used by Caery.
746#[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    /// Whether conversion can be attempted. ffprobe is optional for progress totals.
762    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
771/// Checks `ffmpeg` and `ffprobe` from `PATH` synchronously.
772pub 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        // Worker processes are group leaders, so this also terminates wrappers and descendants.
820        unsafe {
821            libc::kill(-process_group, libc::SIGKILL);
822        }
823    }
824
825    let _ = child.kill();
826    let _ = child.wait();
827}
828
829/// The terminal result of an embedded conversion job.
830#[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/// Events emitted by a cancellable embedded conversion job.
849#[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/// A non-blocking conversion with event polling and cancellation.
863#[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    /// Idempotently asks the worker to stop probing or terminate ffmpeg.
896    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
911/// Starts a cancellable conversion using `ffmpeg` and `ffprobe` from `PATH`.
912pub 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
1335/// Returns the selectable audio output format represented by a path.
1336pub 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
1348/// Returns the selectable video output format represented by a path.
1349pub 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}