mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01: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:
parent
a369613d42
commit
7086109d78
22 changed files with 694 additions and 297 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
241
internal/manager/task/download_ffmpeg.go
Normal file
241
internal/manager/task/download_ffmpeg.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ fragment ConfigGeneralData on ConfigGeneralResult {
|
||||||
cachePath
|
cachePath
|
||||||
blobsPath
|
blobsPath
|
||||||
blobsStorage
|
blobsStorage
|
||||||
|
ffmpegPath
|
||||||
|
ffprobePath
|
||||||
calculateMD5
|
calculateMD5
|
||||||
videoFileNamingAlgorithm
|
videoFileNamingAlgorithm
|
||||||
parallelTasks
|
parallelTasks
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -8,5 +8,7 @@ query SystemStatus {
|
||||||
os
|
os
|
||||||
workingDir
|
workingDir
|
||||||
homeDir
|
homeDir
|
||||||
|
ffmpegPath
|
||||||
|
ffprobePath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue