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