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:
WithoutPants 2024-03-21 12:43:40 +11:00 committed by GitHub
parent a369613d42
commit 7086109d78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 694 additions and 297 deletions

View file

@ -4,6 +4,7 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"os/exec"
flag "github.com/spf13/pflag" flag "github.com/spf13/pflag"
"github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/ffmpeg"
@ -45,6 +46,13 @@ func printPhash(ff *ffmpeg.FFMpeg, ffp ffmpeg.FFProbe, inputfile string, quiet *
return nil return nil
} }
func getPaths() (string, string) {
ffmpegPath, _ := exec.LookPath("ffmpeg")
ffprobePath, _ := exec.LookPath("ffprobe")
return ffmpegPath, ffprobePath
}
func main() { func main() {
flag.Usage = customUsage flag.Usage = customUsage
quiet := flag.BoolP("quiet", "q", false, "print only the phash") quiet := flag.BoolP("quiet", "q", false, "print only the phash")
@ -69,7 +77,7 @@ func main() {
fmt.Fprintf(os.Stderr, "Example: parallel %v ::: *.mp4\n", os.Args[0]) fmt.Fprintf(os.Stderr, "Example: parallel %v ::: *.mp4\n", os.Args[0])
} }
ffmpegPath, ffprobePath := ffmpeg.GetPaths(nil) ffmpegPath, ffprobePath := getPaths()
encoder := ffmpeg.NewEncoder(ffmpegPath) encoder := ffmpeg.NewEncoder(ffmpegPath)
// don't need to InitHWSupport, phashing doesn't use hw acceleration // don't need to InitHWSupport, phashing doesn't use hw acceleration
ffprobe := ffmpeg.FFProbe(ffprobePath) ffprobe := ffmpeg.FFProbe(ffprobePath)

View file

@ -230,6 +230,9 @@ type Mutation {
"Migrates the schema to the required version. Returns the job ID" "Migrates the schema to the required version. Returns the job ID"
migrate(input: MigrateInput!): ID! migrate(input: MigrateInput!): ID!
"Downloads and installs ffmpeg and ffprobe binaries into the configuration directory. Returns the job ID."
downloadFFMpeg: ID!
sceneCreate(input: SceneCreateInput!): Scene sceneCreate(input: SceneCreateInput!): Scene
sceneUpdate(input: SceneUpdateInput!): Scene sceneUpdate(input: SceneUpdateInput!): Scene
sceneMerge(input: SceneMergeInput!): Scene sceneMerge(input: SceneMergeInput!): Scene

View file

@ -81,6 +81,10 @@ input ConfigGeneralInput {
blobsPath: String blobsPath: String
"Where to store blobs" "Where to store blobs"
blobsStorage: BlobsStorageType blobsStorage: BlobsStorageType
"Path to the ffmpeg binary. If empty, stash will attempt to find it in the path or config directory"
ffmpegPath: String
"Path to the ffprobe binary. If empty, stash will attempt to find it in the path or config directory"
ffprobePath: String
"Whether to calculate MD5 checksums for scene video files" "Whether to calculate MD5 checksums for scene video files"
calculateMD5: Boolean calculateMD5: Boolean
"Hash algorithm to use for generated file naming" "Hash algorithm to use for generated file naming"
@ -199,6 +203,10 @@ type ConfigGeneralResult {
blobsPath: String! blobsPath: String!
"Where to store blobs" "Where to store blobs"
blobsStorage: BlobsStorageType! blobsStorage: BlobsStorageType!
"Path to the ffmpeg binary. If empty, stash will attempt to find it in the path or config directory"
ffmpegPath: String!
"Path to the ffprobe binary. If empty, stash will attempt to find it in the path or config directory"
ffprobePath: String!
"Whether to calculate MD5 checksums for scene video files" "Whether to calculate MD5 checksums for scene video files"
calculateMD5: Boolean! calculateMD5: Boolean!
"Hash algorithm to use for generated file naming" "Hash algorithm to use for generated file naming"

View file

@ -326,6 +326,8 @@ type SystemStatus {
os: String! os: String!
workingDir: String! workingDir: String!
homeDir: String! homeDir: String!
ffmpegPath: String
ffprobePath: String
} }
input MigrateInput { input MigrateInput {

View file

@ -6,9 +6,12 @@ import (
"fmt" "fmt"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv"
"github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/internal/manager/task"
"github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
@ -22,6 +25,34 @@ func (r *mutationResolver) Setup(ctx context.Context, input manager.SetupInput)
return err == nil, err return err == nil, err
} }
func (r *mutationResolver) DownloadFFMpeg(ctx context.Context) (string, error) {
mgr := manager.GetInstance()
configDir := mgr.Config.GetConfigPath()
// don't run if ffmpeg is already installed
ffmpegPath := ffmpeg.FindFFMpeg(configDir)
ffprobePath := ffmpeg.FindFFProbe(configDir)
if ffmpegPath != "" && ffprobePath != "" {
return "", fmt.Errorf("ffmpeg and ffprobe already installed at %s and %s", ffmpegPath, ffprobePath)
}
t := &task.DownloadFFmpegJob{
ConfigDirectory: configDir,
OnComplete: func(ctx context.Context) {
// clear the ffmpeg and ffprobe paths
logger.Infof("Clearing ffmpeg and ffprobe config paths so they are resolved from the config directory")
mgr.Config.Set(config.FFMpegPath, "")
mgr.Config.Set(config.FFProbePath, "")
mgr.RefreshFFMpeg(ctx)
mgr.RefreshStreamManager()
},
}
jobID := mgr.JobManager.Add(ctx, "Downloading ffmpeg...", t)
return strconv.Itoa(jobID), nil
}
func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGeneralInput) (*ConfigGeneralResult, error) { func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGeneralInput) (*ConfigGeneralResult, error) {
c := config.GetInstance() c := config.GetInstance()
@ -161,12 +192,34 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
return makeConfigGeneralResult(), fmt.Errorf("blobs path must be set when using filesystem storage") return makeConfigGeneralResult(), fmt.Errorf("blobs path must be set when using filesystem storage")
} }
// TODO - migrate between systems
c.Set(config.BlobsStorage, input.BlobsStorage) c.Set(config.BlobsStorage, input.BlobsStorage)
refreshBlobStorage = true refreshBlobStorage = true
} }
refreshFfmpeg := false
if input.FfmpegPath != nil && *input.FfmpegPath != c.GetFFMpegPath() {
if *input.FfmpegPath != "" {
if err := ffmpeg.ValidateFFMpeg(*input.FfmpegPath); err != nil {
return makeConfigGeneralResult(), fmt.Errorf("invalid ffmpeg path: %w", err)
}
}
c.Set(config.FFMpegPath, input.FfmpegPath)
refreshFfmpeg = true
}
if input.FfprobePath != nil && *input.FfprobePath != c.GetFFProbePath() {
if *input.FfprobePath != "" {
if err := ffmpeg.ValidateFFProbe(*input.FfprobePath); err != nil {
return makeConfigGeneralResult(), fmt.Errorf("invalid ffprobe path: %w", err)
}
}
c.Set(config.FFProbePath, input.FfprobePath)
refreshFfmpeg = true
}
if input.VideoFileNamingAlgorithm != nil && *input.VideoFileNamingAlgorithm != c.GetVideoFileNamingAlgorithm() { if input.VideoFileNamingAlgorithm != nil && *input.VideoFileNamingAlgorithm != c.GetVideoFileNamingAlgorithm() {
calculateMD5 := c.IsCalculateMD5() calculateMD5 := c.IsCalculateMD5()
if input.CalculateMd5 != nil { if input.CalculateMd5 != nil {
@ -379,6 +432,12 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
if refreshPluginCache { if refreshPluginCache {
manager.GetInstance().RefreshPluginCache() manager.GetInstance().RefreshPluginCache()
} }
if refreshFfmpeg {
manager.GetInstance().RefreshFFMpeg(ctx)
// refresh stream manager is required since ffmpeg changed
refreshStreamManager = true
}
if refreshStreamManager { if refreshStreamManager {
manager.GetInstance().RefreshStreamManager() manager.GetInstance().RefreshStreamManager()
} }

View file

@ -91,6 +91,8 @@ func makeConfigGeneralResult() *ConfigGeneralResult {
CachePath: config.GetCachePath(), CachePath: config.GetCachePath(),
BlobsPath: config.GetBlobsPath(), BlobsPath: config.GetBlobsPath(),
BlobsStorage: config.GetBlobsStorage(), BlobsStorage: config.GetBlobsStorage(),
FfmpegPath: config.GetFFMpegPath(),
FfprobePath: config.GetFFProbePath(),
CalculateMd5: config.IsCalculateMD5(), CalculateMd5: config.IsCalculateMD5(),
VideoFileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(), VideoFileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(),
ParallelTasks: config.GetParallelTasks(), ParallelTasks: config.GetParallelTasks(),

View file

@ -38,6 +38,9 @@ const (
Password = "password" Password = "password"
MaxSessionAge = "max_session_age" MaxSessionAge = "max_session_age"
FFMpegPath = "ffmpeg_path"
FFProbePath = "ffprobe_path"
BlobsStorage = "blobs_storage" BlobsStorage = "blobs_storage"
DefaultMaxSessionAge = 60 * 60 * 1 // 1 hours DefaultMaxSessionAge = 60 * 60 * 1 // 1 hours
@ -603,6 +606,18 @@ func (i *Config) GetBackupDirectoryPathOrDefault() string {
return ret return ret
} }
// GetFFMpegPath returns the path to the FFMpeg executable.
// If empty, stash will attempt to resolve it from the path.
func (i *Config) GetFFMpegPath() string {
return i.getString(FFMpegPath)
}
// GetFFProbePath returns the path to the FFProbe executable.
// If empty, stash will attempt to resolve it from the path.
func (i *Config) GetFFProbePath() string {
return i.getString(FFProbePath)
}
func (i *Config) GetJWTSignKey() []byte { func (i *Config) GetJWTSignKey() []byte {
return []byte(i.getString(JWTSignKey)) return []byte(i.getString(JWTSignKey))
} }

View file

@ -192,7 +192,6 @@ func (s *Manager) postInit(ctx context.Context) error {
s.RefreshScraperCache() s.RefreshScraperCache()
s.RefreshScraperSourceManager() s.RefreshScraperSourceManager()
s.RefreshStreamManager()
s.RefreshDLNA() s.RefreshDLNA()
s.SetBlobStoreOptions() s.SetBlobStoreOptions()
@ -239,9 +238,8 @@ func (s *Manager) postInit(ctx context.Context) error {
logger.Info("Using HTTP proxy") logger.Info("Using HTTP proxy")
} }
if err := s.initFFmpeg(ctx); err != nil { s.RefreshFFMpeg(ctx)
return fmt.Errorf("error initializing FFmpeg subsystem: %v", err) s.RefreshStreamManager()
}
return nil return nil
} }
@ -260,41 +258,48 @@ func (s *Manager) writeStashIcon() {
} }
} }
func (s *Manager) initFFmpeg(ctx context.Context) error { func (s *Manager) RefreshFFMpeg(ctx context.Context) {
// use same directory as config path // use same directory as config path
configDirectory := s.Config.GetConfigPath() configDirectory := s.Config.GetConfigPath()
paths := []string{ stashHomeDir := paths.GetStashHomeDirectory()
configDirectory,
paths.GetStashHomeDirectory(),
}
ffmpegPath, ffprobePath := ffmpeg.GetPaths(paths)
if ffmpegPath == "" || ffprobePath == "" { // prefer the configured paths
logger.Infof("couldn't find FFmpeg, attempting to download it") ffmpegPath := s.Config.GetFFMpegPath()
if err := ffmpeg.Download(ctx, configDirectory); err != nil { ffprobePath := s.Config.GetFFProbePath()
path, absErr := filepath.Abs(configDirectory)
if absErr != nil {
path = configDirectory
}
msg := `Unable to automatically download FFmpeg
Check the readme for download links. // ensure the paths are valid
The ffmpeg and ffprobe binaries should be placed in %s. if ffmpegPath != "" {
if err := ffmpeg.ValidateFFMpeg(ffmpegPath); err != nil {
` logger.Errorf("invalid ffmpeg path: %v", err)
logger.Errorf(msg, path) return
return err
} else {
// After download get new paths for ffmpeg and ffprobe
ffmpegPath, ffprobePath = ffmpeg.GetPaths(paths)
} }
} else {
ffmpegPath = ffmpeg.ResolveFFMpeg(configDirectory, stashHomeDir)
} }
s.FFMpeg = ffmpeg.NewEncoder(ffmpegPath) if ffprobePath != "" {
s.FFProbe = ffmpeg.FFProbe(ffprobePath) if err := ffmpeg.ValidateFFProbe(ffmpegPath); err != nil {
logger.Errorf("invalid ffprobe path: %v", err)
return
}
} else {
ffprobePath = ffmpeg.ResolveFFProbe(configDirectory, stashHomeDir)
}
s.FFMpeg.InitHWSupport(ctx) if ffmpegPath == "" {
s.RefreshStreamManager() logger.Warn("Couldn't find FFmpeg")
}
if ffprobePath == "" {
logger.Warn("Couldn't find FFProbe")
}
return nil if ffmpegPath != "" && ffprobePath != "" {
logger.Debugf("using ffmpeg: %s", ffmpegPath)
logger.Debugf("using ffprobe: %s", ffprobePath)
s.FFMpeg = ffmpeg.NewEncoder(ffmpegPath)
s.FFProbe = ffmpeg.FFProbe(ffprobePath)
s.FFMpeg.InitHWSupport(ctx)
}
} }

