Git Repository

miniaudiolib

miniaudiolib scannt lokale Musik- und Hörbuchordner, liest Audio-Metadaten und Cover, verwaltet Favoriten und Playlists und streamt Titel direkt an den Browser.

Projektseite ↗
HTTPShttps://zanvex.de/git/miniaudiolib.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"
	"sync"
	"sync/atomic"
	"time"
)

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

type Result struct{ FFmpeg, FFprobe string }

type Progress struct {
	Phase       string  `json:"phase"`
	Downloaded  int64   `json:"downloaded"`
	Total       int64   `json:"total"`
	BytesPerSec float64 `json:"bytes_per_second"`
}

func Install(ctx context.Context, destination string) (Result, error) {
	return InstallWithProgress(ctx, destination, nil)
}

func InstallWithProgress(ctx context.Context, destination string, report func(Progress)) (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()
	// The FFmpeg archive can take well over 20 minutes on slow connections.
	// Keep a generous safety limit; cancellation is still controlled by ctx.
	client := &http.Client{Timeout: 2 * time.Hour}
	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)
	}
	if resp.StatusCode != http.StatusOK {
		resp.Body.Close()
		return Result{}, fmt.Errorf("download FFmpeg: HTTP %s", resp.Status)
	}
	total := resp.ContentLength
	segmented := total > 0 && total <= 300<<20 && strings.EqualFold(strings.TrimSpace(resp.Header.Get("Accept-Ranges")), "bytes")
	var downloaded int64
	if segmented {
		resp.Body.Close()
		downloaded, err = downloadSegments(ctx, client, tmp, total, 4, report)
		if err != nil {
			if truncateErr := tmp.Truncate(0); truncateErr != nil {
				return Result{}, truncateErr
			}
			if _, seekErr := tmp.Seek(0, io.SeekStart); seekErr != nil {
				return Result{}, seekErr
			}
			resp, err = client.Do(req)
			if err != nil {
				return Result{}, fmt.Errorf("download FFmpeg: %w", err)
			}
			if resp.StatusCode != http.StatusOK {
				resp.Body.Close()
				return Result{}, fmt.Errorf("download FFmpeg: HTTP %s", resp.Status)
			}
			downloaded, err = downloadSingle(resp.Body, tmp, total, report)
			resp.Body.Close()
		}
	} else {
		downloaded, err = downloadSingle(resp.Body, tmp, total, report)
		resp.Body.Close()
	}
	if err != nil {
		return Result{}, err
	}
	hash := sha256.New()
	if report != nil {
		report(Progress{Phase: "verifying", Downloaded: downloaded, Total: total})
	}
	if _, err = tmp.Seek(0, io.SeekStart); err != nil {
		return Result{}, err
	}
	if _, err = io.Copy(hash, io.LimitReader(tmp, 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
	}
	if report != nil {
		report(Progress{Phase: "extracting", Downloaded: downloaded, Total: total})
	}
	return extractExecutables(archive, destination)
}

func downloadSingle(body io.Reader, target io.Writer, total int64, report func(Progress)) (int64, error) {
	started := time.Now()
	var downloaded int64
	last := time.Time{}
	r := &progressReader{reader: body, onRead: func(n int) {
		downloaded += int64(n)
		if report != nil && (last.IsZero() || time.Since(last) >= 200*time.Millisecond || downloaded == total) {
			report(Progress{Phase: "downloading", Downloaded: downloaded, Total: total, BytesPerSec: float64(downloaded) / max(time.Since(started).Seconds(), .001)})
			last = time.Now()
		}
	}}
	_, err := io.Copy(target, io.LimitReader(r, 300<<20))
	return downloaded, err
}

func downloadSegments(ctx context.Context, client *http.Client, target *os.File, total int64, count int, report func(Progress)) (int64, error) {
	if err := target.Truncate(total); err != nil {
		return 0, err
	}
	segmentCtx, cancel := context.WithCancel(ctx)
	defer cancel()
	started := time.Now()
	var downloaded atomic.Int64
	var wg sync.WaitGroup
	errs := make(chan error, count)
	done := make(chan struct{})
	wg.Add(count)
	for i := 0; i < count; i++ {
		go func(i int) {
			defer wg.Done()
			start := int64(i) * total / int64(count)
			end := int64(i+1)*total/int64(count) - 1
			req, err := http.NewRequestWithContext(segmentCtx, http.MethodGet, DownloadURL, nil)
			if err != nil {
				errs <- err
				return
			}
			req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end))
			resp, err := client.Do(req)
			if err != nil {
				errs <- err
				return
			}
			defer resp.Body.Close()
			if resp.StatusCode != http.StatusPartialContent {
				errs <- fmt.Errorf("range request: HTTP %s", resp.Status)
				return
			}
			w := io.NewOffsetWriter(target, start)
			r := &progressReader{reader: resp.Body, onRead: func(n int) { downloaded.Add(int64(n)) }}
			written, err := io.CopyN(w, r, end-start+1)
			if err != nil {
				errs <- err
				return
			}
			if written != end-start+1 {
				errs <- io.ErrUnexpectedEOF
			}
		}(i)
	}
	go func() { wg.Wait(); close(done) }()
	ticker := time.NewTicker(200 * time.Millisecond)
	defer ticker.Stop()
	for {
		select {
		case err := <-errs:
			cancel()
			wg.Wait()
			return downloaded.Load(), err
		case <-ticker.C:
			d := downloaded.Load()
			if report != nil {
				report(Progress{Phase: "downloading", Downloaded: d, Total: total, BytesPerSec: float64(d) / max(time.Since(started).Seconds(), .001)})
			}
		case <-done:
			d := downloaded.Load()
			if report != nil {
				report(Progress{Phase: "downloading", Downloaded: d, Total: total, BytesPerSec: float64(d) / max(time.Since(started).Seconds(), .001)})
			}
			return d, nil
		case <-ctx.Done():
			return downloaded.Load(), ctx.Err()
		}
	}
}

type progressReader struct {
	reader io.Reader
	onRead func(int)
}

func (r *progressReader) Read(p []byte) (int, error) {
	n, err := r.reader.Read(p)
	if n > 0 {
		r.onRead(n)
	}
	return n, err
}

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
}