biliget 0.6.9

简单的B站视频下载工具 支持免登录下载B站高清视频
use crate::concat_arrays;
use crate::processer::ffmpeg::FfmpegError::FileNameError;
use crate::processer::progress_bar::FfmpegProgress;
use crate::progress::bar::Bar;
use std::path::Path;
use std::process::Stdio;
use thiserror::Error;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio_util::sync::CancellationToken;

const FFMPEG_GLOBAL_ARGS: [&str; 4] = ["-hide_banner", "-progress", "pipe:1", "-nostats"];

#[derive(Debug, Error)]
pub(crate) enum FfmpegError {
    #[error("找不到相关文件")]
    FileNotFound(),

    #[error("文件名错误")]
    FileNameError(),

    #[error("合并音视频失败: {0}")]
    MergeError(String),

    #[error("格式转换失败: {0}")]
    ConvertError(String),

    #[error("操作已取消")]
    Cancelled,
}

async fn get_media_duration_us(path: &Path) -> Result<u64, FfmpegError> {
    let output = Command::new("ffprobe")
        .args([
            "-v",
            "error",
            "-show_entries",
            "format=duration",
            "-of",
            "default=noprint_wrappers=1:nokey=1",
            path.to_str().ok_or(FfmpegError::FileNameError())?,
        ])
        .output()
        .await
        .map_err(|e| FfmpegError::ConvertError(format!("无法启动 ffprobe: {}", e)))?;

    if !output.status.success() {
        return Ok(0);
    }

    let duration_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
    let duration_secs: f64 = duration_str.parse().unwrap_or(0.0);

    Ok((duration_secs * 1_000_000.0) as u64)
}

async fn handle_ffmpeg_progress(
    mut child: tokio::process::Child,
    mut progress_bar: impl Bar,
    cancel: CancellationToken,
    err_map: impl Fn(String) -> FfmpegError,
) -> Result<(), FfmpegError> {
    let stdout = child
        .stdout
        .take()
        .ok_or_else(|| err_map("无法获取 FFmpeg stdout".to_string()))?;

    let mut reader = BufReader::new(stdout).lines();
    let mut progress_lines = Vec::with_capacity(12);

    let status = tokio::select! {
        res = async {
            while let Ok(Some(line)) = reader.next_line().await {
                progress_lines.push(line);

                if progress_lines.last().map(|s| s.starts_with("progress=")).unwrap_or(false) {
                    let lines_ref: Vec<&str> = progress_lines.iter().map(|s| s.as_str()).collect();
                    let prog = FfmpegProgress::from_lines(&lines_ref);
                    progress_bar.set_progress(prog.out_time_us).await;
                    progress_lines.clear();
                }
            }
            let wait_res = child.wait().await;
            progress_bar.finish().await;
            wait_res
        } => res,

        () = cancel.cancelled() => {
            let _ = child.kill().await;
            progress_bar.finish().await;
            return Err(FfmpegError::Cancelled);
        }
    };

    match status {
        Ok(s) if s.success() => Ok(()),
        Ok(s) => Err(err_map(format!("Exit code: {}", s.code().unwrap_or(-1)))),
        Err(e) => Err(err_map(e.to_string())),
    }
}

pub(crate) async fn merge_video(
    video_file: &Path,
    audio_file: &Path,
    output_file: &Path,
    mut progress_bar: impl Bar,
    cancel: CancellationToken,
) -> Result<(), FfmpegError> {
    if !video_file.exists() || !audio_file.exists() {
        return Err(FfmpegError::FileNotFound());
    }

    let duration = get_media_duration_us(audio_file).await?;
    progress_bar.set_length(duration).await;

    let child = Command::new("ffmpeg")
        .args(concat_arrays!(
            FFMPEG_GLOBAL_ARGS,
            [
                "-i",
                video_file.to_str().ok_or_else(FileNameError)?,
                "-i",
                audio_file.to_str().ok_or_else(FileNameError)?,
                "-c:v",
                "copy",
                "-c:a",
                "aac",
                "-y",
                output_file.to_str().ok_or_else(FileNameError)?,
            ]
        ))
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .kill_on_drop(true)
        .spawn()
        .map_err(|e| FfmpegError::MergeError(format!("启动失败: {}", e)))?;

    handle_ffmpeg_progress(child, progress_bar, cancel, FfmpegError::MergeError).await
}

pub(crate) async fn convert_audio(
    audio_file: &Path,
    output_file: &Path,
    mut progress_bar: impl Bar,
    cancel: CancellationToken,
) -> Result<(), FfmpegError> {
    if !audio_file.exists() {
        return Err(FfmpegError::FileNotFound());
    }

    let duration = get_media_duration_us(audio_file).await?;
    progress_bar.set_length(duration).await;

    let child = Command::new("ffmpeg")
        .args(concat_arrays!(
            FFMPEG_GLOBAL_ARGS,
            [
                "-i",
                audio_file.to_str().ok_or_else(FileNameError)?,
                "-vn",
                "-ar",
                "44100",
                "-ac",
                "2",
                "-y",
                output_file.to_str().ok_or_else(FileNameError)?,
            ]
        ))
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .kill_on_drop(true)
        .spawn()
        .map_err(|e| FfmpegError::ConvertError(format!("启动 ffmpeg 失败: {}", e)))?;

    handle_ffmpeg_progress(child, progress_bar, cancel, FfmpegError::ConvertError).await
}