feat: add CLI with TUI, self-update, install script, and terminal section on landing page
- Full-screen Bubble Tea TUI with cream background fill using PadLine/FillBlankLines - Self-update command (--update) pulling from GitHub releases - install.sh for curl one-liner installation - Terminal Lovers section on web landing page with install command and CLI features - All 7 format categories, glob/directory batch support, auto-download ffmpeg
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// cacheDir returns the directory where transmute stores its ffmpeg binary.
|
||||
// ~/.transmute/bin/
|
||||
func cacheDir() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".transmute", "bin"), nil
|
||||
}
|
||||
|
||||
// BinaryPath returns the expected path to the ffmpeg binary inside our cache.
|
||||
func BinaryPath() (string, error) {
|
||||
dir, err := cacheDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name := "ffmpeg"
|
||||
if runtime.GOOS == "windows" {
|
||||
name = "ffmpeg.exe"
|
||||
}
|
||||
return filepath.Join(dir, name), nil
|
||||
}
|
||||
|
||||
// Resolve returns a usable ffmpeg path. It checks:
|
||||
// 1. Our managed cache dir
|
||||
// 2. System PATH
|
||||
// Returns empty string + error if not found anywhere.
|
||||
func Resolve() (string, error) {
|
||||
// Check our cache first
|
||||
p, err := BinaryPath()
|
||||
if err == nil {
|
||||
if _, statErr := os.Stat(p); statErr == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check system PATH
|
||||
if sysPath, err := exec.LookPath("ffmpeg"); err == nil {
|
||||
return sysPath, nil
|
||||
}
|
||||
|
||||
return "", errors.New("ffmpeg not found — run `transmute --install-ffmpeg` or install it manually")
|
||||
}
|
||||
|
||||
// IsAvailable returns true if ffmpeg can be resolved.
|
||||
func IsAvailable() bool {
|
||||
_, err := Resolve()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// downloadURL returns the URL for a static ffmpeg build for the current platform.
|
||||
// Uses https://github.com/eugeneware/ffmpeg-static releases (widely used, MIT).
|
||||
func downloadURL() (string, error) {
|
||||
goos := runtime.GOOS
|
||||
goarch := runtime.GOARCH
|
||||
|
||||
// Map Go os/arch to ffmpeg-static naming
|
||||
var platform string
|
||||
switch {
|
||||
case goos == "darwin" && goarch == "arm64":
|
||||
platform = "darwin-arm64"
|
||||
case goos == "darwin" && goarch == "amd64":
|
||||
platform = "darwin-x64"
|
||||
case goos == "linux" && goarch == "amd64":
|
||||
platform = "linux-x64"
|
||||
case goos == "linux" && goarch == "arm64":
|
||||
platform = "linux-arm64"
|
||||
case goos == "windows" && goarch == "amd64":
|
||||
platform = "win32-x64"
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported platform: %s/%s", goos, goarch)
|
||||
}
|
||||
|
||||
// Use johnvansickle static builds for linux, evermeet for mac, gyan.dev for windows
|
||||
switch goos {
|
||||
case "darwin":
|
||||
// evermeet.cx provides universal macOS ffmpeg builds
|
||||
return "https://evermeet.cx/ffmpeg/getrelease/zip", nil
|
||||
case "linux":
|
||||
// johnvansickle provides static Linux builds
|
||||
base := "https://johnvansickle.com/ffmpeg/releases/"
|
||||
switch goarch {
|
||||
case "amd64":
|
||||
return base + "ffmpeg-release-amd64-static.tar.xz", nil
|
||||
case "arm64":
|
||||
return base + "ffmpeg-release-arm64-static.tar.xz", nil
|
||||
}
|
||||
case "windows":
|
||||
return "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip", nil
|
||||
}
|
||||
|
||||
_ = platform // suppress unused
|
||||
return "", fmt.Errorf("unsupported platform: %s/%s", goos, goarch)
|
||||
}
|
||||
|
||||
// Download fetches and installs ffmpeg into ~/.transmute/bin/.
|
||||
// The progress callback receives bytes downloaded so far.
|
||||
func Download(progress func(downloaded int64)) error {
|
||||
url, err := downloadURL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dir, err := cacheDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("creating cache dir: %w", err)
|
||||
}
|
||||
|
||||
binPath, err := BinaryPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Download to temp file
|
||||
resp, err := http.Get(url) //nolint:gosec
|
||||
if err != nil {
|
||||
return fmt.Errorf("downloading ffmpeg: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp(dir, "ffmpeg-download-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating temp file: %w", err)
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
var reader io.Reader = resp.Body
|
||||
if progress != nil {
|
||||
reader = &progressReader{r: resp.Body, fn: progress}
|
||||
}
|
||||
|
||||
if _, err := io.Copy(tmpFile, reader); err != nil {
|
||||
tmpFile.Close()
|
||||
return fmt.Errorf("saving download: %w", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
// Extract based on file type
|
||||
switch {
|
||||
case strings.HasSuffix(url, ".zip"):
|
||||
if err := extractFromZip(tmpPath, binPath); err != nil {
|
||||
return err
|
||||
}
|
||||
case strings.HasSuffix(url, ".tar.xz"), strings.HasSuffix(url, ".tar.gz"):
|
||||
if err := extractFromTarball(tmpPath, binPath); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
// Direct binary
|
||||
if err := os.Rename(tmpPath, binPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Make executable
|
||||
return os.Chmod(binPath, 0o755)
|
||||
}
|
||||
|
||||
func extractFromZip(zipPath, destBin string) error {
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening zip: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
for _, f := range r.File {
|
||||
name := filepath.Base(f.Name)
|
||||
if name == "ffmpeg" || name == "ffmpeg.exe" {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
out, err := os.Create(destBin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, rc)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return errors.New("ffmpeg binary not found in zip archive")
|
||||
}
|
||||
|
||||
func extractFromTarball(tarPath, destBin string) error {
|
||||
f, err := os.Open(tarPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var reader io.Reader
|
||||
// Try gzip first — xz would need a separate lib, but we'll handle .tar.gz here
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
// Not gzip — for .tar.xz we'd need an xz decompressor.
|
||||
// Fallback: try to use system xz command
|
||||
f.Close()
|
||||
return extractWithSystemXZ(tarPath, destBin)
|
||||
}
|
||||
defer gz.Close()
|
||||
reader = gz
|
||||
|
||||
tr := tar.NewReader(reader)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := filepath.Base(hdr.Name)
|
||||
if name == "ffmpeg" {
|
||||
out, err := os.Create(destBin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, tr)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return errors.New("ffmpeg binary not found in tarball")
|
||||
}
|
||||
|
||||
func extractWithSystemXZ(tarPath, destBin string) error {
|
||||
// Use system xz to decompress, then extract with tar
|
||||
dir := filepath.Dir(destBin)
|
||||
cmd := exec.Command("sh", "-c",
|
||||
fmt.Sprintf("xz -dc %q | tar -xf - -C %q --strip-components=1", tarPath, dir))
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("extracting with xz: %w (is xz installed?)", err)
|
||||
}
|
||||
|
||||
// Look for the ffmpeg binary in the extracted files
|
||||
extracted := filepath.Join(dir, "ffmpeg")
|
||||
if _, err := os.Stat(extracted); err == nil {
|
||||
return nil // Already in the right place
|
||||
}
|
||||
|
||||
// Walk to find it
|
||||
var found string
|
||||
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if filepath.Base(path) == "ffmpeg" && !info.IsDir() {
|
||||
found = path
|
||||
return filepath.SkipAll
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if found == "" {
|
||||
return errors.New("ffmpeg binary not found after extraction")
|
||||
}
|
||||
if found != destBin {
|
||||
return os.Rename(found, destBin)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run executes ffmpeg with the given arguments, returning combined output on error.
|
||||
func Run(args ...string) error {
|
||||
bin, err := Resolve()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.Command(bin, args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ffmpeg error: %w\n%s", err, string(output))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunWithOutput executes ffmpeg and returns stdout.
|
||||
func RunWithOutput(args ...string) ([]byte, error) {
|
||||
bin, err := Resolve()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmd := exec.Command(bin, args...)
|
||||
return cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
// progressReader wraps an io.Reader and reports progress.
|
||||
type progressReader struct {
|
||||
r io.Reader
|
||||
fn func(int64)
|
||||
total int64
|
||||
}
|
||||
|
||||
func (pr *progressReader) Read(p []byte) (int, error) {
|
||||
n, err := pr.r.Read(p)
|
||||
pr.total += int64(n)
|
||||
if pr.fn != nil {
|
||||
pr.fn(pr.total)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
Reference in New Issue
Block a user