Git Repository

Mini Video Library

Mini Video Library scannt lokale Medienordner, verwaltet Metadaten und Vorschaubilder und streamt kompatible Videos direkt per HTTP an den Browser.

Projektseite ↗
HTTPShttps://zanvex.de/git/mini-video-library.git

internal/ffmpeginstall/install.go

Zum Verzeichnis
package ffmpeginstall

import (
	"archive/zip"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"time"
)

const DownloadURL = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip"

type Result struct{ FFmpeg, FFprobe string }

func Install(ctx context.Context, destination string) (Result, error) {
	if runtime.GOOS != "windows" {
		return Result{}, fmt.Errorf("automatic installation is currently only available on Windows")
	}
	if err := os.MkdirAll(destination, 0755); err != nil {
		return Result{}, err
	}
	tmp, err := os.CreateTemp(destination, "ffmpeg-*.zip")
	if err != nil {
		return Result{}, err
	}
	archive := tmp.Name()
	defer os.Remove(archive)
	defer tmp.Close()
	client := &http.Client{Timeout: 20 * time.Minute}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, DownloadURL, nil)
	if err != nil {
		return Result{}, err
	}
	resp, err := client.Do(req)
	if err != nil {
		return Result{}, fmt.Errorf("download FFmpeg: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return Result{}, fmt.Errorf("download FFmpeg: HTTP %s", resp.Status)
	}
	hash := sha256.New()
	if _, err = io.Copy(io.MultiWriter(tmp, hash), io.LimitReader(resp.Body, 300<<20)); err != nil {
		return Result{}, err
	}
	if err = tmp.Close(); err != nil {
		return Result{}, err
	}
	if err = verifyChecksum(ctx, client, hex.EncodeToString(hash.Sum(nil))); err != nil {
		return Result{}, err
	}
	return extractExecutables(archive, destination)
}

func verifyChecksum(ctx context.Context, client *http.Client, actual string) error {
	req, _ := http.NewRequestWithContext(ctx, http.MethodGet, DownloadURL+".sha256", nil)
	resp, err := client.Do(req)
	if err != nil {
		return fmt.Errorf("download checksum: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("download checksum: HTTP %s", resp.Status)
	}
	b, err := io.ReadAll(io.LimitReader(resp.Body, 1024))
	if err != nil {
		return err
	}
	expected := strings.Fields(string(b))
	if len(expected) == 0 || !strings.EqualFold(expected[0], actual) {
		return fmt.Errorf("FFmpeg archive checksum does not match")
	}
	return nil
}

func extractExecutables(archive, destination string) (Result, error) {
	z, err := zip.OpenReader(archive)
	if err != nil {
		return Result{}, fmt.Errorf("open FFmpeg archive: %w", err)
	}
	defer z.Close()
	wanted := map[string]string{"ffmpeg.exe": "", "ffprobe.exe": ""}
	for _, f := range z.File {
		name := strings.ToLower(filepath.Base(filepath.FromSlash(f.Name)))
		if _, ok := wanted[name]; !ok || !strings.Contains(strings.ToLower(f.Name), "/bin/") {
			continue
		}
		r, err := f.Open()
		if err != nil {
			return Result{}, err
		}
		target, temp := filepath.Join(destination, name), filepath.Join(destination, name+".new")
		out, err := os.OpenFile(temp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0755)
		if err == nil {
			_, err = io.Copy(out, io.LimitReader(r, 250<<20))
			if closeErr := out.Close(); err == nil {
				err = closeErr
			}
		}
		r.Close()
		if err != nil {
			os.Remove(temp)
			return Result{}, err
		}
		os.Remove(target)
		if err = os.Rename(temp, target); err != nil {
			return Result{}, err
		}
		abs, _ := filepath.Abs(target)
		wanted[name] = abs
	}
	if wanted["ffmpeg.exe"] == "" || wanted["ffprobe.exe"] == "" {
		return Result{}, fmt.Errorf("archive does not contain ffmpeg.exe and ffprobe.exe")
	}
	return Result{FFmpeg: wanted["ffmpeg.exe"], FFprobe: wanted["ffprobe.exe"]}, nil
}