View file

@ -391,6 +391,16 @@ func (s *Manager) GetSystemStatus() *SystemStatus {
configFile := s.Config.GetConfigFile() configFile := s.Config.GetConfigFile()
ffmpegPath := ""
if s.FFMpeg != nil {
ffmpegPath = s.FFMpeg.Path()
}
ffprobePath := ""
if s.FFProbe != "" {
ffprobePath = s.FFProbe.Path()
}
return &SystemStatus{ return &SystemStatus{
Os: runtime.GOOS, Os: runtime.GOOS,
WorkingDir: workingDir, WorkingDir: workingDir,
@ -400,6 +410,8 @@ func (s *Manager) GetSystemStatus() *SystemStatus {
AppSchema: appSchema, AppSchema: appSchema,
Status: status, Status: status,
ConfigPath: &configFile, ConfigPath: &configFile,
FfmpegPath: &ffmpegPath,
FfprobePath: &ffprobePath,
} }
} }

View file

@ -13,6 +13,8 @@ type SystemStatus struct {
Os string `json:"os"` Os string `json:"os"`
WorkingDir string `json:"working_dir"` WorkingDir string `json:"working_dir"`
HomeDir string `json:"home_dir"` HomeDir string `json:"home_dir"`
FfmpegPath *string `json:"ffmpegPath"`
FfprobePath *string `json:"ffprobePath"`
} }
type SetupInput struct { type SetupInput struct {

View file

@ -0,0 +1,241 @@
package task
import (
"archive/zip"
"context"
"fmt"
"io"
"net/http"
"os"
"path"
"path/filepath"
"runtime"
"github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/job"
"github.com/stashapp/stash/pkg/logger"
)
type DownloadFFmpegJob struct {
ConfigDirectory string
OnComplete func(ctx context.Context)
urls []string
downloaded int
}
func (s *DownloadFFmpegJob) Execute(ctx context.Context, progress *job.Progress) error {
if err := s.download(ctx, progress); err != nil {
if job.IsCancelled(ctx) {
return nil
}
return err
}
if s.OnComplete != nil {
s.OnComplete(ctx)
}
return nil
}
func (s *DownloadFFmpegJob) setTaskProgress(taskProgress float64, progress *job.Progress) {
progress.SetPercent((float64(s.downloaded) + taskProgress) / float64(len(s.urls)))
}
func (s *DownloadFFmpegJob) download(ctx context.Context, progress *job.Progress) error {
s.urls = ffmpeg.GetFFmpegURL()
// set steps based on the number of URLs
for _, url := range s.urls {
err := s.downloadSingle(ctx, url, progress)
if err != nil {
return err
}
s.downloaded++
}
// validate that the urls contained what we needed
executables := []string{fsutil.GetExeName("ffmpeg"), fsutil.GetExeName("ffprobe")}
for _, executable := range executables {
_, err := os.Stat(filepath.Join(s.ConfigDirectory, executable))
if err != nil {
return err
}
}
return nil
}
type downloadProgressReader struct {
io.Reader
setProgress func(taskProgress float64)
bytesRead int64
total int64
}
func (r *downloadProgressReader) Read(p []byte) (int, error) {
read, err := r.Reader.Read(p)
if err == nil {
r.bytesRead += int64(read)
if r.total > 0 {
progress := float64(r.bytesRead) / float64(r.total)
r.setProgress(progress)
}
}
return read, err
}
func (s *DownloadFFmpegJob) downloadSingle(ctx context.Context, url string, progress *job.Progress) error {
if url == "" {
return fmt.Errorf("no ffmpeg url for this platform")
}
configDirectory := s.ConfigDirectory
// 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)
progress.ExecuteTask(fmt.Sprintf("Downloading %s", url), func() {
err = s.downloadFile(ctx, url, out, progress)
})
if err != nil {
return fmt.Errorf("failed to download ffmpeg from %s: %w", url, err)
}
logger.Info("Downloading complete")
logger.Infof("Unzipping %s...", archivePath)
progress.ExecuteTask(fmt.Sprintf("Unzipping %s", archivePath), func() {
err = s.unzip(archivePath)
})
if err != nil {
return fmt.Errorf("failed to unzip ffmpeg archive: %w", 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")
}
return nil
}
func (s *DownloadFFmpegJob) downloadFile(ctx context.Context, url string, out *os.File, progress *job.Progress) error {
// 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 := &downloadProgressReader{
Reader: resp.Body,
total: resp.ContentLength,
setProgress: func(taskProgress float64) {
s.setTaskProgress(taskProgress, progress)
},
}
// Write the response to the archive file location
if _, err := io.Copy(out, reader); err != nil {
return err
}
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" {
return fmt.Errorf("downloaded file is not a zip archive")
}
return nil
}
func (s *DownloadFFmpegJob) unzip(src 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(s.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
}

View file

@ -60,7 +60,7 @@ func (f *FFMpeg) InitHWSupport(ctx context.Context) {
cmd.Stderr = &stderr cmd.Stderr = &stderr
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
logger.Debugf("[InitHWSupport] error starting command: %w", err) logger.Debugf("[InitHWSupport] error starting command: %v", err)
continue continue
} }

View file

@ -1,179 +1,10 @@
package ffmpeg package ffmpeg
import ( import (
"archive/zip"
"context"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path"
"path/filepath"
"runtime" "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) { func GetFFmpegURL() []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 {
var urls []string var urls []string
switch runtime.GOOS { switch runtime.GOOS {
case "darwin": case "darwin":
@ -208,60 +39,3 @@ func getFFProbeFilename() string {
} }
return "ffprobe" 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
}

View file

@ -3,11 +3,96 @@ package ffmpeg
import ( import (
"context" "context"
"errors"
"fmt"
"os/exec" "os/exec"
"strings"
stashExec "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 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. // FFMpeg provides an interface to ffmpeg.
type FFMpeg struct { type FFMpeg struct {
ffmpeg string ffmpeg string
@ -27,3 +112,7 @@ func NewEncoder(ffmpegPath string) *FFMpeg {
func (f *FFMpeg) Command(ctx context.Context, args []string) *exec.Cmd { func (f *FFMpeg) Command(ctx context.Context, args []string) *exec.Cmd {
return stashExec.CommandContext(ctx, string(f.ffmpeg), args...) return stashExec.CommandContext(ctx, string(f.ffmpeg), args...)
} }
func (f *FFMpeg) Path() string {
return f.ffmpeg
}

View file

@ -2,17 +2,83 @@ package ffmpeg
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"math" "math"
"os" "os"
"os/exec"
"strconv" "strconv"
"strings" "strings"
"time" "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" "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. // VideoFile represents the ffprobe output for a video file.
type VideoFile struct { type VideoFile struct {
JSON FFProbeJSON JSON FFProbeJSON
@ -75,10 +141,14 @@ func (v *VideoFile) TranscodeScale(maxSize int) (int, int) {
// FFProbe provides an interface to the ffprobe executable. // FFProbe provides an interface to the ffprobe executable.
type FFProbe string type FFProbe string
func (f *FFProbe) Path() string {
return string(*f)
}
// NewVideoFile runs ffprobe on the given path and returns a VideoFile. // NewVideoFile runs ffprobe on the given path and returns a VideoFile.
func (f *FFProbe) NewVideoFile(videoPath string) (*VideoFile, error) { func (f *FFProbe) NewVideoFile(videoPath string) (*VideoFile, error) {
args := []string{"-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", "-show_error", videoPath} 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() out, err := cmd.Output()
if err != nil { if err != nil {
@ -97,7 +167,7 @@ func (f *FFProbe) NewVideoFile(videoPath string) (*VideoFile, error) {
// Used when the frame count is missing or incorrect. // Used when the frame count is missing or incorrect.
func (f *FFProbe) GetReadFrameCount(path string) (int64, error) { func (f *FFProbe) GetReadFrameCount(path string) (int64, error) {
args := []string{"-v", "quiet", "-print_format", "json", "-count_frames", "-show_format", "-show_streams", "-show_error", path} 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 { if err != nil {
return 0, fmt.Errorf("FFProbe encountered an error with <%s>.\nError JSON:\n%s\nError: %s", path, string(out), err.Error()) return 0, fmt.Errorf("FFProbe encountered an error with <%s>.\nError JSON:\n%s\nError: %s", path, string(out), err.Error())

View file

@ -6,6 +6,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime"
"strings" "strings"
"github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/logger"
@ -163,3 +164,12 @@ func SanitiseBasename(v string) string {
return strings.TrimSpace(v) 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
}

View file

@ -13,6 +13,8 @@ fragment ConfigGeneralData on ConfigGeneralResult {
cachePath cachePath
blobsPath blobsPath
blobsStorage blobsStorage
ffmpegPath
ffprobePath
calculateMD5 calculateMD5
videoFileNamingAlgorithm videoFileNamingAlgorithm
parallelTasks parallelTasks

View file

@ -6,6 +6,10 @@ mutation Migrate($input: MigrateInput!) {
migrate(input: $input) migrate(input: $input)
} }
mutation DownloadFFMpeg {
downloadFFMpeg
}
mutation ConfigureGeneral($input: ConfigGeneralInput!) { mutation ConfigureGeneral($input: ConfigGeneralInput!) {
configureGeneral(input: $input) { configureGeneral(input: $input) {
...ConfigGeneralData ...ConfigGeneralData

View file

@ -8,5 +8,7 @@ query SystemStatus {
os os
workingDir workingDir
homeDir homeDir
ffmpegPath
ffprobePath
} }
} }

View file

@ -7,6 +7,7 @@ import {
ModalSetting, ModalSetting,
NumberSetting, NumberSetting,
SelectSetting, SelectSetting,
Setting,
StringListSetting, StringListSetting,
StringSetting, StringSetting,
} from "./Inputs"; } from "./Inputs";
@ -15,12 +16,18 @@ import {
VideoPreviewInput, VideoPreviewInput,
VideoPreviewSettingsInput, VideoPreviewSettingsInput,
} from "./GeneratePreviewOptions"; } from "./GeneratePreviewOptions";
import { useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { Button } from "react-bootstrap";
import { useToast } from "src/hooks/Toast";
import { useHistory } from "react-router-dom";
export const SettingsConfigurationPanel: React.FC = () => { export const SettingsConfigurationPanel: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const Toast = useToast();
const history = useHistory();
const { general, loading, error, saveGeneral } = useSettings(); const { general, loading, error, saveGeneral } = useSettings();
const [mutateDownloadFFMpeg] = GQL.useDownloadFfMpegMutation();
const transcodeQualities = [ const transcodeQualities = [
GQL.StreamingResolutionEnum.Low, GQL.StreamingResolutionEnum.Low,
@ -107,6 +114,16 @@ export const SettingsConfigurationPanel: React.FC = () => {
return "blobs_storage_type.database"; return "blobs_storage_type.database";
} }
async function onDownloadFFMpeg() {
try {
await mutateDownloadFFMpeg();
// navigate to tasks page to see the progress
history.push("/settings?tab=tasks");
} catch (e) {
Toast.error(e);
}
}
if (error) return <h1>{error.message}</h1>; if (error) return <h1>{error.message}</h1>;
if (loading) return <LoadingIndicator />; if (loading) return <LoadingIndicator />;
@ -161,6 +178,35 @@ export const SettingsConfigurationPanel: React.FC = () => {
onChange={(v) => saveGeneral({ customPerformerImageLocation: v })} onChange={(v) => saveGeneral({ customPerformerImageLocation: v })}
/> />
<StringSetting
id="ffmpeg-path"
headingID="config.general.ffmpeg.ffmpeg_path.heading"
subHeadingID="config.general.ffmpeg.ffmpeg_path.description"
value={general.ffmpegPath ?? undefined}
onChange={(v) => saveGeneral({ ffmpegPath: v })}
/>
<StringSetting
id="ffprobe-path"
headingID="config.general.ffmpeg.ffprobe_path.heading"
subHeadingID="config.general.ffmpeg.ffprobe_path.description"
value={general.ffprobePath ?? undefined}
onChange={(v) => saveGeneral({ ffprobePath: v })}
/>
<Setting
heading={
<>
<FormattedMessage id="config.general.ffmpeg.download_ffmpeg.heading" />
</>
}
subHeadingID="config.general.ffmpeg.download_ffmpeg.description"
>
<Button variant="secondary" onClick={() => onDownloadFFMpeg()}>
<FormattedMessage id="config.general.ffmpeg.download_ffmpeg.heading" />
</Button>
</Setting>
<StringSetting <StringSetting
id="python-path" id="python-path"
headingID="config.general.python_path.heading" headingID="config.general.python_path.heading"

View file

@ -14,7 +14,7 @@ import {
useConfigureUI, useConfigureUI,
useSystemStatus, useSystemStatus,
} from "src/core/StashService"; } from "src/core/StashService";
import { Link, useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { ConfigurationContext } from "src/hooks/Config"; import { ConfigurationContext } from "src/hooks/Config";
import StashConfiguration from "../Settings/StashConfiguration"; import StashConfiguration from "../Settings/StashConfiguration";
import { Icon } from "../Shared/Icon"; import { Icon } from "../Shared/Icon";
@ -45,6 +45,7 @@ export const Setup: React.FC = () => {
const [blobsLocation, setBlobsLocation] = useState(""); const [blobsLocation, setBlobsLocation] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [setupError, setSetupError] = useState<string>(); const [setupError, setSetupError] = useState<string>();
const [downloadFFmpeg, setDownloadFFmpeg] = useState(true);
const intl = useIntl(); const intl = useIntl();
const history = useHistory(); const history = useHistory();
@ -57,6 +58,8 @@ export const Setup: React.FC = () => {
const { data: systemStatus, loading: statusLoading } = useSystemStatus(); const { data: systemStatus, loading: statusLoading } = useSystemStatus();
const status = systemStatus?.systemStatus; const status = systemStatus?.systemStatus;
const [mutateDownloadFFMpeg] = GQL.useDownloadFfMpegMutation();
const windows = status?.os === "windows"; const windows = status?.os === "windows";
const pathSep = windows ? "\\" : "/"; const pathSep = windows ? "\\" : "/";
const homeDir = windows ? "%USERPROFILE%" : "$HOME"; const homeDir = windows ? "%USERPROFILE%" : "$HOME";
@ -164,7 +167,7 @@ export const Setup: React.FC = () => {
); );
} }
function renderWelcomeSpecificConfig() { const WelcomeSpecificConfig = () => {
return ( return (
<> <>
<section> <section>
@ -197,9 +200,9 @@ export const Setup: React.FC = () => {
</section> </section>
</> </>
); );
} };
function renderWelcome() { function DefaultWelcomeStep() {
const homeDirPath = pathJoin(status?.homeDir ?? homeDir, ".stash"); const homeDirPath = pathJoin(status?.homeDir ?? homeDir, ".stash");
return ( return (
@ -523,7 +526,7 @@ export const Setup: React.FC = () => {
); );
} }
function renderSetPaths() { function SetPathsStep() {
return ( return (
<> <>
{maybeRenderStashAlert()} {maybeRenderStashAlert()}
@ -623,7 +626,7 @@ export const Setup: React.FC = () => {
} }
} }
function renderConfirm() { function ConfirmStep() {
let cfgDir: string; let cfgDir: string;
let config: string; let config: string;
if (overrideConfig) { if (overrideConfig) {
@ -735,7 +738,7 @@ export const Setup: React.FC = () => {
); );
} }
function renderError() { function ErrorStep() {
function onBackClick() { function onBackClick() {
setSetupError(undefined); setSetupError(undefined);
goBack(2); goBack(2);
@ -771,7 +774,15 @@ export const Setup: React.FC = () => {
); );
} }
function renderSuccess() { function onFinishClick() {
if ((!status?.ffmpegPath || !status?.ffprobePath) && downloadFFmpeg) {
mutateDownloadFFMpeg();
}
history.push("/settings?tab=library");
}
function SuccessStep() {
return ( return (
<> <>
<section> <section>
@ -793,6 +804,28 @@ export const Setup: React.FC = () => {
}} }}
/> />
</p> </p>
{!status?.ffmpegPath || !status?.ffprobePath ? (
<>
<Alert variant="warning text-center">
<FormattedMessage
id="setup.success.missing_ffmpeg"
values={{
code: (chunks: string) => <code>{chunks}</code>,
}}
/>
</Alert>
<p>
<Form.Check
id="download-ffmpeg"
checked={downloadFFmpeg}
label={intl.formatMessage({
id: "setup.success.download_ffmpeg",
})}
onChange={() => setDownloadFFmpeg(!downloadFFmpeg)}
/>
</p>
</>
) : null}
</section> </section>
<section> <section>
<h3> <h3>
@ -838,23 +871,21 @@ export const Setup: React.FC = () => {
</section> </section>
<section className="mt-5"> <section className="mt-5">
<div className="d-flex justify-content-center"> <div className="d-flex justify-content-center">
<Link to="/settings?tab=library"> <Button variant="success mx-2 p-5" onClick={() => onFinishClick()}>
<Button variant="success mx-2 p-5"> <FormattedMessage id="actions.finish" />
<FormattedMessage id="actions.finish" /> </Button>
</Button>
</Link>
</div> </div>
</section> </section>
</> </>
); );
} }
function renderFinish() { function FinishStep() {
if (setupError !== undefined) { if (setupError !== undefined) {
return renderError(); return <ErrorStep />;
} }
return renderSuccess(); return <SuccessStep />;
} }
// only display setup wizard if system is not setup // only display setup wizard if system is not setup
@ -868,10 +899,11 @@ export const Setup: React.FC = () => {
return <LoadingIndicator />; return <LoadingIndicator />;
} }
const welcomeStep = overrideConfig const WelcomeStep = overrideConfig
? renderWelcomeSpecificConfig ? WelcomeSpecificConfig
: renderWelcome; : DefaultWelcomeStep;
const steps = [welcomeStep, renderSetPaths, renderConfirm, renderFinish]; const steps = [WelcomeStep, SetPathsStep, ConfirmStep, FinishStep];
const Step = steps[step];
function renderCreating() { function renderCreating() {
return ( return (
@ -881,14 +913,6 @@ export const Setup: React.FC = () => {
id: "setup.creating.creating_your_system", id: "setup.creating.creating_your_system",
})} })}
/> />
<Alert variant="info text-center">
<FormattedMessage
id="setup.creating.ffmpeg_notice"
values={{
code: (chunks: string) => <code>{chunks}</code>,
}}
/>
</Alert>
</Card> </Card>
); );
} }
@ -901,7 +925,13 @@ export const Setup: React.FC = () => {
<h1 className="text-center"> <h1 className="text-center">
<FormattedMessage id="setup.stash_setup_wizard" /> <FormattedMessage id="setup.stash_setup_wizard" />
</h1> </h1>
{loading ? renderCreating() : <Card>{steps[step]()}</Card>} {loading ? (
renderCreating()
) : (
<Card>
<Step />
</Card>
)}
</Container> </Container>
); );
}; };

View file

@ -316,6 +316,18 @@
"excluded_video_patterns_desc": "Regexps of video files/paths to exclude from Scan and add to Clean", "excluded_video_patterns_desc": "Regexps of video files/paths to exclude from Scan and add to Clean",
"excluded_video_patterns_head": "Excluded Video Patterns", "excluded_video_patterns_head": "Excluded Video Patterns",
"ffmpeg": { "ffmpeg": {
"download_ffmpeg": {
"description": "Downloads FFmpeg into the configuration directory and clears the ffmpeg and ffprobe paths to resolve from the configuration directory.",
"heading": "Download FFmpeg"
},
"ffmpeg_path": {
"description": "Path to the ffmpeg executable (not just the folder). If empty, ffmpeg will be resolved from the environment via $PATH, the configuration directory, or from $HOME/.stash",
"heading": "FFmpeg Executable Path"
},
"ffprobe_path": {
"description": "Path to the ffprobe executable (not just the folder). If empty, ffprobe will be resolved from the environment via $PATH, the configuration directory, or from $HOME/.stash",
"heading": "FFprobe Executable Path"
},
"hardware_acceleration": { "hardware_acceleration": {
"desc": "Uses available hardware to encode video for live transcoding.", "desc": "Uses available hardware to encode video for live transcoding.",
"heading": "FFmpeg hardware encoding" "heading": "FFmpeg hardware encoding"
@ -1253,8 +1265,7 @@
"stash_library_directories": "Stash library directories" "stash_library_directories": "Stash library directories"
}, },
"creating": { "creating": {
"creating_your_system": "Creating your system", "creating_your_system": "Creating your system"
"ffmpeg_notice": "If <code>ffmpeg</code> is not yet in your paths, please be patient while stash downloads it. View the console output to see download progress."
}, },
"errors": { "errors": {
"something_went_wrong": "Oh no! Something went wrong!", "something_went_wrong": "Oh no! Something went wrong!",
@ -1303,9 +1314,11 @@
}, },
"stash_setup_wizard": "Stash Setup Wizard", "stash_setup_wizard": "Stash Setup Wizard",
"success": { "success": {
"download_ffmpeg": "Download ffmpeg",
"getting_help": "Getting help", "getting_help": "Getting help",
"help_links": "If you run into issues or have any questions or suggestions, feel free to open an issue in the {githubLink}, or ask the community in the {discordLink}.", "help_links": "If you run into issues or have any questions or suggestions, feel free to open an issue in the {githubLink}, or ask the community in the {discordLink}.",
"in_app_manual_explained": "You are encouraged to check out the in-app manual which can be accessed from the icon in the top-right corner of the screen that looks like this: {icon}", "in_app_manual_explained": "You are encouraged to check out the in-app manual which can be accessed from the icon in the top-right corner of the screen that looks like this: {icon}",
"missing_ffmpeg": "You are missing the required <code>ffmpeg</code> binary. You can automatically download it into your configuration directory by checking the box below. Alternatively, you can supply paths to the <code>ffmpeg</code> and <code>ffprobe</code> binaries in the System Settings. These binaries must be present for Stash to function.",
"next_config_step_one": "You will be taken to the Configuration page next. This page will allow you to customize what files to include and exclude, set a username and password to protect your system, and a whole bunch of other options.", "next_config_step_one": "You will be taken to the Configuration page next. This page will allow you to customize what files to include and exclude, set a username and password to protect your system, and a whole bunch of other options.",
"next_config_step_two": "When you are satisfied with these settings, you can begin scanning your content into Stash by clicking on <code>{localized_task}</code>, then <code>{localized_scan}</code>.", "next_config_step_two": "When you are satisfied with these settings, you can begin scanning your content into Stash by clicking on <code>{localized_task}</code>, then <code>{localized_scan}</code>.",
"open_collective": "Check out our {open_collective_link} to see how you can contribute to the continued development of Stash.", "open_collective": "Check out our {open_collective_link} to see how you can contribute to the continued development of Stash.",