Files
ggjuku/internal/app/ffmpeg_install.go
T
2026-09-30 10:20:01 +08:00

407 lines
14 KiB
Go
Raw 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 app
import (
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
)
const ffmpegReleaseURL = "https://github.com/eugeneware/ffmpeg-static/releases/download/b6.1.1/"
type ffmpegPackage struct {
platform string
archiveSize int64
binarySize int64
archiveSHA256 string
binarySHA256 string
licenseSHA256 string
readmeSHA256 string
}
var ffmpegPackages = map[string]ffmpegPackage{
"darwin/amd64": {"darwin-x64", 25296431, 78862176,
"929b375c1182d956c51f7ac25e0b2b0411fb01f6f407aa15c9758efeb4242106", "ebdddc936f61e14049a2d4b549a412b8a40deeff6540e58a9f2a2da9e6b18894",
"2e1d16c72fd74e12063776371da757322f8b77589386532f4fd8634bde7de1af", "e88a0325f8e5b75210355e37341824f074d3cd82def2125be54c914b62848a36"},
"darwin/arm64": {"darwin-arm64", 19246198, 45568216,
"8923876afa8db5585022d7860ec7e589af192f441c56793971276d450ed3bbfa", "a90e3db6a3fd35f6074b013f948b1aa45b31c6375489d39e572bea3f18336584",
"cb48bf09a11f5fb576cddb0431c8f5ed0a60157a9ec942adffc13907cbe083f2", "05ba4b92c96605434b1aaae3eedf5a2c280c9607bf78ffca9a5b536d9af2dc6a"},
"linux/amd64": {"linux-x64", 29354986, 79826272,
"bfe8a8fc511530457b528c48d77b5737527b504a3797a9bc4866aeca69c2dffa", "e7e7fb30477f717e6f55f9180a70386c62677ef8a4d4d1a5d948f4098aa3eb99",
"8ceb4b9ee5adedde47b31e975c1d90c73ad27b6b165a1dcd80c7c545eb65b903", "72f4b1b06d419d22ace6e7cc75f06826f90737345aa0b1736158929f4aacc537"},
"windows/amd64": {"win32-x64", 29581307, 82797568,
"8883a3dffbd0a16cf4ef95206ea05283f78908dbfb118f73c83f4951dcc06d77", "04e1307997530f9cf2fe35cba2ca7e8875ca91da02f89d6c7243df819c94ad00",
"8ceb4b9ee5adedde47b31e975c1d90c73ad27b6b165a1dcd80c7c545eb65b903", "a636a7183c58006351acbaf35303c0ed85c6e1320fd4e80de453ba6157de6311"},
}
type ffmpegInstallState struct {
Status string `json:"status"`
Detail string `json:"detail,omitempty"`
Path string `json:"path,omitempty"`
DownloadedBytes int64 `json:"downloadedBytes"`
TotalBytes int64 `json:"totalBytes"`
Error string `json:"error,omitempty"`
}
type ffmpegInstaller struct {
mu sync.Mutex
configured string
directory string
name string
client *http.Client
pack ffmpegPackage
state ffmpegInstallState
record func(diagnosticEvent)
done chan struct{}
cancel context.CancelFunc
lastError error
lastTry time.Time
}
func (downloader *Downloader) ffmpegInstallation() *ffmpegInstaller {
downloader.ffmpegMu.Lock()
defer downloader.ffmpegMu.Unlock()
if downloader.ffmpegInstaller == nil {
directory, name := ffmpegManagedDirectory(), ffmpegExecutableName()
client := *downloader.client
client.Timeout = 0
client.CheckRedirect = func(request *http.Request, via []*http.Request) error {
if request.URL.Scheme != "https" || len(via) >= 10 {
return errors.New("FFmpeg 下载重定向不安全或次数过多")
}
return nil
}
installer := &ffmpegInstaller{
configured: downloader.cfg.FFmpeg, directory: directory, name: name, client: &client,
pack: ffmpegPackages[runtime.GOOS+"/"+runtime.GOARCH], state: ffmpegInstallState{Status: "idle"}, record: downloader.recordDiagnostic,
}
downloader.ffmpegInstaller = installer
}
return downloader.ffmpegInstaller
}
func (downloader *Downloader) ensureFFmpeg(ctx context.Context) (string, error) {
return downloader.ffmpegInstallation().ensure(ctx)
}
func (installer *ffmpegInstaller) snapshot() ffmpegInstallState {
installer.mu.Lock()
defer installer.mu.Unlock()
return installer.state
}
func (installer *ffmpegInstaller) update(status, detail string, downloaded, total int64) {
installer.mu.Lock()
installer.state.Status, installer.state.Detail = status, detail
installer.state.DownloadedBytes, installer.state.TotalBytes = downloaded, total
installer.mu.Unlock()
}
func (installer *ffmpegInstaller) ensure(ctx context.Context) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
installer.mu.Lock()
if installer.state.Status == "ready" {
if path, err := exec.LookPath(installer.state.Path); err == nil {
installer.mu.Unlock()
return path, nil
}
installer.state.Status = "idle"
}
if installer.done == nil {
if installer.lastError != nil && time.Since(installer.lastTry) < 30*time.Second {
err := installer.lastError
installer.mu.Unlock()
return "", err
}
installer.done = make(chan struct{})
installer.lastTry = time.Now()
installer.state = ffmpegInstallState{Status: "verifying", Detail: "正在检查 FFmpeg 的播放能力"}
installCtx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
installer.cancel = cancel
go installer.run(installCtx)
}
done := installer.done
installer.mu.Unlock()
select {
case <-ctx.Done():
return "", ctx.Err()
case <-done:
installer.mu.Lock()
defer installer.mu.Unlock()
return installer.state.Path, installer.lastError
}
}
func (installer *ffmpegInstaller) run(ctx context.Context) {
path, err := installer.prepare(ctx)
err = publicError(err)
event := diagnosticEvent{Level: "info", Event: "ffmpeg.ready", Message: "FFmpeg 已就绪:" + path}
if err != nil {
event = diagnosticEvent{Event: "ffmpeg.failed", Message: err.Error()}
}
if installer.record != nil {
installer.record(event)
}
installer.mu.Lock()
defer installer.mu.Unlock()
installer.cancel()
installer.cancel = nil
installer.lastError = err
if err != nil {
installer.state.Status = "failed"
installer.state.Error = err.Error()
installer.state.Detail = "FFmpeg 准备失败,请检查路径、完整版本或代理"
} else {
installer.state = ffmpegInstallState{Status: "ready", Path: path, Detail: event.Message}
}
close(installer.done)
installer.done = nil
}
func (installer *ffmpegInstaller) retry() {
installer.mu.Lock()
if installer.done == nil {
installer.lastError = nil
installer.state = ffmpegInstallState{Status: "idle"}
}
installer.mu.Unlock()
}
func (installer *ffmpegInstaller) stop() {
installer.mu.Lock()
defer installer.mu.Unlock()
if installer.cancel != nil {
installer.cancel()
}
}
func (installer *ffmpegInstaller) fetch(ctx context.Context, name, target, checksum string, limit int64, progress bool) error {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, ffmpegReleaseURL+name, nil)
if err != nil {
return err
}
request.Header.Set("User-Agent", "juku")
request.Header.Set("Accept-Encoding", "identity")
response, err := installer.client.Do(request)
if err != nil {
return publicError(err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("FFmpeg 下载失败:HTTP %d,请检查代理设置后重试", response.StatusCode)
}
if response.ContentLength > limit {
return errors.New("FFmpeg 下载文件超过预期大小")
}
file, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return err
}
defer file.Close()
digest := sha256.New()
reader := io.TeeReader(io.LimitReader(response.Body, limit+1), digest)
var downloaded int64
buffer := make([]byte, 128*1024)
lastReport := time.Time{}
for {
count, readErr := reader.Read(buffer)
if count > 0 {
downloaded += int64(count)
if downloaded > limit {
return errors.New("FFmpeg 下载文件超过预期大小")
}
if _, err := file.Write(buffer[:count]); err != nil {
return err
}
if progress && time.Since(lastReport) >= 200*time.Millisecond {
installer.update("downloading", "正在自动下载 FFmpeg", downloaded, installer.pack.archiveSize)
lastReport = time.Now()
}
}
if readErr != nil {
if readErr != io.EOF {
return readErr
}
break
}
}
if hex.EncodeToString(digest.Sum(nil)) != checksum {
return errors.New("FFmpeg 下载文件 SHA-256 校验失败,未安装或执行该文件")
}
return file.Close()
}
func unpackFFmpeg(archive, output string, pack ffmpegPackage) error {
input, err := os.Open(archive)
if err != nil {
return err
}
defer input.Close()
reader, err := gzip.NewReader(input)
if err != nil {
return err
}
defer reader.Close()
file, err := os.OpenFile(output, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return err
}
defer file.Close()
digest := sha256.New()
size, err := io.Copy(io.MultiWriter(file, digest), io.LimitReader(reader, pack.binarySize+1))
if err != nil {
return err
}
if size != pack.binarySize || hex.EncodeToString(digest.Sum(nil)) != pack.binarySHA256 {
return errors.New("FFmpeg 可执行文件校验失败,未安装或执行该文件")
}
if err := file.Close(); err != nil {
return err
}
return os.Chmod(output, 0755)
}
var configureFFmpegProbeProcess = func(command *exec.Cmd) {}
func validateFFmpeg(ctx context.Context, path string) error {
checkCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
command := exec.CommandContext(checkCtx, path, "-hide_banner", "-encoders")
output := &cappedStringWriter{limit: 256 * 1024}
command.Stdout, command.Stderr = output, output
command.WaitDelay = time.Second
configureFFmpegProbeProcess(command)
if err := command.Run(); err != nil {
if checkCtx.Err() != nil {
return fmt.Errorf("FFmpeg 能力检查未完成:%w", checkCtx.Err())
}
return fmt.Errorf("FFmpeg 无法启动:%w", err)
}
var video, audio bool
for _, line := range strings.Split(output.String(), "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 {
video = video || fields[1] == "libx264"
audio = audio || fields[1] == "aac"
}
}
if !video || !audio {
return errors.New("FFmpeg 缺少 libx264 或 AAC 编码器,需要完整版本")
}
command = exec.CommandContext(checkCtx, path,
"-hide_banner", "-loglevel", "error", "-nostdin", "-filter_threads", "1", "-filter_complex_threads", "1",
"-f", "lavfi", "-i", "color=c=black:s=16x16:r=30:d=0.1",
"-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=48000",
"-t", "0.1", "-map", "0:v:0", "-map", "1:a:0",
"-vf", "fps=30,scale=trunc(iw/2)*2:trunc(ih/2)*2,setsar=1",
"-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency", "-profile:v", "baseline",
"-pix_fmt", "yuv420p", "-crf", "23", "-threads", "1",
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2",
"-movflags", "+frag_keyframe+empty_moov+default_base_moof", "-f", "mp4", "pipe:1")
output = &cappedStringWriter{limit: 16 * 1024}
command.Stdout, command.Stderr = io.Discard, output
command.WaitDelay = time.Second
configureFFmpegProbeProcess(command)
if err := command.Run(); err != nil {
if checkCtx.Err() != nil {
return fmt.Errorf("FFmpeg 播放能力检查未完成:%w", checkCtx.Err())
}
return fmt.Errorf("FFmpeg 无法完成 H.264/AAC 播放转码(需要 preset 等编码选项):%w %s", err, truncate(strings.TrimSpace(output.String()), 1500))
}
return nil
}
func (installer *ffmpegInstaller) prepare(ctx context.Context) (string, error) {
candidates := ffmpegCandidates(installer.configured, installer.directory)
for _, path := range candidates {
installer.update("verifying", "正在检查 FFmpeg:"+path, 0, 0)
err := validateFFmpeg(ctx, path)
if err == nil {
return path, nil
}
if ctx.Err() != nil {
return "", ctx.Err()
}
if !automaticFFmpeg(installer.configured) {
return "", fmt.Errorf("指定的 FFmpeg 不可用(%s):%w;请更正 download.ffmpeg / -ffmpeg,或设为 ffmpeg 使用自动查找", path, err)
}
if installer.record != nil {
installer.record(diagnosticEvent{Level: "warn", Event: "ffmpeg.rejected", Message: "跳过不可用 FFmpeg:" + path + ":" + err.Error()})
}
}
if !automaticFFmpeg(installer.configured) {
return "", fmt.Errorf("找不到指定的 FFmpeg(%s);请更正 download.ffmpeg / -ffmpeg,或设为 ffmpeg 使用自动查找", normalizedFFmpegSetting(installer.configured))
}
detail := "未找到可用 FFmpeg,正在自动准备"
if len(candidates) > 0 {
detail = "现有 FFmpeg 未通过播放检查,正在自动准备兼容版本"
}
installer.update("downloading", detail, 0, installer.pack.archiveSize)
return installer.install(ctx)
}
func (installer *ffmpegInstaller) install(ctx context.Context) (string, error) {
if !automaticFFmpeg(installer.configured) {
return "", errors.New("指定的 FFmpeg 不可用,请更正 -ffmpeg 路径,或恢复默认 ffmpeg 以启用自动下载")
}
if installer.pack.platform == "" {
return "", fmt.Errorf("暂不支持自动下载 %s/%s 的 FFmpeg,请将对应可执行文件放入 bin 目录", runtime.GOOS, runtime.GOARCH)
}
if err := os.MkdirAll(filepath.Dir(installer.directory), 0755); err != nil {
return "", fmt.Errorf("无法准备 FFmpeg 目录,请将程序移到可写目录:%w", err)
}
work, err := os.MkdirTemp(filepath.Dir(installer.directory), ".ffmpeg-install-*")
if err != nil {
return "", err
}
defer os.RemoveAll(work)
archive := filepath.Join(work, "download.gz")
pack := installer.pack
if err := installer.fetch(ctx, "ffmpeg-"+pack.platform+".gz", archive, pack.archiveSHA256, pack.archiveSize, true); err != nil {
return "", err
}
installer.update("verifying", "正在校验、解压 FFmpeg", pack.archiveSize, pack.archiveSize)
binary := filepath.Join(work, installer.name)
if err := unpackFFmpeg(archive, binary, pack); err != nil {
return "", err
}
if err := os.Remove(archive); err != nil {
return "", err
}
for _, document := range []struct{ remote, local, checksum string }{
{pack.platform + ".LICENSE", "LICENSE.txt", pack.licenseSHA256},
{pack.platform + ".README", "README.txt", pack.readmeSHA256},
} {
if err := installer.fetch(ctx, document.remote, filepath.Join(work, document.local), document.checksum, 256*1024, false); err != nil {
return "", fmt.Errorf("下载 FFmpeg 许可与构建说明失败:%w", err)
}
}
if err := validateFFmpeg(ctx, binary); err != nil {
return "", err
}
if err := ctx.Err(); err != nil {
return "", err
}
if err := os.Rename(work, installer.directory); err != nil {
installed := filepath.Join(installer.directory, installer.name)
if validationErr := validateFFmpeg(ctx, installed); validationErr == nil {
return installed, nil
}
return "", fmt.Errorf("保存 FFmpeg 失败,请检查 bin 目录权限或已有文件:%w", err)
}
return filepath.Join(installer.directory, installer.name), nil
}