2019-02-09 12:30:49 +00:00
|
|
|
package ffmpeg
|
|
|
|
|
|
|
|
import (
|
|
|
|
"encoding/json"
|
2024-03-21 01:43:40 +00:00
|
|
|
"errors"
|
2019-02-09 12:30:49 +00:00
|
|
|
"fmt"
|
|
|
|
"math"
|
|
|
|
"os"
|
2024-03-21 01:43:40 +00:00
|
|
|
"os/exec"
|
2019-02-09 12:30:49 +00:00
|
|
|
"strconv"
|
|
|
|
"strings"
|
|
|
|
"time"
|
2020-04-09 22:38:34 +00:00
|
|
|
|
2024-03-21 01:43:40 +00:00
|
|
|
stashExec "github.com/stashapp/stash/pkg/exec"
|
|
|
|
"github.com/stashapp/stash/pkg/fsutil"
|
2020-05-26 23:33:49 +00:00
|
|
|
"github.com/stashapp/stash/pkg/logger"
|
2020-04-09 22:38:34 +00:00
|
|
|
)
|
|
|
|
|
2024-03-21 01:43:40 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2022-04-18 00:50:10 +00:00
|
|
|
// VideoFile represents the ffprobe output for a video file.
|
2019-02-10 05:30:54 +00:00
|
|
|
type VideoFile struct {
|
2019-02-14 22:53:32 +00:00
|
|
|
JSON FFProbeJSON
|
2019-02-10 05:30:54 +00:00
|
|
|
AudioStream *FFProbeStream
|
|
|
|
VideoStream *FFProbeStream
|
2019-02-09 12:30:49 +00:00
|
|
|
|
2022-11-21 06:21:27 +00:00
|
|
|
Path string
|
|
|
|
Title string
|
|
|
|
Comment string
|
|
|
|
Container string
|
|
|
|
// FileDuration is the declared (meta-data) duration of the *file*.
|
|
|
|
// In most cases (sprites, previews, etc.) we actually care about the duration of the video stream specifically,
|
|
|
|
// because those two can differ slightly (e.g. audio stream longer than the video stream, making the whole file
|
|
|
|
// longer).
|
|
|
|
FileDuration float64
|
|
|
|
VideoStreamDuration float64
|
|
|
|
StartTime float64
|
|
|
|
Bitrate int64
|
|
|
|
Size int64
|
|
|
|
CreationTime time.Time
|
2019-02-09 12:30:49 +00:00
|
|
|
|
|
|
|
VideoCodec string
|
|
|
|
VideoBitrate int64
|
|
|
|
Width int
|
|
|
|
Height int
|
|
|
|
FrameRate float64
|
|
|
|
Rotation int64
|
2022-01-04 02:46:53 +00:00
|
|
|
FrameCount int64
|
2019-02-09 12:30:49 +00:00
|
|
|
|
|
|
|
AudioCodec string
|
|
|
|
}
|
|
|
|
|
2022-04-18 00:50:10 +00:00
|
|
|
// TranscodeScale calculates the dimension scaling for a transcode, where maxSize is the maximum size of the longest dimension of the input video.
|
|
|
|
// If no scaling is required, then returns 0, 0.
|
|
|
|
// Returns -2 for the dimension that will scale to maintain aspect ratio.
|
|
|
|
func (v *VideoFile) TranscodeScale(maxSize int) (int, int) {
|
|
|
|
// get the smaller dimension of the video file
|
|
|
|
videoSize := v.Height
|
|
|
|
if v.Width < videoSize {
|
|
|
|
videoSize = v.Width
|
|
|
|
}
|
|
|
|
|
|
|
|
// if our streaming resolution is larger than the video dimension
|
|
|
|
// or we are streaming the original resolution, then just set the
|
|
|
|
// input width
|
|
|
|
if maxSize >= videoSize || maxSize == 0 {
|
|
|
|
return 0, 0
|
|
|
|
}
|
|
|
|
|
|
|
|
// we're setting either the width or height
|
|
|
|
// we'll set the smaller dimesion
|
|
|
|
if v.Width > v.Height {
|
|
|
|
// set the height
|
|
|
|
return -2, maxSize
|
|
|
|
}
|
|
|
|
|
|
|
|
return maxSize, -2
|
|
|
|
}
|
|
|
|
|
|
|
|
// FFProbe provides an interface to the ffprobe executable.
|
2021-10-14 23:39:48 +00:00
|
|
|
type FFProbe string
|
|
|
|
|
2024-03-21 01:43:40 +00:00
|
|
|
func (f *FFProbe) Path() string {
|
|
|
|
return string(*f)
|
|
|
|
}
|
|
|
|
|
2022-04-18 00:50:10 +00:00
|
|
|
// NewVideoFile runs ffprobe on the given path and returns a VideoFile.
|
|
|
|
func (f *FFProbe) NewVideoFile(videoPath string) (*VideoFile, error) {
|
2019-02-10 05:30:54 +00:00
|
|
|
args := []string{"-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", "-show_error", videoPath}
|
2024-03-21 01:43:40 +00:00
|
|
|
cmd := stashExec.Command(string(*f), args...)
|
2022-02-03 00:20:34 +00:00
|
|
|
out, err := cmd.Output()
|
2019-02-09 12:30:49 +00:00
|
|
|
|
|
|
|
if err != nil {
|
2019-02-10 05:30:54 +00:00
|
|
|
return nil, fmt.Errorf("FFProbe encountered an error with <%s>.\nError JSON:\n%s\nError: %s", videoPath, string(out), err.Error())
|
2019-02-09 12:30:49 +00:00
|
|
|
}
|
|
|
|
|
2019-02-10 05:30:54 +00:00
|
|
|
probeJSON := &FFProbeJSON{}
|
2019-02-09 12:30:49 +00:00
|
|
|
if err := json.Unmarshal(out, probeJSON); err != nil {
|
2021-09-08 01:23:10 +00:00
|
|
|
return nil, fmt.Errorf("error unmarshalling video data for <%s>: %s", videoPath, err.Error())
|
2019-02-09 12:30:49 +00:00
|
|
|
}
|
|
|
|
|
2022-04-18 00:50:10 +00:00
|
|
|
return parse(videoPath, probeJSON)
|
2019-02-09 12:30:49 +00:00
|
|
|
}
|
|
|
|
|
2022-04-18 00:50:10 +00:00
|
|
|
// GetReadFrameCount counts the actual frames of the video file.
|
|
|
|
// Used when the frame count is missing or incorrect.
|
|
|
|
func (f *FFProbe) GetReadFrameCount(path string) (int64, error) {
|
|
|
|
args := []string{"-v", "quiet", "-print_format", "json", "-count_frames", "-show_format", "-show_streams", "-show_error", path}
|
2024-03-21 01:43:40 +00:00
|
|
|
out, err := stashExec.Command(string(*f), args...).Output()
|
2022-01-04 02:46:53 +00:00
|
|
|
|
|
|
|
if err != nil {
|
2022-04-18 00:50:10 +00:00
|
|
|
return 0, fmt.Errorf("FFProbe encountered an error with <%s>.\nError JSON:\n%s\nError: %s", path, string(out), err.Error())
|
2022-01-04 02:46:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
probeJSON := &FFProbeJSON{}
|
|
|
|
if err := json.Unmarshal(out, probeJSON); err != nil {
|
2022-04-18 00:50:10 +00:00
|
|
|
return 0, fmt.Errorf("error unmarshalling video data for <%s>: %s", path, err.Error())
|
2022-01-04 02:46:53 +00:00
|
|
|
}
|
|
|
|
|
2022-04-18 00:50:10 +00:00
|
|
|
fc, err := parse(path, probeJSON)
|
2022-01-04 02:46:53 +00:00
|
|
|
return fc.FrameCount, err
|
|
|
|
}
|
|
|
|
|
2022-04-18 00:50:10 +00:00
|
|
|
func parse(filePath string, probeJSON *FFProbeJSON) (*VideoFile, error) {
|
2019-02-10 05:30:54 +00:00
|
|
|
if probeJSON == nil {
|
2020-10-11 01:02:41 +00:00
|
|
|
return nil, fmt.Errorf("failed to get ffprobe json for <%s>", filePath)
|
2019-02-10 05:30:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
result := &VideoFile{}
|
|
|
|
result.JSON = *probeJSON
|
|
|
|
|
|
|
|
if result.JSON.Error.Code != 0 {
|
|
|
|
return nil, fmt.Errorf("ffprobe error code %d: %s", result.JSON.Error.Code, result.JSON.Error.String)
|
|
|
|
}
|
2019-02-09 12:30:49 +00:00
|
|
|
|
|
|
|
result.Path = filePath
|
2019-08-15 22:47:35 +00:00
|
|
|
result.Title = probeJSON.Format.Tags.Title
|
|
|
|
|
|
|
|
result.Comment = probeJSON.Format.Tags.Comment
|
2019-02-10 05:30:54 +00:00
|
|
|
result.Bitrate, _ = strconv.ParseInt(probeJSON.Format.BitRate, 10, 64)
|
2022-01-04 02:46:53 +00:00
|
|
|
|
2019-02-10 05:30:54 +00:00
|
|
|
result.Container = probeJSON.Format.FormatName
|
|
|
|
duration, _ := strconv.ParseFloat(probeJSON.Format.Duration, 64)
|
2022-11-21 06:21:27 +00:00
|
|
|
result.FileDuration = math.Round(duration*100) / 100
|
2020-05-26 23:33:49 +00:00
|
|
|
fileStat, err := os.Stat(filePath)
|
|
|
|
if err != nil {
|
2021-09-20 23:34:25 +00:00
|
|
|
statErr := fmt.Errorf("error statting file <%s>: %w", filePath, err)
|
|
|
|
logger.Errorf("%v", statErr)
|
|
|
|
return nil, statErr
|
2020-05-26 23:33:49 +00:00
|
|
|
}
|
2019-02-09 12:30:49 +00:00
|
|
|
result.Size = fileStat.Size()
|
2019-02-10 05:30:54 +00:00
|
|
|
result.StartTime, _ = strconv.ParseFloat(probeJSON.Format.StartTime, 64)
|
2019-03-09 18:14:55 +00:00
|
|
|
result.CreationTime = probeJSON.Format.Tags.CreationTime.Time
|
2019-02-10 05:30:54 +00:00
|
|
|
|
2022-03-17 00:33:59 +00:00
|
|
|
audioStream := result.getAudioStream()
|
2019-02-10 05:30:54 +00:00
|
|
|
if audioStream != nil {
|
|
|
|
result.AudioCodec = audioStream.CodecName
|
|
|
|
result.AudioStream = audioStream
|
|
|
|
}
|
2019-02-09 12:30:49 +00:00
|
|
|
|
2022-03-17 00:33:59 +00:00
|
|
|
videoStream := result.getVideoStream()
|
2019-02-10 05:30:54 +00:00
|
|
|
if videoStream != nil {
|
|
|
|
result.VideoStream = videoStream
|
2019-02-09 12:30:49 +00:00
|
|
|
result.VideoCodec = videoStream.CodecName
|
2022-01-04 02:46:53 +00:00
|
|
|
result.FrameCount, _ = strconv.ParseInt(videoStream.NbFrames, 10, 64)
|
|
|
|
if videoStream.NbReadFrames != "" { // if ffprobe counted the frames use that instead
|
|
|
|
fc, _ := strconv.ParseInt(videoStream.NbReadFrames, 10, 64)
|
|
|
|
if fc > 0 {
|
|
|
|
result.FrameCount, _ = strconv.ParseInt(videoStream.NbReadFrames, 10, 64)
|
|
|
|
} else {
|
|
|
|
logger.Debugf("[ffprobe] <%s> invalid Read Frames count", videoStream.NbReadFrames)
|
|
|
|
}
|
|
|
|
}
|
2019-02-09 12:30:49 +00:00
|
|
|
result.VideoBitrate, _ = strconv.ParseInt(videoStream.BitRate, 10, 64)
|
|
|
|
var framerate float64
|
|
|
|
if strings.Contains(videoStream.AvgFrameRate, "/") {
|
|
|
|
frameRateSplit := strings.Split(videoStream.AvgFrameRate, "/")
|
|
|
|
numerator, _ := strconv.ParseFloat(frameRateSplit[0], 64)
|
|
|
|
denominator, _ := strconv.ParseFloat(frameRateSplit[1], 64)
|
|
|
|
framerate = numerator / denominator
|
|
|
|
} else {
|
|
|
|
framerate, _ = strconv.ParseFloat(videoStream.AvgFrameRate, 64)
|
|
|
|
}
|
2022-07-13 06:30:54 +00:00
|
|
|
if math.IsNaN(framerate) {
|
|
|
|
framerate = 0
|
|
|
|
}
|
2019-02-14 22:53:32 +00:00
|
|
|
result.FrameRate = math.Round(framerate*100) / 100
|
2019-02-09 12:30:49 +00:00
|
|
|
if rotate, err := strconv.ParseInt(videoStream.Tags.Rotate, 10, 64); err == nil && rotate != 180 {
|
|
|
|
result.Width = videoStream.Height
|
|
|
|
result.Height = videoStream.Width
|
|
|
|
} else {
|
|
|
|
result.Width = videoStream.Width
|
|
|
|
result.Height = videoStream.Height
|
|
|
|
}
|
2022-11-21 06:21:27 +00:00
|
|
|
result.VideoStreamDuration, err = strconv.ParseFloat(videoStream.Duration, 64)
|
|
|
|
if err != nil {
|
|
|
|
// Revert to the historical behaviour, which is still correct in the vast majority of cases.
|
|
|
|
result.VideoStreamDuration = result.FileDuration
|
|
|
|
}
|
2019-02-09 12:30:49 +00:00
|
|
|
}
|
|
|
|
|
2019-02-10 05:30:54 +00:00
|
|
|
return result, nil
|
|
|
|
}
|
|
|
|
|
2022-03-17 00:33:59 +00:00
|
|
|
func (v *VideoFile) getAudioStream() *FFProbeStream {
|
2019-02-10 05:30:54 +00:00
|
|
|
index := v.getStreamIndex("audio", v.JSON)
|
|
|
|
if index != -1 {
|
|
|
|
return &v.JSON.Streams[index]
|
2019-02-09 12:30:49 +00:00
|
|
|
}
|
2019-02-10 05:30:54 +00:00
|
|
|
return nil
|
|
|
|
}
|
2019-02-09 12:30:49 +00:00
|
|
|
|
2022-03-17 00:33:59 +00:00
|
|
|
func (v *VideoFile) getVideoStream() *FFProbeStream {
|
2019-02-10 05:30:54 +00:00
|
|
|
index := v.getStreamIndex("video", v.JSON)
|
|
|
|
if index != -1 {
|
|
|
|
return &v.JSON.Streams[index]
|
|
|
|
}
|
|
|
|
return nil
|
2019-02-09 12:30:49 +00:00
|
|
|
}
|
|
|
|
|
2019-02-14 22:53:32 +00:00
|
|
|
func (v *VideoFile) getStreamIndex(fileType string, probeJSON FFProbeJSON) int {
|
2022-07-22 07:21:39 +00:00
|
|
|
ret := -1
|
2019-02-14 22:53:32 +00:00
|
|
|
for i, stream := range probeJSON.Streams {
|
2022-07-22 07:21:39 +00:00
|
|
|
// skip cover art/thumbnails
|
|
|
|
if stream.CodecType == fileType && stream.Disposition.AttachedPic == 0 {
|
|
|
|
// prefer default stream
|
|
|
|
if stream.Disposition.Default == 1 {
|
|
|
|
return i
|
|
|
|
}
|
|
|
|
|
|
|
|
// backwards compatible behaviour - fallback to first matching stream
|
|
|
|
if ret == -1 {
|
|
|
|
ret = i
|
|
|
|
}
|
2019-02-09 12:30:49 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-07-22 07:21:39 +00:00
|
|
|
return ret
|
2019-02-14 22:53:32 +00:00
|
|
|
}
|