stash/pkg/ffmpeg/ffprobe.go

379 lines
11 KiB
Go
Raw Normal View History

2019-02-09 12:30:49 +00:00
package ffmpeg
import (
"encoding/json"
"fmt"
"math"
"os"
"os/exec"
"path/filepath"
2019-02-09 12:30:49 +00:00
"strconv"
"strings"
"time"
"github.com/stashapp/stash/pkg/desktop"
"github.com/stashapp/stash/pkg/logger"
)
type Container string
type AudioCodec string
const (
Mp4 Container = "mp4"
M4v Container = "m4v"
Mov Container = "mov"
Wmv Container = "wmv"
Webm Container = "webm"
Matroska Container = "matroska"
Avi Container = "avi"
Flv Container = "flv"
Mpegts Container = "mpegts"
Aac AudioCodec = "aac"
Mp3 AudioCodec = "mp3"
Opus AudioCodec = "opus"
Vorbis AudioCodec = "vorbis"
MissingUnsupported AudioCodec = ""
Mp4Ffmpeg string = "mov,mp4,m4a,3gp,3g2,mj2" // browsers support all of them
M4vFfmpeg string = "mov,mp4,m4a,3gp,3g2,mj2" // so we don't care that ffmpeg
MovFfmpeg string = "mov,mp4,m4a,3gp,3g2,mj2" // can't differentiate between them
WmvFfmpeg string = "asf"
WebmFfmpeg string = "matroska,webm"
MatroskaFfmpeg string = "matroska,webm"
AviFfmpeg string = "avi"
FlvFfmpeg string = "flv"
MpegtsFfmpeg string = "mpegts"
H264 string = "h264"
H265 string = "h265" // found in rare cases from a faulty encoder
Hevc string = "hevc"
Vp8 string = "vp8"
Vp9 string = "vp9"
Mkv string = "mkv" // only used from the browser to indicate mkv support
Hls string = "hls" // only used from the browser to indicate hls support
MimeWebm string = "video/webm"
MimeMkv string = "video/x-matroska"
MimeMp4 string = "video/mp4"
MimeHLS string = "application/vnd.apple.mpegurl"
MimeMpegts string = "video/MP2T"
2019-02-09 12:30:49 +00:00
)
// only support H264 by default, since Safari does not support VP8/VP9
var DefaultSupportedCodecs = []string{H264, H265}
var validForH264Mkv = []Container{Mp4, Matroska}
var validForH264 = []Container{Mp4}
var validForH265Mkv = []Container{Mp4, Matroska}
var validForH265 = []Container{Mp4}
var validForVp8 = []Container{Webm}
var validForVp9Mkv = []Container{Webm, Matroska}
var validForVp9 = []Container{Webm}
var validForHevcMkv = []Container{Mp4, Matroska}
var validForHevc = []Container{Mp4}
var validAudioForMkv = []AudioCodec{Aac, Mp3, Vorbis, Opus}
var validAudioForWebm = []AudioCodec{Vorbis, Opus}
var validAudioForMp4 = []AudioCodec{Aac, Mp3}
// ContainerToFfprobe maps user readable container strings to ffprobe's format_name.
// On some formats ffprobe can't differentiate
var ContainerToFfprobe = map[Container]string{
Mp4: Mp4Ffmpeg,
M4v: M4vFfmpeg,
Mov: MovFfmpeg,
Wmv: WmvFfmpeg,
Webm: WebmFfmpeg,
Matroska: MatroskaFfmpeg,
Avi: AviFfmpeg,
Flv: FlvFfmpeg,
Mpegts: MpegtsFfmpeg,
}
var FfprobeToContainer = map[string]Container{
Mp4Ffmpeg: Mp4,
WmvFfmpeg: Wmv,
AviFfmpeg: Avi,
FlvFfmpeg: Flv,
MpegtsFfmpeg: Mpegts,
MatroskaFfmpeg: Matroska,
}
func MatchContainer(format string, filePath string) Container { // match ffprobe string to our Container
container := FfprobeToContainer[format]
if container == Matroska {
container = MagicContainer(filePath) // use magic number instead of ffprobe for matroska,webm
}
if container == "" { // if format is not in our Container list leave it as ffprobes reported format_name
container = Container(format)
}
return container
}
2019-02-10 05:30:54 +00:00
func IsValidCodec(codecName string, supportedCodecs []string) bool {
for _, c := range supportedCodecs {
2019-02-10 05:30:54 +00:00
if c == codecName {
return true
}
}
return false
2019-02-09 12:30:49 +00:00
}
Enable gocritic (#1848) * Don't capitalize local variables ValidCodecs -> validCodecs * Capitalize deprecation markers A deprecated marker should be capitalized. * Use re.MustCompile for static regexes If the regex fails to compile, it's a programmer error, and should be treated as such. The regex is entirely static. * Simplify else-if constructions Rewrite else { if cond {}} to else if cond {} * Use a switch statement to analyze formats Break an if-else chain. While here, simplify code flow. Also introduce a proper static error for unsupported image formats, paving the way for being able to check against the error. * Rewrite ifElse chains into switch statements The "Effective Go" https://golang.org/doc/effective_go#switch document mentions it is more idiomatic to write if-else chains as switches when it is possible. Find all the plain rewrite occurrences in the code base and rewrite. In some cases, the if-else chains are replaced by a switch scrutinizer. That is, the code sequence if x == 1 { .. } else if x == 2 { .. } else if x == 3 { ... } can be rewritten into switch x { case 1: .. case 2: .. case 3: .. } which is clearer for the compiler: it can decide if the switch is better served by a jump-table then a branch-chain. * Rewrite switches, introduce static errors Introduce two new static errors: * `ErrNotImplmented` * `ErrNotSupported` And use these rather than forming new generative errors whenever the code is called. Code can now test on the errors (since they are static and the pointers to them wont change). Also rewrite ifElse chains into switches in this part of the code base. * Introduce a StashBoxError in configuration Since all stashbox errors are the same, treat them as such in the code base. While here, rewrite an ifElse chain. In the future, it might be beneifical to refactor configuration errors into one error which can handle missing fields, which context the error occurs in and so on. But for now, try to get an overview of the error categories by hoisting them into static errors. * Get rid of an else-block in transaction handling If we succesfully `recover()`, we then always `panic()`. This means the rest of the code is not reachable, so we can avoid having an else-block here. It also solves an ifElse-chain style check in the code base. * Use strings.ReplaceAll Rewrite strings.Replace(s, o, n, -1) into strings.ReplaceAll(s, o, n) To make it consistent and clear that we are doing an all-replace in the string rather than replacing parts of it. It's more of a nitpick since there are no implementation differences: the stdlib implementation is just to supply -1. * Rewrite via gocritic's assignOp Statements of the form x = x + e is rewritten into x += e where applicable. * Formatting * Review comments handled Stash-box is a proper noun. Rewrite a switch into an if-chain which returns on the first error encountered. * Use context.TODO() over context.Background() Patch in the same vein as everything else: use the TODO() marker so we can search for it later and link it into the context tree/tentacle once it reaches down to this level in the code base. * Tell the linter to ignore a section in manager_tasks.go The section is less readable, so mark it with a nolint for now. Because the rewrite enables a ifElseChain, also mark that as nolint for now. * Use strings.ReplaceAll over strings.Replace * Apply an ifElse rewrite else { if .. { .. } } rewrite into else if { .. } * Use switch-statements over ifElseChains Rewrite chains of if-else into switch statements. Where applicable, add an early nil-guard to simplify case analysis. Also, in ScanTask's Start(..), invert the logic to outdent the whole block, and help the reader: if it's not a scene, the function flow is now far more local to the top of the function, and it's clear that the rest of the function has to do with scene management. * Enable gocritic on the code base. Disable appendAssign for now since we aren't passing that check yet. * Document the nolint additions * Document StashBoxBatchPerformerTagInput
2021-10-18 03:12:40 +00:00
func IsValidAudio(audio AudioCodec, validCodecs []AudioCodec) bool {
// if audio codec is missing or unsupported by ffmpeg we can't do anything about it
// report it as valid so that the file can at least be streamed directly if the video codec is supported
if audio == MissingUnsupported {
return true
}
Enable gocritic (#1848) * Don't capitalize local variables ValidCodecs -> validCodecs * Capitalize deprecation markers A deprecated marker should be capitalized. * Use re.MustCompile for static regexes If the regex fails to compile, it's a programmer error, and should be treated as such. The regex is entirely static. * Simplify else-if constructions Rewrite else { if cond {}} to else if cond {} * Use a switch statement to analyze formats Break an if-else chain. While here, simplify code flow. Also introduce a proper static error for unsupported image formats, paving the way for being able to check against the error. * Rewrite ifElse chains into switch statements The "Effective Go" https://golang.org/doc/effective_go#switch document mentions it is more idiomatic to write if-else chains as switches when it is possible. Find all the plain rewrite occurrences in the code base and rewrite. In some cases, the if-else chains are replaced by a switch scrutinizer. That is, the code sequence if x == 1 { .. } else if x == 2 { .. } else if x == 3 { ... } can be rewritten into switch x { case 1: .. case 2: .. case 3: .. } which is clearer for the compiler: it can decide if the switch is better served by a jump-table then a branch-chain. * Rewrite switches, introduce static errors Introduce two new static errors: * `ErrNotImplmented` * `ErrNotSupported` And use these rather than forming new generative errors whenever the code is called. Code can now test on the errors (since they are static and the pointers to them wont change). Also rewrite ifElse chains into switches in this part of the code base. * Introduce a StashBoxError in configuration Since all stashbox errors are the same, treat them as such in the code base. While here, rewrite an ifElse chain. In the future, it might be beneifical to refactor configuration errors into one error which can handle missing fields, which context the error occurs in and so on. But for now, try to get an overview of the error categories by hoisting them into static errors. * Get rid of an else-block in transaction handling If we succesfully `recover()`, we then always `panic()`. This means the rest of the code is not reachable, so we can avoid having an else-block here. It also solves an ifElse-chain style check in the code base. * Use strings.ReplaceAll Rewrite strings.Replace(s, o, n, -1) into strings.ReplaceAll(s, o, n) To make it consistent and clear that we are doing an all-replace in the string rather than replacing parts of it. It's more of a nitpick since there are no implementation differences: the stdlib implementation is just to supply -1. * Rewrite via gocritic's assignOp Statements of the form x = x + e is rewritten into x += e where applicable. * Formatting * Review comments handled Stash-box is a proper noun. Rewrite a switch into an if-chain which returns on the first error encountered. * Use context.TODO() over context.Background() Patch in the same vein as everything else: use the TODO() marker so we can search for it later and link it into the context tree/tentacle once it reaches down to this level in the code base. * Tell the linter to ignore a section in manager_tasks.go The section is less readable, so mark it with a nolint for now. Because the rewrite enables a ifElseChain, also mark that as nolint for now. * Use strings.ReplaceAll over strings.Replace * Apply an ifElse rewrite else { if .. { .. } } rewrite into else if { .. } * Use switch-statements over ifElseChains Rewrite chains of if-else into switch statements. Where applicable, add an early nil-guard to simplify case analysis. Also, in ScanTask's Start(..), invert the logic to outdent the whole block, and help the reader: if it's not a scene, the function flow is now far more local to the top of the function, and it's clear that the rest of the function has to do with scene management. * Enable gocritic on the code base. Disable appendAssign for now since we aren't passing that check yet. * Document the nolint additions * Document StashBoxBatchPerformerTagInput
2021-10-18 03:12:40 +00:00
for _, c := range validCodecs {
if c == audio {
return true
}
}
return false
}
func IsValidAudioForContainer(audio AudioCodec, format Container) bool {
switch format {
case Matroska:
return IsValidAudio(audio, validAudioForMkv)
case Webm:
return IsValidAudio(audio, validAudioForWebm)
case Mp4:
return IsValidAudio(audio, validAudioForMp4)
}
return false
}
func IsValidForContainer(format Container, validContainers []Container) bool {
for _, fmt := range validContainers {
if fmt == format {
return true
}
}
return false
}
// IsValidCombo checks if a codec/container combination is valid.
// Returns true on validity, false otherwise
func IsValidCombo(codecName string, format Container, supportedVideoCodecs []string) bool {
supportMKV := IsValidCodec(Mkv, supportedVideoCodecs)
supportHEVC := IsValidCodec(Hevc, supportedVideoCodecs)
switch codecName {
case H264:
if supportMKV {
return IsValidForContainer(format, validForH264Mkv)
}
return IsValidForContainer(format, validForH264)
case H265:
if supportMKV {
return IsValidForContainer(format, validForH265Mkv)
}
return IsValidForContainer(format, validForH265)
case Vp8:
return IsValidForContainer(format, validForVp8)
case Vp9:
if supportMKV {
return IsValidForContainer(format, validForVp9Mkv)
}
return IsValidForContainer(format, validForVp9)
case Hevc:
if supportHEVC {
if supportMKV {
return IsValidForContainer(format, validForHevcMkv)
}
return IsValidForContainer(format, validForHevc)
}
}
return false
}
func IsStreamable(videoCodec string, audioCodec AudioCodec, container Container) bool {
supportedVideoCodecs := DefaultSupportedCodecs
// check if the video codec matches the supported codecs
return IsValidCodec(videoCodec, supportedVideoCodecs) && IsValidCombo(videoCodec, container, supportedVideoCodecs) && IsValidAudioForContainer(audioCodec, container)
}
2019-02-10 05:30:54 +00:00
type VideoFile struct {
JSON FFProbeJSON
2019-02-10 05:30:54 +00:00
AudioStream *FFProbeStream
VideoStream *FFProbeStream
2019-02-09 12:30:49 +00:00
Path string
Title string
Comment string
2019-02-09 12:30:49 +00:00
Container string
Duration float64
StartTime float64
Bitrate int64
Size int64
CreationTime time.Time
VideoCodec string
VideoBitrate int64
Width int
Height int
FrameRate float64
Rotation int64
FrameCount int64
2019-02-09 12:30:49 +00:00
AudioCodec string
}
// FFProbe
type FFProbe string
2019-02-09 12:30:49 +00:00
// Execute exec command and bind result to struct.
func (f *FFProbe) NewVideoFile(videoPath string, stripExt bool) (*VideoFile, error) {
2019-02-10 05:30:54 +00:00
args := []string{"-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", "-show_error", videoPath}
cmd := exec.Command(string(*f), args...)
desktop.HideExecShell(cmd)
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 {
return nil, fmt.Errorf("error unmarshalling video data for <%s>: %s", videoPath, err.Error())
2019-02-09 12:30:49 +00:00
}
return parse(videoPath, probeJSON, stripExt)
2019-02-09 12:30:49 +00:00
}
// GetReadFrameCount counts the actual frames of the video file
func (f *FFProbe) GetReadFrameCount(vf *VideoFile) (int64, error) {
args := []string{"-v", "quiet", "-print_format", "json", "-count_frames", "-show_format", "-show_streams", "-show_error", vf.Path}
out, err := exec.Command(string(*f), args...).Output()
if err != nil {
return 0, fmt.Errorf("FFProbe encountered an error with <%s>.\nError JSON:\n%s\nError: %s", vf.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", vf.Path, err.Error())
}
fc, err := parse(vf.Path, probeJSON, false)
return fc.FrameCount, err
}
func parse(filePath string, probeJSON *FFProbeJSON, stripExt bool) (*VideoFile, error) {
2019-02-10 05:30:54 +00:00
if probeJSON == nil {
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
result.Title = probeJSON.Format.Tags.Title
if result.Title == "" {
// default title to filename
result.SetTitleFromPath(stripExt)
}
result.Comment = probeJSON.Format.Tags.Comment
2019-02-10 05:30:54 +00:00
result.Bitrate, _ = strconv.ParseInt(probeJSON.Format.BitRate, 10, 64)
2019-02-10 05:30:54 +00:00
result.Container = probeJSON.Format.FormatName
duration, _ := strconv.ParseFloat(probeJSON.Format.Duration, 64)
result.Duration = math.Round(duration*100) / 100
fileStat, err := os.Stat(filePath)
if err != nil {
Errcheck phase 1 (#1715) * Avoid redundant logging in migrations Return the error and let the caller handle the logging of the error if needed. While here, defer m.Close() to the function boundary. * Treat errors as values Use %v rather than %s and pass the errors directly. * Generate a wrapped error on stat-failure * Log 3 unchecked errors Rather than ignore errors, log them at the WARNING log level. The server has been functioning without these, so assume they are not at the ERROR level. * Propagate errors upward Failure in path generation was ignored. Propagate the errors upward the call stack, so it can be handled at the level of orchestration. * Warn on errors Log errors rather than quenching them. Errors are logged at the Warn-level for now. * Check error when creating test databases Use the builtin log package and stop the program fatally on error. * Add warnings to uncheck task errors Focus on the task system in a single commit, logging unchecked errors as warnings. * Warn-on-error in API routes Look through the API routes, and make sure errors are being logged if they occur. Prefer the Warn-log-level because none of these has proven to be fatal in the system up until now. * Propagate error when adding Util API * Propagate error on adding util API * Return unhandled error * JS log API: propagate and log errors * JS Plugins: log GQL addition failures. * Warn on failure to write to stdin * Warn on failure to stop task * Wrap viper.BindEnv The current viper code only errors if no name is provided, so it should never fail. Rewrite the code flow to factor through a panic-function. This removes error warnings from this part of the code. * Log errors in concurrency test If we can't initialize the configuration, treat the test as a failure. * Warn on errors in configuration code * Plug an unchecked error in gallery zip walking * Warn on screenshot serving failure * Warn on encoder screenshot failure * Warn on errors in path-handling code * Undo the errcheck on configurations for now. * Use one-line initializers where applicable rather than using err := f() if err!= nil { .. prefer the shorter if err := f(); err != nil { .. If f() isn't too long of a name, or wraps a function with a body.
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
}
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)
result.CreationTime = probeJSON.Format.Tags.CreationTime.Time
2019-02-10 05:30:54 +00:00
audioStream := result.GetAudioStream()
if audioStream != nil {
result.AudioCodec = audioStream.CodecName
result.AudioStream = audioStream
}
2019-02-09 12:30:49 +00:00
2019-02-10 05:30:54 +00:00
videoStream := result.GetVideoStream()
if videoStream != nil {
result.VideoStream = videoStream
2019-02-09 12:30:49 +00:00
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)
}
}
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)
}
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
}
}
2019-02-10 05:30:54 +00:00
return result, nil
}
func (v *VideoFile) GetAudioStream() *FFProbeStream {
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
2019-02-10 05:30:54 +00:00
func (v *VideoFile) GetVideoStream() *FFProbeStream {
index := v.getStreamIndex("video", v.JSON)
if index != -1 {
return &v.JSON.Streams[index]
}
return nil
2019-02-09 12:30:49 +00:00
}
func (v *VideoFile) getStreamIndex(fileType string, probeJSON FFProbeJSON) int {
for i, stream := range probeJSON.Streams {
2019-02-09 12:30:49 +00:00
if stream.CodecType == fileType {
return i
}
}
return -1
}
2019-10-12 08:20:27 +00:00
func (v *VideoFile) SetTitleFromPath(stripExtension bool) {
2019-10-12 08:20:27 +00:00
v.Title = filepath.Base(v.Path)
if stripExtension {
ext := filepath.Ext(v.Title)
v.Title = strings.TrimSuffix(v.Title, ext)
}
2019-10-12 08:20:27 +00:00
}