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
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 tokio::fs::create_dir_all(parent).await?;
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}