mirror of
https://github.com/stashapp/stash.git
synced 2025-12-16 20:07:05 +03:00
Change ffmpeg handling (#4688)
* Make ffmpeg/ffprobe settable and remove auto download * Detect when ffmpeg not present in setup * Add download ffmpeg task * Add download ffmpeg button in system settings * Download ffmpeg during setup
This commit is contained in:
@@ -60,7 +60,7 @@ func (f *FFMpeg) InitHWSupport(ctx context.Context) {
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
logger.Debugf("[InitHWSupport] error starting command: %w", err)
|
||||
logger.Debugf("[InitHWSupport] error starting command: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -1,179 +1,10 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
stashExec "github.com/stashapp/stash/pkg/exec"
|
||||
"github.com/stashapp/stash/pkg/fsutil"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
)
|
||||
|
||||
func GetPaths(paths []string) (string, string) {
|
||||
var ffmpegPath, ffprobePath string
|
||||
|
||||
// Check if ffmpeg exists in the PATH
|
||||
if pathBinaryHasCorrectFlags() {
|
||||
ffmpegPath, _ = exec.LookPath("ffmpeg")
|
||||
ffprobePath, _ = exec.LookPath("ffprobe")
|
||||
}
|
||||
|
||||
// Check if ffmpeg exists in the config directory
|
||||
if ffmpegPath == "" {
|
||||
ffmpegPath = fsutil.FindInPaths(paths, getFFMpegFilename())
|
||||
}
|
||||
if ffprobePath == "" {
|
||||
ffprobePath = fsutil.FindInPaths(paths, getFFProbeFilename())
|
||||
}
|
||||
|
||||
return ffmpegPath, ffprobePath
|
||||
}
|
||||
|
||||
func Download(ctx context.Context, configDirectory string) error {
|
||||
for _, url := range getFFmpegURL() {
|
||||
err := downloadSingle(ctx, configDirectory, url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// validate that the urls contained what we needed
|
||||
executables := []string{getFFMpegFilename(), getFFProbeFilename()}
|
||||
for _, executable := range executables {
|
||||
_, err := os.Stat(filepath.Join(configDirectory, executable))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type progressReader struct {
|
||||
io.Reader
|
||||
lastProgress int64
|
||||
bytesRead int64
|
||||
total int64
|
||||
}
|
||||
|
||||
func (r *progressReader) Read(p []byte) (int, error) {
|
||||
read, err := r.Reader.Read(p)
|
||||
if err == nil {
|
||||
r.bytesRead += int64(read)
|
||||
if r.total > 0 {
|
||||
progress := int64(float64(r.bytesRead) / float64(r.total) * 100)
|
||||
if progress/5 > r.lastProgress {
|
||||
logger.Infof("%d%% downloaded...", progress)
|
||||
r.lastProgress = progress / 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return read, err
|
||||
}
|
||||
|
||||
func downloadSingle(ctx context.Context, configDirectory, url string) error {
|
||||
if url == "" {
|
||||
return fmt.Errorf("no ffmpeg url for this platform")
|
||||
}
|
||||
|
||||
// Configure where we want to download the archive
|
||||
urlBase := path.Base(url)
|
||||
archivePath := filepath.Join(configDirectory, urlBase)
|
||||
_ = os.Remove(archivePath) // remove archive if it already exists
|
||||
out, err := os.Create(archivePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
logger.Infof("Downloading %s...", url)
|
||||
|
||||
// Make the HTTP request
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
transport := &http.Transport{Proxy: http.ProxyFromEnvironment}
|
||||
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check server response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("bad status: %s", resp.Status)
|
||||
}
|
||||
|
||||
reader := &progressReader{
|
||||
Reader: resp.Body,
|
||||
total: resp.ContentLength,
|
||||
}
|
||||
|
||||
// Write the response to the archive file location
|
||||
_, err = io.Copy(out, reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info("Downloading complete")
|
||||
|
||||
mime := resp.Header.Get("Content-Type")
|
||||
if mime != "application/zip" { // try detecting MIME type since some servers don't return the correct one
|
||||
data := make([]byte, 500) // http.DetectContentType only reads up to 500 bytes
|
||||
_, _ = out.ReadAt(data, 0)
|
||||
mime = http.DetectContentType(data)
|
||||
}
|
||||
|
||||
if mime == "application/zip" {
|
||||
logger.Infof("Unzipping %s...", archivePath)
|
||||
if err := unzip(archivePath, configDirectory); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// On OSX or Linux set downloaded files permissions
|
||||
if runtime.GOOS == "darwin" || runtime.GOOS == "linux" {
|
||||
_, err = os.Stat(filepath.Join(configDirectory, "ffmpeg"))
|
||||
if !os.IsNotExist(err) {
|
||||
if err = os.Chmod(filepath.Join(configDirectory, "ffmpeg"), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err = os.Stat(filepath.Join(configDirectory, "ffprobe"))
|
||||
if !os.IsNotExist(err) {
|
||||
if err := os.Chmod(filepath.Join(configDirectory, "ffprobe"), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: In future possible clear xattr to allow running on osx without user intervention
|
||||
// TODO: this however may not be required.
|
||||
// xattr -c /path/to/binary -- xattr.Remove(path, "com.apple.quarantine")
|
||||
}
|
||||
|
||||
} else {
|
||||
return fmt.Errorf("ffmpeg was downloaded to %s", archivePath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getFFmpegURL() []string {
|
||||
func GetFFmpegURL() []string {
|
||||
var urls []string
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
@@ -208,60 +39,3 @@ func getFFProbeFilename() string {
|
||||
}
|
||||
return "ffprobe"
|
||||
}
|
||||
|
||||
// Checks if ffmpeg in the path has the correct flags
|
||||
func pathBinaryHasCorrectFlags() bool {
|
||||
ffmpegPath, err := exec.LookPath("ffmpeg")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
cmd := stashExec.Command(ffmpegPath)
|
||||
bytes, _ := cmd.CombinedOutput()
|
||||
output := string(bytes)
|
||||
hasOpus := strings.Contains(output, "--enable-libopus")
|
||||
hasVpx := strings.Contains(output, "--enable-libvpx")
|
||||
hasX264 := strings.Contains(output, "--enable-libx264")
|
||||
hasX265 := strings.Contains(output, "--enable-libx265")
|
||||
hasWebp := strings.Contains(output, "--enable-libwebp")
|
||||
return hasOpus && hasVpx && hasX264 && hasX265 && hasWebp
|
||||
}
|
||||
|
||||
func unzip(src, configDirectory string) error {
|
||||
zipReader, err := zip.OpenReader(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer zipReader.Close()
|
||||
|
||||
for _, f := range zipReader.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
filename := f.FileInfo().Name()
|
||||
if filename != "ffprobe" && filename != "ffmpeg" && filename != "ffprobe.exe" && filename != "ffmpeg.exe" {
|
||||
continue
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
unzippedPath := filepath.Join(configDirectory, filename)
|
||||
unzippedOutput, err := os.Create(unzippedPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.Copy(unzippedOutput, rc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := unzippedOutput.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,11 +3,96 @@ package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
stashExec "github.com/stashapp/stash/pkg/exec"
|
||||
"github.com/stashapp/stash/pkg/fsutil"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
)
|
||||
|
||||
func ValidateFFMpeg(ffmpegPath string) error {
|
||||
cmd := stashExec.Command(ffmpegPath, "-h")
|
||||
bytes, err := cmd.CombinedOutput()
|
||||
output := string(bytes)
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return fmt.Errorf("error running ffmpeg: %v", output)
|
||||
}
|
||||
|
||||
return fmt.Errorf("error running ffmpeg: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "--enable-libopus") {
|
||||
return fmt.Errorf("ffmpeg is missing libopus support")
|
||||
}
|
||||
if !strings.Contains(output, "--enable-libvpx") {
|
||||
return fmt.Errorf("ffmpeg is missing libvpx support")
|
||||
}
|
||||
if !strings.Contains(output, "--enable-libx264") {
|
||||
return fmt.Errorf("ffmpeg is missing libx264 support")
|
||||
}
|
||||
if !strings.Contains(output, "--enable-libx265") {
|
||||
return fmt.Errorf("ffmpeg is missing libx265 support")
|
||||
}
|
||||
if !strings.Contains(output, "--enable-libwebp") {
|
||||
return fmt.Errorf("ffmpeg is missing libwebp support")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func LookPathFFMpeg() string {
|
||||
ret, _ := exec.LookPath(getFFMpegFilename())
|
||||
|
||||
if ret != "" {
|
||||
// ensure ffmpeg has the correct flags
|
||||
if err := ValidateFFMpeg(ret); err != nil {
|
||||
logger.Warnf("ffmpeg found in PATH (%s), but it is missing required flags: %v", ret, err)
|
||||
ret = ""
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func FindFFMpeg(path string) string {
|
||||
ret := fsutil.FindInPaths([]string{path}, getFFMpegFilename())
|
||||
|
||||
if ret != "" {
|
||||
// ensure ffmpeg has the correct flags
|
||||
if err := ValidateFFMpeg(ret); err != nil {
|
||||
logger.Warnf("ffmpeg found (%s), but it is missing required flags: %v", ret, err)
|
||||
ret = ""
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// ResolveFFMpeg attempts to resolve the path to the ffmpeg executable.
|
||||
// It first looks in the provided path, then resolves from the environment, and finally looks in the fallback path.
|
||||
// Returns an empty string if a valid ffmpeg cannot be found.
|
||||
func ResolveFFMpeg(path string, fallbackPath string) string {
|
||||
// look in the provided path first
|
||||
ret := FindFFMpeg(path)
|
||||
if ret != "" {
|
||||
return ret
|
||||
}
|
||||
|
||||
// then resolve from the environment
|
||||
ret = LookPathFFMpeg()
|
||||
if ret != "" {
|
||||
return ret
|
||||
}
|
||||
|
||||
// finally, look in the fallback path
|
||||
ret = FindFFMpeg(fallbackPath)
|
||||
return ret
|
||||
}
|
||||
|
||||
// FFMpeg provides an interface to ffmpeg.
|
||||
type FFMpeg struct {
|
||||
ffmpeg string
|
||||
@@ -27,3 +112,7 @@ func NewEncoder(ffmpegPath string) *FFMpeg {
|
||||
func (f *FFMpeg) Command(ctx context.Context, args []string) *exec.Cmd {
|
||||
return stashExec.CommandContext(ctx, string(f.ffmpeg), args...)
|
||||
}
|
||||
|
||||
func (f *FFMpeg) Path() string {
|
||||
return f.ffmpeg
|
||||
}
|
||||
|
||||
@@ -2,17 +2,83 @@ package ffmpeg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/exec"
|
||||
stashExec "github.com/stashapp/stash/pkg/exec"
|
||||
"github.com/stashapp/stash/pkg/fsutil"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
)
|
||||
|
||||
func ValidateFFProbe(ffprobePath string) error {
|
||||
cmd := stashExec.Command(ffprobePath, "-h")
|
||||
bytes, err := cmd.CombinedOutput()
|
||||
output := string(bytes)
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return fmt.Errorf("error running ffprobe: %v", output)
|
||||
}
|
||||
|
||||
return fmt.Errorf("error running ffprobe: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func LookPathFFProbe() string {
|
||||
ret, _ := exec.LookPath(getFFProbeFilename())
|
||||
|
||||
if ret != "" {
|
||||
if err := ValidateFFProbe(ret); err != nil {
|
||||
logger.Warnf("ffprobe found in PATH (%s), but it is missing required flags: %v", ret, err)
|
||||
ret = ""
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func FindFFProbe(path string) string {
|
||||
ret := fsutil.FindInPaths([]string{path}, getFFProbeFilename())
|
||||
|
||||
if ret != "" {
|
||||
if err := ValidateFFProbe(ret); err != nil {
|
||||
logger.Warnf("ffprobe found (%s), but it is missing required flags: %v", ret, err)
|
||||
ret = ""
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// ResolveFFMpeg attempts to resolve the path to the ffmpeg executable.
|
||||
// It first looks in the provided path, then resolves from the environment, and finally looks in the fallback path.
|
||||
// Returns an empty string if a valid ffmpeg cannot be found.
|
||||
func ResolveFFProbe(path string, fallbackPath string) string {
|
||||
// look in the provided path first
|
||||
ret := FindFFProbe(path)
|
||||
if ret != "" {
|
||||
return ret
|
||||
}
|
||||
|
||||
// then resolve from the environment
|
||||
ret = LookPathFFProbe()
|
||||
if ret != "" {
|
||||
return ret
|
||||
}
|
||||
|
||||
// finally, look in the fallback path
|
||||
ret = FindFFProbe(fallbackPath)
|
||||
return ret
|
||||
}
|
||||
|
||||
// VideoFile represents the ffprobe output for a video file.
|
||||
type VideoFile struct {
|
||||
JSON FFProbeJSON
|
||||
@@ -75,10 +141,14 @@ func (v *VideoFile) TranscodeScale(maxSize int) (int, int) {
|
||||
// FFProbe provides an interface to the ffprobe executable.
|
||||
type FFProbe string
|
||||
|
||||
func (f *FFProbe) Path() string {
|
||||
return string(*f)
|
||||
}
|
||||
|
||||
// NewVideoFile runs ffprobe on the given path and returns a VideoFile.
|
||||
func (f *FFProbe) NewVideoFile(videoPath string) (*VideoFile, error) {
|
||||
args := []string{"-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", "-show_error", videoPath}
|
||||
cmd := exec.Command(string(*f), args...)
|
||||
cmd := stashExec.Command(string(*f), args...)
|
||||
out, err := cmd.Output()
|
||||
|
||||
if err != nil {
|
||||
@@ -97,7 +167,7 @@ func (f *FFProbe) NewVideoFile(videoPath string) (*VideoFile, error) {
|
||||
// Used when the frame count is missing or incorrect.
|
||||
func (f *FFProbe) GetReadFrameCount(path string) (int64, error) {
|
||||
args := []string{"-v", "quiet", "-print_format", "json", "-count_frames", "-show_format", "-show_streams", "-show_error", path}
|
||||
out, err := exec.Command(string(*f), args...).Output()
|
||||
out, err := stashExec.Command(string(*f), args...).Output()
|
||||
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("FFProbe encountered an error with <%s>.\nError JSON:\n%s\nError: %s", path, string(out), err.Error())
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
@@ -163,3 +164,12 @@ func SanitiseBasename(v string) string {
|
||||
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
// GetExeName returns the name of the given executable for the current platform.
|
||||
// One windows it returns the name with the .exe extension.
|
||||
func GetExeName(base string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return base + ".exe"
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user