stash/pkg/ffmpeg/ffprobe.go

403 lines
10 KiB
Go

package ffmpeg
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"math"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
stashExec "github.com/stashapp/stash/pkg/exec"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
)
const minimumFFProbeVersion = 5
func ValidateFFProbe(ffprobePath string) error {
cmd := stashExec.Command(ffprobePath, "-h")
bytes, err := cmd.CombinedOutput()
output := string(bytes)
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return fmt.Errorf("error running ffprobe: %v", output)
}
return fmt.Errorf("error running ffprobe: %v", err)
}
return nil
}
func LookPathFFProbe() string {
ret, _ := exec.LookPath(getFFProbeFilename())
if ret != "" {
if err := ValidateFFProbe(ret); err != nil {
logger.Warnf("ffprobe found in PATH (%s), but it is missing required flags: %v", ret, err)
ret = ""
}
}
return ret
}
func FindFFProbe(path string) string {
ret := fsutil.FindInPaths([]string{path}, getFFProbeFilename())
if ret != "" {
if err := ValidateFFProbe(ret); err != nil {
logger.Warnf("ffprobe found (%s), but it is missing required flags: %v", ret, err)
ret = ""
}
}
return ret
}
// ResolveFFMpeg attempts to resolve the path to the ffmpeg executable.
// It first looks in the provided path, then resolves from the environment, and finally looks in the fallback path.
// Returns an empty string if a valid ffmpeg cannot be found.
func ResolveFFProbe(path string, fallbackPath string) string {
// look in the provided path first
ret := FindFFProbe(path)
if ret != "" {
return ret
}
// then resolve from the environment
ret = LookPathFFProbe()
if ret != "" {
return ret
}
// finally, look in the fallback path
ret = FindFFProbe(fallbackPath)
return ret
}
// VideoFile represents the ffprobe output for a video file.
type VideoFile struct {
JSON FFProbeJSON
AudioStream *FFProbeStream
VideoStream *FFProbeStream
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
VideoCodec string
VideoBitrate int64
Width int
Height int
FrameRate float64
Rotation int64
FrameCount int64
AudioCodec string
}
// 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.
type FFProbe struct {
path string
version Version
}
func (f *FFProbe) Path() string {
return f.path
}
var ffprobeVersionRE = regexp.MustCompile(`ffprobe version n?((\d+)\.(\d+)(?:\.(\d+))?)`)
func (f *FFProbe) getVersion() error {
var args []string
args = append(args, "-version")
cmd := stashExec.Command(f.path, args...)
var stdout bytes.Buffer
cmd.Stdout = &stdout
var err error
if err = cmd.Run(); err != nil {
return err
}
stdoutStr := stdout.String()
match := ffprobeVersionRE.FindStringSubmatchIndex(stdoutStr)
if match == nil {
return errors.New("version string malformed")
}
majorS := stdoutStr[match[4]:match[5]]
minorS := stdoutStr[match[6]:match[7]]
// patch is optional
var patchS string
if match[8] != -1 && match[9] != -1 {
patchS = stdoutStr[match[8]:match[9]]
}
if i, err := strconv.Atoi(majorS); err == nil {
f.version.major = i
}
if i, err := strconv.Atoi(minorS); err == nil {
f.version.minor = i
}
if i, err := strconv.Atoi(patchS); err == nil {
f.version.patch = i
}
logger.Debugf("FFProbe version %s detected", f.version.String())
return nil
}
// Creates a new FFProbe instance.
func NewFFProbe(path string) *FFProbe {
ret := &FFProbe{
path: path,
}
if err := ret.getVersion(); err != nil {
logger.Warnf("FFProbe version not detected %v", err)
}
if ret.version.major != 0 && ret.version.major < minimumFFProbeVersion {
logger.Warnf("FFProbe version %d.%d.%d detected, but %d.x or later is required", ret.version.major, ret.version.minor, ret.version.patch, minimumFFProbeVersion)
}
return ret
}
// NewVideoFile runs ffprobe on the given path and returns a VideoFile.
func (f *FFProbe) NewVideoFile(videoPath string) (*VideoFile, error) {
args := []string{
"-v",
"quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
"-show_error",
}
// show_entries stream_side_data=rotation requires 5.x or later ffprobe
if f.version.major >= 5 {
args = append(args, "-show_entries", "stream_side_data=rotation")
}
args = append(args, videoPath)
cmd := stashExec.Command(f.path, args...)
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("FFProbe encountered an error with <%s>.\nError JSON:\n%s\nError: %s", videoPath, string(out), err.Error())
}
probeJSON := &FFProbeJSON{}
if err := json.Unmarshal(out, probeJSON); err != nil {
return nil, fmt.Errorf("error unmarshalling video data for <%s>: %s", videoPath, err.Error())
}
return parse(videoPath, probeJSON)
}
// 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}
out, err := stashExec.Command(f.path, args...).Output()
if err != nil {
return 0, fmt.Errorf("FFProbe encountered an error with <%s>.\nError JSON:\n%s\nError: %s", path, string(out), err.Error())
}
probeJSON := &FFProbeJSON{}
if err := json.Unmarshal(out, probeJSON); err != nil {
return 0, fmt.Errorf("error unmarshalling video data for <%s>: %s", path, err.Error())
}
fc, err := parse(path, probeJSON)
return fc.FrameCount, err
}
func parse(filePath string, probeJSON *FFProbeJSON) (*VideoFile, error) {
if probeJSON == nil {
return nil, fmt.Errorf("failed to get ffprobe json for <%s>", filePath)
}
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)
}
result.Path = filePath
result.Title = probeJSON.Format.Tags.Title
result.Comment = probeJSON.Format.Tags.Comment
result.Bitrate, _ = strconv.ParseInt(probeJSON.Format.BitRate, 10, 64)
result.Container = probeJSON.Format.FormatName
duration, _ := strconv.ParseFloat(probeJSON.Format.Duration, 64)
result.FileDuration = math.Round(duration*100) / 100
fileStat, err := os.Stat(filePath)
if err != nil {
statErr := fmt.Errorf("error statting file <%s>: %w", filePath, err)
logger.Errorf("%v", statErr)
return nil, statErr
}
result.Size = fileStat.Size()
result.StartTime, _ = strconv.ParseFloat(probeJSON.Format.StartTime, 64)
result.CreationTime = probeJSON.Format.Tags.CreationTime.Time
audioStream := result.getAudioStream()
if audioStream != nil {
result.AudioCodec = audioStream.CodecName
result.AudioStream = audioStream
}
videoStream := result.getVideoStream()
if videoStream != nil {
result.VideoStream = videoStream
result.VideoCodec = videoStream.CodecName
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)
}
}
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)
}
if math.IsNaN(framerate) {
framerate = 0
}
result.FrameRate = math.Round(framerate*100) / 100
result.Width = videoStream.Width
result.Height = videoStream.Height
if isRotated(videoStream) {
result.Width = videoStream.Height
result.Height = videoStream.Width
}
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
}
}
return result, nil
}
func isRotated(s *FFProbeStream) bool {
rotate, _ := strconv.ParseInt(s.Tags.Rotate, 10, 64)
if rotate != 180 && rotate != 0 {
return true
}
for _, sd := range s.SideDataList {
r := sd.Rotation
if r < 0 {
r = -r
}
if r != 0 && r != 180 {
return true
}
}
return false
}
func (v *VideoFile) getAudioStream() *FFProbeStream {
index := v.getStreamIndex("audio", v.JSON)
if index != -1 {
return &v.JSON.Streams[index]
}
return nil
}
func (v *VideoFile) getVideoStream() *FFProbeStream {
index := v.getStreamIndex("video", v.JSON)
if index != -1 {
return &v.JSON.Streams[index]
}
return nil
}
func (v *VideoFile) getStreamIndex(fileType string, probeJSON FFProbeJSON) int {
ret := -1
for i, stream := range probeJSON.Streams {
// 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
}
}
}
return ret
}