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.
HTTPS
https://zanvex.de/git/miniaudiolib.gitinternal/ffmpeginstall/install.go
Zum Verzeichnispackage 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
}