Skip to main content

mangofetch_core/core/
ffmpeg.rs

1use std::path::Path;
2
3use anyhow::anyhow;
4use serde::{Deserialize, Serialize};
5use tokio::io::{AsyncBufReadExt, BufReader};
6use tokio::sync::mpsc;
7use tokio_util::sync::CancellationToken;
8
9static FFMPEG_AVAILABLE_CACHE: std::sync::RwLock<Option<bool>> = std::sync::RwLock::new(None);
10
11pub async fn is_ffmpeg_available() -> bool {
12    if let Ok(cache) = FFMPEG_AVAILABLE_CACHE.read() {
13        if let Some(val) = *cache {
14            return val;
15        }
16    }
17    let available = crate::core::dependencies::find_tool("ffmpeg")
18        .await
19        .is_some();
20    if let Ok(mut cache) = FFMPEG_AVAILABLE_CACHE.write() {
21        *cache = Some(available);
22    }
23    available
24}
25
26pub fn reset_ffmpeg_available_cache() {
27    if let Ok(mut cache) = FFMPEG_AVAILABLE_CACHE.write() {
28        *cache = None;
29    }
30}
31
32pub async fn mux_video_audio(video: &Path, audio: &Path, output: &Path) -> anyhow::Result<()> {
33    if let Some(parent) = output.parent() {
34        tokio::fs::create_dir_all(parent).await?;
35    }
36
37    let status = crate::core::process::command("ffmpeg")
38        .args([
39            "-y",
40            "-i",
41            &video.to_string_lossy(),
42            "-i",
43            &audio.to_string_lossy(),
44            "-c",
45            "copy",
46            &output.to_string_lossy(),
47        ])
48        .stdout(std::process::Stdio::null())
49        .stderr(std::process::Stdio::null())
50        .status()
51        .await
52        .map_err(|e| anyhow!("Failed to run ffmpeg: {}", e))?;
53
54    if !status.success() {
55        return Err(anyhow!("ffmpeg returned code {}", status));
56    }
57
58    Ok(())
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct ConversionOptions {
63    pub input_path: String,
64    pub output_path: String,
65    pub video_codec: Option<String>,
66    pub audio_codec: Option<String>,
67    pub resolution: Option<String>,
68    pub video_bitrate: Option<String>,
69    pub audio_bitrate: Option<String>,
70    pub sample_rate: Option<u32>,
71    pub fps: Option<f64>,
72    pub trim_start: Option<String>,
73    pub trim_end: Option<String>,
74    pub additional_input_args: Option<Vec<String>>,
75    pub additional_output_args: Option<Vec<String>>,
76    pub preset: Option<String>,
77}
78
79impl ConversionOptions {
80    pub fn build_ffmpeg_args(&self) -> Vec<String> {
81        let mut args: Vec<String> = vec!["-y".to_string()];
82
83        if let Some(ref start) = self.trim_start {
84            args.extend(["-ss".to_string(), start.clone()]);
85        }
86
87        if let Some(ref extra) = self.additional_input_args {
88            args.extend(extra.clone());
89        }
90
91        args.extend(["-i".to_string(), self.input_path.clone()]);
92
93        if let Some(ref end) = self.trim_end {
94            args.extend(["-to".to_string(), end.clone()]);
95        }
96
97        if let Some(ref codec) = self.video_codec {
98            args.extend(["-c:v".to_string(), codec.clone()]);
99        }
100
101        if let Some(ref codec) = self.audio_codec {
102            args.extend(["-c:a".to_string(), codec.clone()]);
103        }
104
105        if let Some(ref res) = self.resolution {
106            args.extend(["-s".to_string(), res.clone()]);
107        }
108
109        if let Some(ref br) = self.video_bitrate {
110            args.extend(["-b:v".to_string(), br.clone()]);
111        }
112
113        if let Some(ref br) = self.audio_bitrate {
114            args.extend(["-b:a".to_string(), br.clone()]);
115        }
116
117        if let Some(sr) = self.sample_rate {
118            args.extend(["-ar".to_string(), sr.to_string()]);
119        }
120
121        if let Some(fps) = self.fps {
122            args.extend(["-r".to_string(), fps.to_string()]);
123        }
124
125        if let Some(ref preset) = self.preset {
126            args.extend(["-preset".to_string(), preset.clone()]);
127        }
128
129        if let Some(ref extra) = self.additional_output_args {
130            args.extend(extra.clone());
131        }
132
133        args.extend([
134            "-progress".to_string(),
135            "pipe:1".to_string(),
136            "-nostats".to_string(),
137            self.output_path.clone(),
138        ]);
139
140        args
141    }
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct MediaProbeInfo {
146    pub duration_seconds: f64,
147    pub format_name: String,
148    pub format_long_name: String,
149    pub file_size_bytes: u64,
150    pub bit_rate: u64,
151    pub streams: Vec<StreamInfo>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct StreamInfo {
156    pub index: u32,
157    pub codec_type: String,
158    pub codec_name: String,
159    pub codec_long_name: String,
160    pub width: Option<u32>,
161    pub height: Option<u32>,
162    pub fps: Option<f64>,
163    pub bit_rate: Option<u64>,
164    pub sample_rate: Option<u32>,
165    pub channels: Option<u32>,
166    pub duration_seconds: Option<f64>,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct ConversionResult {
171    pub success: bool,
172    pub output_path: String,
173    pub file_size_bytes: u64,
174    pub duration_seconds: f64,
175    pub error: Option<String>,
176}
177
178pub async fn probe(path: &Path) -> anyhow::Result<MediaProbeInfo> {
179    let output = crate::core::process::command("ffprobe")
180        .args([
181            "-v",
182            "quiet",
183            "-print_format",
184            "json",
185            "-show_format",
186            "-show_streams",
187            &path.to_string_lossy(),
188        ])
189        .stdout(std::process::Stdio::piped())
190        .stderr(std::process::Stdio::piped())
191        .output()
192        .await
193        .map_err(|e| anyhow!("Failed to run ffprobe: {}", e))?;
194
195    if !output.status.success() {
196        let stderr = String::from_utf8_lossy(&output.stderr);
197        return Err(anyhow!("ffprobe failed: {}", stderr));
198    }
199
200    let json: serde_json::Value = serde_json::from_slice(&output.stdout)
201        .map_err(|e| anyhow!("Failed to parse ffprobe JSON: {}", e))?;
202
203    let format = json
204        .get("format")
205        .ok_or_else(|| anyhow!("Missing 'format' field"))?;
206
207    let duration_seconds = format
208        .get("duration")
209        .and_then(|v| v.as_str())
210        .and_then(|s| s.parse::<f64>().ok())
211        .unwrap_or(0.0);
212
213    let format_name = format
214        .get("format_name")
215        .and_then(|v| v.as_str())
216        .unwrap_or("")
217        .to_string();
218
219    let format_long_name = format
220        .get("format_long_name")
221        .and_then(|v| v.as_str())
222        .unwrap_or("")
223        .to_string();
224
225    let file_size_bytes = format
226        .get("size")
227        .and_then(|v| v.as_str())
228        .and_then(|s| s.parse::<u64>().ok())
229        .unwrap_or(0);
230
231    let bit_rate = format
232        .get("bit_rate")
233        .and_then(|v| v.as_str())
234        .and_then(|s| s.parse::<u64>().ok())
235        .unwrap_or(0);
236
237    let streams = json
238        .get("streams")
239        .and_then(|v| v.as_array())
240        .map(|arr| arr.iter().map(parse_stream_info).collect())
241        .unwrap_or_default();
242
243    Ok(MediaProbeInfo {
244        duration_seconds,
245        format_name,
246        format_long_name,
247        file_size_bytes,
248        bit_rate,
249        streams,
250    })
251}
252
253fn parse_stream_info(s: &serde_json::Value) -> StreamInfo {
254    let index = s.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
255
256    let codec_type = s
257        .get("codec_type")
258        .and_then(|v| v.as_str())
259        .unwrap_or("")
260        .to_string();
261
262    let codec_name = s
263        .get("codec_name")
264        .and_then(|v| v.as_str())
265        .unwrap_or("")
266        .to_string();
267
268    let codec_long_name = s
269        .get("codec_long_name")
270        .and_then(|v| v.as_str())
271        .unwrap_or("")
272        .to_string();
273
274    let width = s.get("width").and_then(|v| v.as_u64()).map(|v| v as u32);
275    let height = s.get("height").and_then(|v| v.as_u64()).map(|v| v as u32);
276
277    let fps = s
278        .get("r_frame_rate")
279        .and_then(|v| v.as_str())
280        .and_then(parse_frame_rate);
281
282    let bit_rate = s
283        .get("bit_rate")
284        .and_then(|v| v.as_str())
285        .and_then(|s| s.parse::<u64>().ok());
286
287    let sample_rate = s
288        .get("sample_rate")
289        .and_then(|v| v.as_str())
290        .and_then(|s| s.parse::<u32>().ok());
291
292    let channels = s.get("channels").and_then(|v| v.as_u64()).map(|v| v as u32);
293
294    let duration_seconds = s
295        .get("duration")
296        .and_then(|v| v.as_str())
297        .and_then(|s| s.parse::<f64>().ok());
298
299    StreamInfo {
300        index,
301        codec_type,
302        codec_name,
303        codec_long_name,
304        width,
305        height,
306        fps,
307        bit_rate,
308        sample_rate,
309        channels,
310        duration_seconds,
311    }
312}
313
314fn parse_frame_rate(s: &str) -> Option<f64> {
315    let parts: Vec<&str> = s.split('/').collect();
316    if parts.len() == 2 {
317        let num = parts[0].parse::<f64>().ok()?;
318        let den = parts[1].parse::<f64>().ok()?;
319        if den > 0.0 {
320            return Some(num / den);
321        }
322    }
323    s.parse::<f64>().ok()
324}
325
326pub async fn get_duration_us(path: &Path) -> anyhow::Result<u64> {
327    let info = probe(path).await?;
328    Ok((info.duration_seconds * 1_000_000.0) as u64)
329}
330
331pub async fn convert(
332    opts: &ConversionOptions,
333    cancel_token: CancellationToken,
334    progress_tx: mpsc::Sender<f64>,
335) -> anyhow::Result<ConversionResult> {
336    let input_path = Path::new(&opts.input_path);
337    let output_path = Path::new(&opts.output_path);
338
339    if let Some(parent) = output_path.parent() {
340        tokio::fs::create_dir_all(parent).await?;
341    }
342
343    let total_duration_us = get_duration_us(input_path).await.unwrap_or(0);
344
345    let args = opts.build_ffmpeg_args();
346
347    let mut child = crate::core::process::command("ffmpeg")
348        .args(&args)
349        .stdout(std::process::Stdio::piped())
350        .stderr(std::process::Stdio::piped())
351        .spawn()
352        .map_err(|e| anyhow!("Failed to start ffmpeg: {}", e))?;
353
354    let stdout = child
355        .stdout
356        .take()
357        .ok_or_else(|| anyhow!("No stdout from ffmpeg"))?;
358    let reader = BufReader::new(stdout);
359    let mut lines = reader.lines();
360
361    let cancel = cancel_token.clone();
362    let progress = progress_tx.clone();
363    let line_reader = tokio::spawn(async move {
364        while let Ok(Some(line)) = lines.next_line().await {
365            if cancel.is_cancelled() {
366                break;
367            }
368            if let Some(us) = parse_out_time_us(&line) {
369                if total_duration_us > 0 {
370                    let pct = (us as f64 / total_duration_us as f64 * 100.0).min(100.0);
371                    let _ = progress.send(pct).await;
372                }
373            }
374        }
375    });
376
377    let result = tokio::select! {
378        status = child.wait() => {
379            let _ = line_reader.await;
380            status.map_err(|e| anyhow!("ffmpeg process failed: {}", e))
381        }
382        _ = cancel_token.cancelled() => {
383            let _ = child.kill().await;
384            let _ = line_reader.await;
385            return Ok(ConversionResult {
386                success: false,
387                output_path: opts.output_path.clone(),
388                file_size_bytes: 0,
389                duration_seconds: 0.0,
390                error: Some("Conversion cancelled".to_string()),
391            });
392        }
393    };
394
395    match result {
396        Ok(status) if status.success() => {
397            let _ = progress_tx.send(100.0).await;
398            let meta = std::fs::metadata(output_path);
399            let file_size = meta.map(|m| m.len()).unwrap_or(0);
400
401            let duration = probe(output_path)
402                .await
403                .map(|i| i.duration_seconds)
404                .unwrap_or(0.0);
405
406            Ok(ConversionResult {
407                success: true,
408                output_path: opts.output_path.clone(),
409                file_size_bytes: file_size,
410                duration_seconds: duration,
411                error: None,
412            })
413        }
414        Ok(status) => Ok(ConversionResult {
415            success: false,
416            output_path: opts.output_path.clone(),
417            file_size_bytes: 0,
418            duration_seconds: 0.0,
419            error: Some(format!("ffmpeg exited with code {}", status)),
420        }),
421        Err(e) => Ok(ConversionResult {
422            success: false,
423            output_path: opts.output_path.clone(),
424            file_size_bytes: 0,
425            duration_seconds: 0.0,
426            error: Some(e.to_string()),
427        }),
428    }
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize, Default)]
432pub struct MetadataEmbed {
433    pub title: Option<String>,
434    pub artist: Option<String>,
435    pub album: Option<String>,
436    pub track_number: Option<String>,
437    pub genre: Option<String>,
438    pub year: Option<String>,
439    pub comment: Option<String>,
440    pub thumbnail_url: Option<String>,
441}
442
443pub fn build_metadata_args(
444    file: &Path,
445    thumbnail_path: Option<&Path>,
446    metadata: &MetadataEmbed,
447    temp_output: &Path,
448) -> Vec<String> {
449    let mut args: Vec<String> = vec![
450        "-y".to_string(),
451        "-i".to_string(),
452        file.to_string_lossy().to_string(),
453    ];
454
455    if let Some(thumb) = thumbnail_path {
456        args.extend(["-i".to_string(), thumb.to_string_lossy().to_string()]);
457    }
458
459    if thumbnail_path.is_some() {
460        args.extend([
461            "-map".to_string(),
462            "0:a".to_string(),
463            "-map".to_string(),
464            "1:v".to_string(),
465            "-c".to_string(),
466            "copy".to_string(),
467            "-disposition:v:0".to_string(),
468            "attached_pic".to_string(),
469        ]);
470    } else {
471        args.extend(["-c".to_string(), "copy".to_string()]);
472    }
473
474    if let Some(ref v) = metadata.title {
475        args.extend(["-metadata".to_string(), format!("title={}", v)]);
476    }
477    if let Some(ref v) = metadata.artist {
478        args.extend(["-metadata".to_string(), format!("artist={}", v)]);
479    }
480    if let Some(ref v) = metadata.album {
481        args.extend(["-metadata".to_string(), format!("album={}", v)]);
482    }
483    if let Some(ref v) = metadata.track_number {
484        args.extend(["-metadata".to_string(), format!("track={}", v)]);
485    }
486    if let Some(ref v) = metadata.genre {
487        args.extend(["-metadata".to_string(), format!("genre={}", v)]);
488    }
489    if let Some(ref v) = metadata.year {
490        args.extend(["-metadata".to_string(), format!("date={}", v)]);
491    }
492    if let Some(ref v) = metadata.comment {
493        args.extend(["-metadata".to_string(), format!("comment={}", v)]);
494    }
495
496    args.push(temp_output.to_string_lossy().to_string());
497
498    args
499}
500
501pub async fn embed_metadata(
502    file: &Path,
503    metadata: &MetadataEmbed,
504    embed_thumbnail: bool,
505    http_client: &reqwest::Client,
506) -> anyhow::Result<()> {
507    if !is_ffmpeg_available().await {
508        return Err(anyhow!("ffmpeg not available"));
509    }
510
511    let temp_dir = file.parent().unwrap_or(Path::new("."));
512    let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("mp4");
513    let temp_output = temp_dir.join(format!(".mangofetch_meta_{}.{}", uuid::Uuid::new_v4(), ext));
514
515    let is_audio_only = matches!(
516        ext.to_lowercase().as_str(),
517        "mp3" | "m4a" | "aac" | "ogg" | "opus" | "flac" | "wav" | "wma"
518    );
519
520    let thumbnail_path = if embed_thumbnail && is_audio_only {
521        if let Some(ref url) = metadata.thumbnail_url {
522            match download_thumbnail(http_client, url, temp_dir).await {
523                Ok(p) => Some(p),
524                Err(e) => {
525                    tracing::warn!("Failed to download thumbnail: {}", e);
526                    None
527                }
528            }
529        } else {
530            None
531        }
532    } else {
533        None
534    };
535
536    let args = build_metadata_args(file, thumbnail_path.as_deref(), metadata, &temp_output);
537
538    let output = crate::core::process::command("ffmpeg")
539        .args(&args)
540        .stdout(std::process::Stdio::piped())
541        .stderr(std::process::Stdio::piped())
542        .output()
543        .await
544        .map_err(|e| anyhow!("Failed to run ffmpeg: {}", e))?;
545
546    if let Some(ref thumb) = thumbnail_path {
547        let _ = std::fs::remove_file(thumb);
548    }
549
550    if !output.status.success() {
551        let _ = std::fs::remove_file(&temp_output);
552        let stderr = String::from_utf8_lossy(&output.stderr);
553        return Err(anyhow!("ffmpeg metadata failed: {}", stderr));
554    }
555
556    let mut rename_ok = false;
557    for attempt in 0..3 {
558        match std::fs::rename(&temp_output, file) {
559            Ok(()) => {
560                rename_ok = true;
561                break;
562            }
563            Err(e) if attempt < 2 => {
564                tracing::warn!(
565                    "Failed to replace file (attempt {}): {}, retrying...",
566                    attempt + 1,
567                    e
568                );
569                tokio::time::sleep(std::time::Duration::from_millis(500 * (attempt as u64 + 1)))
570                    .await;
571            }
572            Err(e) => {
573                let _ = std::fs::remove_file(&temp_output);
574                return Err(anyhow!("Failed to replace file after 3 attempts: {}", e));
575            }
576        }
577    }
578    if !rename_ok {
579        let _ = std::fs::remove_file(&temp_output);
580        return Err(anyhow!("Failed to replace file"));
581    }
582
583    Ok(())
584}
585
586async fn download_thumbnail(
587    client: &reqwest::Client,
588    url: &str,
589    dest_dir: &Path,
590) -> anyhow::Result<std::path::PathBuf> {
591    let response = client
592        .get(url)
593        .send()
594        .await
595        .map_err(|e| anyhow!("Failed to download thumbnail: {}", e))?;
596
597    let content_type = response
598        .headers()
599        .get("content-type")
600        .and_then(|v| v.to_str().ok())
601        .unwrap_or("image/jpeg")
602        .to_string();
603
604    let bytes = response
605        .bytes()
606        .await
607        .map_err(|e| anyhow!("Failed to read thumbnail: {}", e))?;
608
609    let ext = if content_type.contains("png") {
610        "png"
611    } else {
612        "jpg"
613    };
614
615    let thumb_path = dest_dir.join(format!(
616        ".mangofetch_thumb_{}.{}",
617        uuid::Uuid::new_v4(),
618        ext
619    ));
620    std::fs::write(&thumb_path, &bytes)?;
621
622    if ext == "png" {
623        let jpg_path = dest_dir.join(format!(".mangofetch_thumb_{}.jpg", uuid::Uuid::new_v4()));
624        let convert_result = crate::core::process::command("ffmpeg")
625            .args([
626                "-y",
627                "-i",
628                &thumb_path.to_string_lossy(),
629                &jpg_path.to_string_lossy(),
630            ])
631            .stdout(std::process::Stdio::null())
632            .stderr(std::process::Stdio::null())
633            .status()
634            .await;
635
636        let _ = std::fs::remove_file(&thumb_path);
637
638        if let Ok(status) = convert_result {
639            if status.success() {
640                return Ok(jpg_path);
641            }
642        }
643        let _ = std::fs::remove_file(&jpg_path);
644        return Err(anyhow!("Failed to convert thumbnail to JPEG"));
645    }
646
647    Ok(thumb_path)
648}
649
650fn parse_out_time_us(line: &str) -> Option<u64> {
651    let line = line.trim();
652    if let Some(val) = line.strip_prefix("out_time_us=") {
653        return val.trim().parse::<u64>().ok();
654    }
655    if let Some(val) = line.strip_prefix("out_time_ms=") {
656        return val.trim().parse::<u64>().ok().map(|ms| ms * 1000);
657    }
658    None
659}