2019-02-09 12:30:49 +00:00
package ffmpeg
import (
2024-09-03 06:33:15 +00:00
"bytes"
2019-02-09 12:30:49 +00:00
"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"
2024-09-03 06:33:15 +00:00
"regexp"
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-09-03 06:33:15 +00:00
const minimumFFProbeVersion = 5
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.
2024-09-03 06:33:15 +00:00
type FFProbe struct {
path string
version Version
}
2021-10-14 23:39:48 +00:00
2024-03-21 01:43:40 +00:00
func ( f * FFProbe ) Path ( ) string {
2024-09-03 06:33:15 +00:00
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
2024-03-21 01:43:40 +00:00
}
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 ) {
2024-09-03 06:33:15 +00:00
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 ... )
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-09-03 06:33:15 +00:00
out , err := stashExec . Command ( f . path , 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
2024-09-03 06:33:15 +00:00
result . Width = videoStream . Width
result . Height = videoStream . Height
if isRotated ( videoStream ) {
2019-02-09 12:30:49 +00:00
result . Width = videoStream . Height
result . Height = videoStream . Width
}
2024-09-03 06:33:15 +00:00
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
}
2024-09-03 06:33:15 +00:00
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
}
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
}