2019-02-09 12:30:49 +00:00
|
|
|
package ffmpeg
|
|
|
|
|
|
|
|
import (
|
|
|
|
"encoding/json"
|
|
|
|
"fmt"
|
|
|
|
"math"
|
|
|
|
"os"
|
|
|
|
"os/exec"
|
2019-08-15 22:47:35 +00:00
|
|
|
"path/filepath"
|
2019-02-09 12:30:49 +00:00
|
|
|
"strconv"
|
|
|
|
"strings"
|
|
|
|
"time"
|
2020-04-09 22:38:34 +00:00
|
|
|
|
2020-05-26 23:33:49 +00:00
|
|
|
"github.com/stashapp/stash/pkg/logger"
|
2020-04-09 22:38:34 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
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"
|
2020-07-23 01:56:08 +00:00
|
|
|
Mkv string = "mkv" // only used from the browser to indicate mkv support
|
|
|
|
Hls string = "hls" // only used from the browser to indicate hls support
|
2020-04-09 22:38:34 +00:00
|
|
|
MimeWebm string = "video/webm"
|
|
|
|
MimeMkv string = "video/x-matroska"
|
2020-07-23 01:56:08 +00:00
|
|
|
MimeMp4 string = "video/mp4"
|
|
|
|
MimeHLS string = "application/vnd.apple.mpegurl"
|
|
|
|
MimeMpegts string = "video/MP2T"
|
2019-02-09 12:30:49 +00:00
|
|
|
)
|
|
|
|
|
2020-07-23 01:56:08 +00:00
|
|
|
// only support H264 by default, since Safari does not support VP8/VP9
|
|
|
|
var DefaultSupportedCodecs = []string{H264, H265}
|
2020-04-09 22:38:34 +00:00
|
|
|
|
|
|
|
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}
|
|
|
|
|
|
|
|
//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
|
|
|
|
2020-07-23 01:56:08 +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
|
|
|
}
|
|
|
|
|
2020-04-09 22:38:34 +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
|
|
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
|
//extend stream validation check to take into account container
|
2020-07-23 01:56:08 +00:00
|
|
|
func IsValidCombo(codecName string, format Container, supportedVideoCodecs []string) bool {
|
|
|
|
supportMKV := IsValidCodec(Mkv, supportedVideoCodecs)
|
|
|
|
supportHEVC := IsValidCodec(Hevc, supportedVideoCodecs)
|
|
|
|
|
2020-04-09 22:38:34 +00:00
|
|
|
switch codecName {
|
|
|
|
case H264:
|
2020-07-23 01:56:08 +00:00
|
|
|
if supportMKV {
|
2020-04-09 22:38:34 +00:00
|
|
|
return IsValidForContainer(format, validForH264Mkv)
|
|
|
|
}
|
|
|
|
return IsValidForContainer(format, validForH264)
|
|
|
|
case H265:
|
2020-07-23 01:56:08 +00:00
|
|
|
if supportMKV {
|
2020-04-09 22:38:34 +00:00
|
|
|
return IsValidForContainer(format, validForH265Mkv)
|
|
|
|
}
|
|
|
|
return IsValidForContainer(format, validForH265)
|
|
|
|
case Vp8:
|
|
|
|
return IsValidForContainer(format, validForVp8)
|
|
|
|
case Vp9:
|
2020-07-23 01:56:08 +00:00
|
|
|
if supportMKV {
|
2020-04-09 22:38:34 +00:00
|
|
|
return IsValidForContainer(format, validForVp9Mkv)
|
|
|
|
}
|
|
|
|
return IsValidForContainer(format, validForVp9)
|
|
|
|
case Hevc:
|
2020-07-23 01:56:08 +00:00
|
|
|
if supportHEVC {
|
|
|
|
if supportMKV {
|
2020-04-09 22:38:34 +00:00
|
|
|
return IsValidForContainer(format, validForHevcMkv)
|
|
|
|
}
|
|
|
|
return IsValidForContainer(format, validForHevc)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
2020-07-23 01:56:08 +00:00
|
|
|
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 {
|
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
|
|
|
|
|
|
|
Path string
|
2019-08-15 22:47:35 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
AudioCodec string
|
|
|
|
}
|
|
|
|
|
|
|
|
// Execute exec command and bind result to struct.
|
2019-02-10 05:30:54 +00:00
|
|
|
func NewVideoFile(ffprobePath string, videoPath string) (*VideoFile, error) {
|
|
|
|
args := []string{"-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", "-show_error", videoPath}
|
|
|
|
//// Extremely slow on windows for some reason
|
|
|
|
//if runtime.GOOS != "windows" {
|
|
|
|
// args = append(args, "-count_frames")
|
|
|
|
//}
|
|
|
|
out, err := exec.Command(ffprobePath, args...).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 {
|
2020-10-11 01:02:41 +00:00
|
|
|
return nil, fmt.Errorf("Error unmarshalling video data for <%s>: %s", videoPath, err.Error())
|
2019-02-09 12:30:49 +00:00
|
|
|
}
|
|
|
|
|
2019-02-10 05:30:54 +00:00
|
|
|
return parse(videoPath, probeJSON)
|
2019-02-09 12:30:49 +00:00
|
|
|
}
|
|
|
|
|
2019-02-10 05:30:54 +00:00
|
|
|
func parse(filePath string, probeJSON *FFProbeJSON) (*VideoFile, error) {
|
|
|
|
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)
|
|
|
|
}
|
|
|
|
//} else if (ffprobeResult.stderr.includes("could not find codec parameters")) {
|
|
|
|
// throw new Error(`FFProbe [${filePath}] -> Could not find codec parameters`);
|
|
|
|
//} // TODO nil_or_unsupported.(video_stream) && nil_or_unsupported.(audio_stream)
|
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
|
|
|
|
|
|
|
|
if result.Title == "" {
|
|
|
|
// default title to filename
|
2019-10-12 08:20:27 +00:00
|
|
|
result.SetTitleFromPath()
|
2019-08-15 22:47:35 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
result.Comment = probeJSON.Format.Tags.Comment
|
2019-02-10 05:30:54 +00:00
|
|
|
|
|
|
|
result.Bitrate, _ = strconv.ParseInt(probeJSON.Format.BitRate, 10, 64)
|
|
|
|
result.Container = probeJSON.Format.FormatName
|
|
|
|
duration, _ := strconv.ParseFloat(probeJSON.Format.Duration, 64)
|
2019-02-14 22:53:32 +00:00
|
|
|
result.Duration = math.Round(duration*100) / 100
|
2020-05-26 23:33:49 +00:00
|
|
|
fileStat, err := os.Stat(filePath)
|
|
|
|
if err != nil {
|
2020-10-11 01:02:41 +00:00
|
|
|
logger.Errorf("Error statting file <%s>: %s", filePath, err.Error())
|
2020-05-26 23:33:49 +00:00
|
|
|
return nil, err
|
|
|
|
}
|
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
|
|
|
|
|
|
|
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.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)
|
|
|
|
}
|
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
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
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
|
|
|
}
|
|
|
|
|
2019-02-14 22:53:32 +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-02-14 22:53:32 +00:00
|
|
|
}
|
2019-10-12 08:20:27 +00:00
|
|
|
|
|
|
|
func (v *VideoFile) SetTitleFromPath() {
|
|
|
|
v.Title = filepath.Base(v.Path)
|
|
|
|
}
|