Files
nl-im-service/internal/mediaserver/ffmpeg_installer.go

365 lines
8.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* package mediaserver
*
* FFmpeg 自动检测与安装模块
* 在服务启动时检测 FFmpeg 是否可用,如不可用则自动下载安装
*/
package mediaserver
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
)
// FFmpeg 下载地址配置
const (
// Linux 静态编译版本 (来自 johnvansickle.com)
FFmpegLinuxURL = "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz"
// Windows 版本 (来自 gyan.dev)
FFmpegWindowsURL = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip"
// 默认安装目录
DefaultInstallDir = "/ffmpegs"
)
var (
ffmpegPath string
ffmpegInitOnce sync.Once
ffmpegInitErr error
)
// EnsureFFmpeg 确保 FFmpeg 可用,返回可执行文件路径
// 检测顺序:
// 1. 系统 PATH 中的 ffmpeg
// 2. 自定义安装目录 /ffmpegs/ffmpeg
// 3. 如果都不存在,自动下载安装
func EnsureFFmpeg() (string, error) {
ffmpegInitOnce.Do(func() {
ffmpegPath, ffmpegInitErr = initFFmpeg()
})
return ffmpegPath, ffmpegInitErr
}
// initFFmpeg 初始化 FFmpeg
func initFFmpeg() (string, error) {
// 1. 检查系统 PATH 中的 ffmpeg
if path, err := exec.LookPath("ffmpeg"); err == nil {
log.Printf("✅ [FFmpeg] 在系统 PATH 中找到: %s", path)
// 验证版本
if err := verifyFFmpeg(path); err == nil {
return path, nil
}
log.Printf("⚠️ [FFmpeg] 系统 ffmpeg 验证失败,尝试其他路径")
}
// 2. 检查自定义安装目录
customPath := getCustomFFmpegPath()
if _, err := os.Stat(customPath); err == nil {
log.Printf("✅ [FFmpeg] 在自定义目录找到: %s", customPath)
if err := verifyFFmpeg(customPath); err == nil {
return customPath, nil
}
log.Printf("⚠️ [FFmpeg] 自定义 ffmpeg 验证失败,尝试重新下载")
}
// 3. 下载安装
log.Printf("📥 [FFmpeg] 未找到可用的 ffmpeg开始下载安装...")
return downloadAndInstallFFmpeg(DefaultInstallDir)
}
// getCustomFFmpegPath 获取自定义安装路径
func getCustomFFmpegPath() string {
if runtime.GOOS == "windows" {
return filepath.Join(DefaultInstallDir, "ffmpeg.exe")
}
return filepath.Join(DefaultInstallDir, "ffmpeg")
}
// verifyFFmpeg 验证 FFmpeg 是否可用
func verifyFFmpeg(path string) error {
cmd := exec.Command(path, "-version")
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("执行 ffmpeg -version 失败: %w", err)
}
// 检查输出是否包含版本信息
outputStr := string(output)
if !strings.Contains(outputStr, "ffmpeg version") {
return fmt.Errorf("ffmpeg 输出异常: %s", outputStr[:min(len(outputStr), 100)])
}
log.Printf("📋 [FFmpeg] 版本信息: %s", strings.Split(outputStr, "\n")[0])
return nil
}
// downloadAndInstallFFmpeg 下载并安装 FFmpeg
func downloadAndInstallFFmpeg(installDir string) (string, error) {
// 创建安装目录
if err := os.MkdirAll(installDir, 0755); err != nil {
return "", fmt.Errorf("创建安装目录失败: %w", err)
}
var downloadURL string
var extractFunc func(string, string) (string, error)
switch runtime.GOOS {
case "linux":
downloadURL = FFmpegLinuxURL
extractFunc = extractTarXz
case "windows":
downloadURL = FFmpegWindowsURL
extractFunc = extractZip
default:
return "", fmt.Errorf("不支持的操作系统: %s", runtime.GOOS)
}
// 下载文件
log.Printf("📥 [FFmpeg] 正在从 %s 下载...", downloadURL)
tmpFile, err := downloadFile(downloadURL)
if err != nil {
return "", fmt.Errorf("下载失败: %w", err)
}
defer os.Remove(tmpFile)
// 解压并安装
log.Printf("📦 [FFmpeg] 正在解压安装到 %s ...", installDir)
ffmpegBinary, err := extractFunc(tmpFile, installDir)
if err != nil {
return "", fmt.Errorf("解压失败: %w", err)
}
// 验证安装
if err := verifyFFmpeg(ffmpegBinary); err != nil {
return "", fmt.Errorf("安装验证失败: %w", err)
}
log.Printf("✅ [FFmpeg] 安装成功: %s", ffmpegBinary)
return ffmpegBinary, nil
}
// downloadFile 下载文件到临时目录
func downloadFile(url string) (string, error) {
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP 状态码: %d", resp.StatusCode)
}
// 创建临时文件
tmpFile, err := os.CreateTemp("", "ffmpeg-download-*")
if err != nil {
return "", err
}
defer tmpFile.Close()
// 下载并显示进度
written, err := io.Copy(tmpFile, resp.Body)
if err != nil {
os.Remove(tmpFile.Name())
return "", err
}
log.Printf("📥 [FFmpeg] 下载完成,大小: %.2f MB", float64(written)/1024/1024)
return tmpFile.Name(), nil
}
// extractTarXz 解压 tar.xz 文件 (Linux)
func extractTarXz(archivePath, destDir string) (string, error) {
// 使用 xz 命令解压(需要系统安装 xz-utils
// 或者使用纯 Go 的 gzip 解压 (如果是 .tar.gz)
// 先尝试使用系统命令
cmd := exec.Command("tar", "-xf", archivePath, "-C", destDir)
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("tar 解压失败: %w", err)
}
// 查找解压后的 ffmpeg 二进制文件
var ffmpegBinary string
err := filepath.Walk(destDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && info.Name() == "ffmpeg" {
ffmpegBinary = path
return filepath.SkipAll
}
return nil
})
if err != nil && err != filepath.SkipAll {
return "", err
}
if ffmpegBinary == "" {
return "", fmt.Errorf("解压后未找到 ffmpeg 二进制文件")
}
// 移动到目标位置
targetPath := filepath.Join(destDir, "ffmpeg")
if ffmpegBinary != targetPath {
if err := os.Rename(ffmpegBinary, targetPath); err != nil {
// 如果 rename 失败(跨设备),使用复制
if err := copyFile(ffmpegBinary, targetPath); err != nil {
return "", err
}
}
}
// 设置执行权限
if err := os.Chmod(targetPath, 0755); err != nil {
return "", err
}
return targetPath, nil
}
// extractZip 解压 zip 文件 (Windows)
func extractZip(archivePath, destDir string) (string, error) {
r, err := zip.OpenReader(archivePath)
if err != nil {
return "", err
}
defer r.Close()
var ffmpegBinary string
for _, f := range r.File {
// 只查找 ffmpeg.exe
if strings.HasSuffix(f.Name, "ffmpeg.exe") {
rc, err := f.Open()
if err != nil {
return "", err
}
targetPath := filepath.Join(destDir, "ffmpeg.exe")
outFile, err := os.Create(targetPath)
if err != nil {
rc.Close()
return "", err
}
_, err = io.Copy(outFile, rc)
outFile.Close()
rc.Close()
if err != nil {
return "", err
}
ffmpegBinary = targetPath
break
}
}
if ffmpegBinary == "" {
return "", fmt.Errorf("zip 包中未找到 ffmpeg.exe")
}
return ffmpegBinary, nil
}
// extractTarGz 解压 tar.gz 文件
func extractTarGz(archivePath, destDir string) (string, error) {
f, err := os.Open(archivePath)
if err != nil {
return "", err
}
defer f.Close()
gzr, err := gzip.NewReader(f)
if err != nil {
return "", err
}
defer gzr.Close()
tr := tar.NewReader(gzr)
var ffmpegBinary string
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return "", err
}
// 只查找 ffmpeg 二进制文件
if strings.HasSuffix(header.Name, "/ffmpeg") || header.Name == "ffmpeg" {
targetPath := filepath.Join(destDir, "ffmpeg")
outFile, err := os.Create(targetPath)
if err != nil {
return "", err
}
_, err = io.Copy(outFile, tr)
outFile.Close()
if err != nil {
return "", err
}
// 设置执行权限
if err := os.Chmod(targetPath, 0755); err != nil {
return "", err
}
ffmpegBinary = targetPath
break
}
}
if ffmpegBinary == "" {
return "", fmt.Errorf("tar.gz 包中未找到 ffmpeg 二进制文件")
}
return ffmpegBinary, nil
}
// copyFile 复制文件
func copyFile(src, dst string) error {
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()
destFile, err := os.Create(dst)
if err != nil {
return err
}
defer destFile.Close()
_, err = io.Copy(destFile, sourceFile)
return err
}
// GetFFmpegPath 获取 FFmpeg 路径(仅在已初始化后调用)
func GetFFmpegPath() string {
return ffmpegPath
}
// IsFFmpegAvailable 检查 FFmpeg 是否可用
func IsFFmpegAvailable() bool {
path, err := EnsureFFmpeg()
return err == nil && path != ""
}