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}