diff --git a/graphql/documents/data/config.graphql b/graphql/documents/data/config.graphql index 72dc7bce4..2bc1929a0 100644 --- a/graphql/documents/data/config.graphql +++ b/graphql/documents/data/config.graphql @@ -4,6 +4,8 @@ fragment ConfigGeneralData on ConfigGeneralResult { generatedPath maxTranscodeSize maxStreamingTranscodeSize + forceMkv + forceHevc username password maxSessionAge diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index 067e555ff..240caf2e3 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -18,6 +18,10 @@ input ConfigGeneralInput { maxTranscodeSize: StreamingResolutionEnum """Max streaming transcode size""" maxStreamingTranscodeSize: StreamingResolutionEnum + """Force MKV as supported format""" + forceMkv: Boolean! + """Force HEVC as a supported codec""" + forceHevc: Boolean! """Username""" username: String """Password""" @@ -49,6 +53,10 @@ type ConfigGeneralResult { maxTranscodeSize: StreamingResolutionEnum """Max streaming transcode size""" maxStreamingTranscodeSize: StreamingResolutionEnum + """Force MKV as supported format""" + forceMkv: Boolean! + """Force HEVC as a supported codec""" + forceHevc: Boolean! """Username""" username: String! """Password""" diff --git a/pkg/api/resolver_mutation_configure.go b/pkg/api/resolver_mutation_configure.go index db0c8976f..6b5d1b86f 100644 --- a/pkg/api/resolver_mutation_configure.go +++ b/pkg/api/resolver_mutation_configure.go @@ -45,6 +45,8 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co if input.MaxStreamingTranscodeSize != nil { config.Set(config.MaxStreamingTranscodeSize, input.MaxStreamingTranscodeSize.String()) } + config.Set(config.ForceMKV, input.ForceMkv) + config.Set(config.ForceHEVC, input.ForceHevc) if input.Username != nil { config.Set(config.Username, input.Username) diff --git a/pkg/api/resolver_query_configuration.go b/pkg/api/resolver_query_configuration.go index 8cf7fd9b4..ab5235d46 100644 --- a/pkg/api/resolver_query_configuration.go +++ b/pkg/api/resolver_query_configuration.go @@ -41,6 +41,8 @@ func makeConfigGeneralResult() *models.ConfigGeneralResult { GeneratedPath: config.GetGeneratedPath(), MaxTranscodeSize: &maxTranscodeSize, MaxStreamingTranscodeSize: &maxStreamingTranscodeSize, + ForceMkv: config.GetForceMKV(), + ForceHevc: config.GetForceHEVC(), Username: config.GetUsername(), Password: config.GetPasswordHash(), MaxSessionAge: config.GetMaxSessionAge(), diff --git a/pkg/api/routes_scene.go b/pkg/api/routes_scene.go index b5cf8827e..171d868b4 100644 --- a/pkg/api/routes_scene.go +++ b/pkg/api/routes_scene.go @@ -4,6 +4,7 @@ import ( "context" "io" "net/http" + "os" "strconv" "strings" @@ -42,13 +43,32 @@ func (rs sceneRoutes) Routes() chi.Router { // region Handlers func (rs sceneRoutes) Stream(w http.ResponseWriter, r *http.Request) { + scene := r.Context().Value(sceneKey).(*models.Scene) + container := "" + if scene.Format.Valid { + container = scene.Format.String + } else { // container isn't in the DB + // shouldn't happen, fallback to ffprobe + tmpVideoFile, err := ffmpeg.NewVideoFile(manager.GetInstance().FFProbePath, scene.Path) + if err != nil { + logger.Errorf("[transcode] error reading video file: %s", err.Error()) + return + } + + container = string(ffmpeg.MatchContainer(tmpVideoFile.Container, scene.Path)) + } + // detect if not a streamable file and try to transcode it instead filepath := manager.GetInstance().Paths.Scene.GetStreamPath(scene.Path, scene.Checksum) videoCodec := scene.VideoCodec.String + audioCodec := ffmpeg.MissingUnsupported + if scene.AudioCodec.Valid { + audioCodec = ffmpeg.AudioCodec(scene.AudioCodec.String) + } hasTranscode, _ := manager.HasTranscode(scene) - if ffmpeg.IsValidCodec(videoCodec) || hasTranscode { + if ffmpeg.IsValidCodec(videoCodec) && ffmpeg.IsValidCombo(videoCodec, ffmpeg.Container(container)) && ffmpeg.IsValidAudioForContainer(audioCodec, ffmpeg.Container(container)) || hasTranscode { manager.RegisterStream(filepath, &w) http.ServeFile(w, r, filepath) manager.WaitAndDeregisterStream(filepath, &w, r) @@ -69,16 +89,50 @@ func (rs sceneRoutes) Stream(w http.ResponseWriter, r *http.Request) { encoder := ffmpeg.NewEncoder(manager.GetInstance().FFMPEGPath) - stream, process, err := encoder.StreamTranscode(*videoFile, startTime, config.GetMaxStreamingTranscodeSize()) + var stream io.ReadCloser + var process *os.Process + mimeType := ffmpeg.MimeWebm + + if audioCodec == ffmpeg.MissingUnsupported { + //ffmpeg fails if it trys to transcode a non supported audio codec + stream, process, err = encoder.StreamTranscodeVideo(*videoFile, startTime, config.GetMaxStreamingTranscodeSize()) + } else { + copyVideo := false // try to be smart if the video to be transcoded is in a Matroska container + // mp4 has always supported audio so it doesn't need to be checked + // while mpeg_ts has seeking issues if we don't reencode the video + + if config.GetForceMKV() { // If MKV is forced as supported and video codec is also supported then only transcode audio + if ffmpeg.Container(container) == ffmpeg.Matroska { + switch videoCodec { + case ffmpeg.H264, ffmpeg.Vp9, ffmpeg.Vp8: + copyVideo = true + case ffmpeg.Hevc: + if config.GetForceHEVC() { + copyVideo = true + } + + } + } + } + + if copyVideo { // copy video stream instead of transcoding it + stream, process, err = encoder.StreamMkvTranscodeAudio(*videoFile, startTime, config.GetMaxStreamingTranscodeSize()) + mimeType = ffmpeg.MimeMkv + + } else { + stream, process, err = encoder.StreamTranscode(*videoFile, startTime, config.GetMaxStreamingTranscodeSize()) + } + } + if err != nil { logger.Errorf("[stream] error transcoding video file: %s", err.Error()) return } w.WriteHeader(http.StatusOK) - w.Header().Set("Content-Type", "video/webm") + w.Header().Set("Content-Type", mimeType) - logger.Info("[stream] transcoding video file") + logger.Infof("[stream] transcoding video file to %s", mimeType) // handle if client closes the connection notify := r.Context().Done() diff --git a/pkg/database/database.go b/pkg/database/database.go index f9f2392af..8d648671c 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -19,7 +19,7 @@ import ( var DB *sqlx.DB var dbPath string -var appSchemaVersion uint = 5 +var appSchemaVersion uint = 6 var databaseSchemaVersion uint const sqlite3Driver = "sqlite3_regexp" diff --git a/pkg/database/migrations/6_scenes_format.up.sql b/pkg/database/migrations/6_scenes_format.up.sql new file mode 100644 index 000000000..93f5c44a9 --- /dev/null +++ b/pkg/database/migrations/6_scenes_format.up.sql @@ -0,0 +1 @@ +ALTER TABLE `scenes` ADD COLUMN `format` varchar(255); diff --git a/pkg/ffmpeg/encoder_transcode.go b/pkg/ffmpeg/encoder_transcode.go index a908f00ac..12b596396 100644 --- a/pkg/ffmpeg/encoder_transcode.go +++ b/pkg/ffmpeg/encoder_transcode.go @@ -69,6 +69,49 @@ func (e *Encoder) Transcode(probeResult VideoFile, options TranscodeOptions) { _, _ = e.run(probeResult, args) } +//transcode the video, remove the audio +//in some videos where the audio codec is not supported by ffmpeg +//ffmpeg fails if you try to transcode the audio +func (e *Encoder) TranscodeVideo(probeResult VideoFile, options TranscodeOptions) { + scale := calculateTranscodeScale(probeResult, options.MaxTranscodeSize) + args := []string{ + "-i", probeResult.Path, + "-an", + "-c:v", "libx264", + "-pix_fmt", "yuv420p", + "-profile:v", "high", + "-level", "4.2", + "-preset", "superfast", + "-crf", "23", + "-vf", "scale=" + scale, + options.OutputPath, + } + _, _ = e.run(probeResult, args) +} + +//copy the video stream as is, transcode audio +func (e *Encoder) TranscodeAudio(probeResult VideoFile, options TranscodeOptions) { + args := []string{ + "-i", probeResult.Path, + "-c:v", "copy", + "-c:a", "aac", + "-strict", "-2", + options.OutputPath, + } + _, _ = e.run(probeResult, args) +} + +//copy the video stream as is, drop audio +func (e *Encoder) CopyVideo(probeResult VideoFile, options TranscodeOptions) { + args := []string{ + "-i", probeResult.Path, + "-an", + "-c:v", "copy", + options.OutputPath, + } + _, _ = e.run(probeResult, args) +} + func (e *Encoder) StreamTranscode(probeResult VideoFile, startTime string, maxTranscodeSize models.StreamingResolutionEnum) (io.ReadCloser, *os.Process, error) { scale := calculateTranscodeScale(probeResult, maxTranscodeSize) args := []string{} @@ -92,3 +135,53 @@ func (e *Encoder) StreamTranscode(probeResult VideoFile, startTime string, maxTr return e.stream(probeResult, args) } + +//transcode the video, remove the audio +//in some videos where the audio codec is not supported by ffmpeg +//ffmpeg fails if you try to transcode the audio +func (e *Encoder) StreamTranscodeVideo(probeResult VideoFile, startTime string, maxTranscodeSize models.StreamingResolutionEnum) (io.ReadCloser, *os.Process, error) { + scale := calculateTranscodeScale(probeResult, maxTranscodeSize) + args := []string{} + + if startTime != "" { + args = append(args, "-ss", startTime) + } + + args = append(args, + "-i", probeResult.Path, + "-an", + "-c:v", "libvpx-vp9", + "-vf", "scale="+scale, + "-deadline", "realtime", + "-cpu-used", "5", + "-row-mt", "1", + "-crf", "30", + "-b:v", "0", + "-f", "webm", + "pipe:", + ) + + return e.stream(probeResult, args) +} + +//it is very common in MKVs to have just the audio codec unsupported +//copy the video stream, transcode the audio and serve as Matroska +func (e *Encoder) StreamMkvTranscodeAudio(probeResult VideoFile, startTime string, maxTranscodeSize models.StreamingResolutionEnum) (io.ReadCloser, *os.Process, error) { + args := []string{} + + if startTime != "" { + args = append(args, "-ss", startTime) + } + + args = append(args, + "-i", probeResult.Path, + "-c:v", "copy", + "-c:a", "libopus", + "-b:a", "96k", + "-vbr", "on", + "-f", "matroska", + "pipe:", + ) + + return e.stream(probeResult, args) +} diff --git a/pkg/ffmpeg/ffprobe.go b/pkg/ffmpeg/ffprobe.go index cbddb14d3..206fd210f 100644 --- a/pkg/ffmpeg/ffprobe.go +++ b/pkg/ffmpeg/ffprobe.go @@ -10,11 +10,105 @@ import ( "strconv" "strings" "time" + + "github.com/stashapp/stash/pkg/manager/config" ) -var ValidCodecs = []string{"h264", "h265", "vp8", "vp9"} +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" + MimeWebm string = "video/webm" + MimeMkv string = "video/x-matroska" +) + +var ValidCodecs = []string{H264, H265, Vp8, Vp9} + +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 +} func IsValidCodec(codecName string) bool { + forceHEVC := config.GetForceHEVC() + if forceHEVC { + if codecName == Hevc { + return true + } + } + for _, c := range ValidCodecs { if c == codecName { return true @@ -23,6 +117,78 @@ func IsValidCodec(codecName string) bool { return false } +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 +func IsValidCombo(codecName string, format Container) bool { + forceMKV := config.GetForceMKV() + forceHEVC := config.GetForceHEVC() + switch codecName { + case H264: + if forceMKV { + return IsValidForContainer(format, validForH264Mkv) + } + return IsValidForContainer(format, validForH264) + case H265: + if forceMKV { + return IsValidForContainer(format, validForH265Mkv) + } + return IsValidForContainer(format, validForH265) + case Vp8: + return IsValidForContainer(format, validForVp8) + case Vp9: + if forceMKV { + return IsValidForContainer(format, validForVp9Mkv) + } + return IsValidForContainer(format, validForVp9) + case Hevc: + if forceHEVC { + if forceMKV { + return IsValidForContainer(format, validForHevcMkv) + } + return IsValidForContainer(format, validForHevc) + } + } + return false +} + type VideoFile struct { JSON FFProbeJSON AudioStream *FFProbeStream diff --git a/pkg/ffmpeg/media_detection.go b/pkg/ffmpeg/media_detection.go new file mode 100644 index 000000000..4de7e4ba6 --- /dev/null +++ b/pkg/ffmpeg/media_detection.go @@ -0,0 +1,66 @@ +package ffmpeg + +import ( + "bytes" + "github.com/stashapp/stash/pkg/logger" + "os" +) + +// detect file format from magic file number +// https://github.com/lex-r/filetype/blob/73c10ad714e3b8ecf5cd1564c882ed6d440d5c2d/matchers/video.go + +func mkv(buf []byte) bool { + return len(buf) > 3 && + buf[0] == 0x1A && buf[1] == 0x45 && + buf[2] == 0xDF && buf[3] == 0xA3 && + containsMatroskaSignature(buf, []byte{'m', 'a', 't', 'r', 'o', 's', 'k', 'a'}) +} + +func webm(buf []byte) bool { + return len(buf) > 3 && + buf[0] == 0x1A && buf[1] == 0x45 && + buf[2] == 0xDF && buf[3] == 0xA3 && + containsMatroskaSignature(buf, []byte{'w', 'e', 'b', 'm'}) +} + +func containsMatroskaSignature(buf, subType []byte) bool { + limit := 4096 + if len(buf) < limit { + limit = len(buf) + } + + index := bytes.Index(buf[:limit], subType) + if index < 3 { + return false + } + + return buf[index-3] == 0x42 && buf[index-2] == 0x82 +} + +//returns container as string ("" on error or no match) +//implements only mkv or webm as ffprobe can't distinguish between them +//and not all browsers support mkv +func MagicContainer(file_path string) Container { + file, err := os.Open(file_path) + if err != nil { + logger.Errorf("[magicfile] %v", err) + return "" + } + + defer file.Close() + + buf := make([]byte, 4096) + _, err = file.Read(buf) + if err != nil { + logger.Errorf("[magicfile] %v", err) + return "" + } + + if webm(buf) { + return Webm + } + if mkv(buf) { + return Matroska + } + return "" +} diff --git a/pkg/manager/config/config.go b/pkg/manager/config/config.go index ec84fe389..3ab9782fc 100644 --- a/pkg/manager/config/config.go +++ b/pkg/manager/config/config.go @@ -55,6 +55,10 @@ const AutostartVideo = "autostart_video" const ShowStudioAsText = "show_studio_as_text" const CSSEnabled = "cssEnabled" +// Playback force codec,container +const ForceMKV = "forceMKV" +const ForceHEVC = "forceHEVC" + // Logging options const LogFile = "logFile" const LogOut = "logOut" @@ -291,6 +295,15 @@ func GetCSSEnabled() bool { return viper.GetBool(CSSEnabled) } +// force codec,container +func GetForceMKV() bool { + return viper.GetBool(ForceMKV) +} + +func GetForceHEVC() bool { + return viper.GetBool(ForceHEVC) +} + // GetLogFile returns the filename of the file to output logs to. // An empty string means that file logging will be disabled. func GetLogFile() string { diff --git a/pkg/manager/jsonschema/scene.go b/pkg/manager/jsonschema/scene.go index 57b16e860..c69d44660 100644 --- a/pkg/manager/jsonschema/scene.go +++ b/pkg/manager/jsonschema/scene.go @@ -22,6 +22,7 @@ type SceneFile struct { Duration string `json:"duration"` VideoCodec string `json:"video_codec"` AudioCodec string `json:"audio_codec"` + Format string `json:"format"` Width int `json:"width"` Height int `json:"height"` Framerate string `json:"framerate"` diff --git a/pkg/manager/task_export.go b/pkg/manager/task_export.go index 96ccd8840..4321e78b7 100644 --- a/pkg/manager/task_export.go +++ b/pkg/manager/task_export.go @@ -165,6 +165,9 @@ func (t *ExportTask) ExportScenes(ctx context.Context) { if scene.AudioCodec.Valid { newSceneJSON.File.AudioCodec = scene.AudioCodec.String } + if scene.Format.Valid { + newSceneJSON.File.Format = scene.Format.String + } if scene.Width.Valid { newSceneJSON.File.Width = int(scene.Width.Int64) } diff --git a/pkg/manager/task_import.go b/pkg/manager/task_import.go index 15795b4c3..1c49b40a3 100644 --- a/pkg/manager/task_import.go +++ b/pkg/manager/task_import.go @@ -501,6 +501,9 @@ func (t *ImportTask) ImportScenes(ctx context.Context) { if sceneJSON.File.AudioCodec != "" { newScene.AudioCodec = sql.NullString{String: sceneJSON.File.AudioCodec, Valid: true} } + if sceneJSON.File.Format != "" { + newScene.Format = sql.NullString{String: sceneJSON.File.Format, Valid: true} + } if sceneJSON.File.Width != 0 { newScene.Width = sql.NullInt64{Int64: int64(sceneJSON.File.Width), Valid: true} } diff --git a/pkg/manager/task_scan.go b/pkg/manager/task_scan.go index 98fde4652..01e327b65 100644 --- a/pkg/manager/task_scan.go +++ b/pkg/manager/task_scan.go @@ -133,8 +133,31 @@ func (t *ScanTask) scanScene() { qb := models.NewSceneQueryBuilder() scene, _ := qb.FindByPath(t.FilePath) if scene != nil { - // We already have this item in the database, check for thumbnails,screenshots + // We already have this item in the database + //check for thumbnails,screenshots t.makeScreenshots(nil, scene.Checksum) + + //check for container + if !scene.Format.Valid { + videoFile, err := ffmpeg.NewVideoFile(instance.FFProbePath, t.FilePath) + if err != nil { + logger.Error(err.Error()) + return + } + container := ffmpeg.MatchContainer(videoFile.Container, t.FilePath) + logger.Infof("Adding container %s to file %s", container, t.FilePath) + + ctx := context.TODO() + tx := database.DB.MustBeginTx(ctx, nil) + err = qb.UpdateFormat(scene.ID, string(container), tx) + if err != nil { + logger.Error(err.Error()) + _ = tx.Rollback() + } else if err := tx.Commit(); err != nil { + logger.Error(err.Error()) + } + + } return } @@ -143,6 +166,7 @@ func (t *ScanTask) scanScene() { logger.Error(err.Error()) return } + container := ffmpeg.MatchContainer(videoFile.Container, t.FilePath) // Override title to be filename if UseFileMetadata is false if !t.UseFileMetadata { @@ -182,6 +206,7 @@ func (t *ScanTask) scanScene() { Duration: sql.NullFloat64{Float64: videoFile.Duration, Valid: true}, VideoCodec: sql.NullString{String: videoFile.VideoCodec, Valid: true}, AudioCodec: sql.NullString{String: videoFile.AudioCodec, Valid: true}, + Format: sql.NullString{String: string(container), Valid: true}, Width: sql.NullInt64{Int64: int64(videoFile.Width), Valid: true}, Height: sql.NullInt64{Int64: int64(videoFile.Height), Valid: true}, Framerate: sql.NullFloat64{Float64: videoFile.FrameRate, Valid: true}, diff --git a/pkg/manager/task_transcode.go b/pkg/manager/task_transcode.go index a43e50cd3..7db3be0e3 100644 --- a/pkg/manager/task_transcode.go +++ b/pkg/manager/task_transcode.go @@ -16,17 +16,37 @@ type GenerateTranscodeTask struct { func (t *GenerateTranscodeTask) Start(wg *sync.WaitGroup) { defer wg.Done() - videoCodec := t.Scene.VideoCodec.String - if ffmpeg.IsValidCodec(videoCodec) { - return - } hasTranscode, _ := HasTranscode(&t.Scene) if hasTranscode { return } - logger.Infof("[transcode] <%s> scene has codec %s", t.Scene.Checksum, t.Scene.VideoCodec.String) + var container ffmpeg.Container + + if t.Scene.Format.Valid { + container = ffmpeg.Container(t.Scene.Format.String) + + } else { // container isn't in the DB + // shouldn't happen unless user hasn't scanned after updating to PR#384+ version + tmpVideoFile, err := ffmpeg.NewVideoFile(instance.FFProbePath, t.Scene.Path) + if err != nil { + logger.Errorf("[transcode] error reading video file: %s", err.Error()) + return + } + + container = ffmpeg.MatchContainer(tmpVideoFile.Container, t.Scene.Path) + } + + videoCodec := t.Scene.VideoCodec.String + audioCodec := ffmpeg.MissingUnsupported + if t.Scene.AudioCodec.Valid { + audioCodec = ffmpeg.AudioCodec(t.Scene.AudioCodec.String) + } + + if ffmpeg.IsValidCodec(videoCodec) && ffmpeg.IsValidCombo(videoCodec, container) && ffmpeg.IsValidAudioForContainer(audioCodec, container) { + return + } videoFile, err := ffmpeg.NewVideoFile(instance.FFProbePath, t.Scene.Path) if err != nil { @@ -41,24 +61,52 @@ func (t *GenerateTranscodeTask) Start(wg *sync.WaitGroup) { MaxTranscodeSize: transcodeSize, } encoder := ffmpeg.NewEncoder(instance.FFMPEGPath) - encoder.Transcode(*videoFile, options) + + if videoCodec == ffmpeg.H264 { // for non supported h264 files stream copy the video part + if audioCodec == ffmpeg.MissingUnsupported { + encoder.CopyVideo(*videoFile, options) + } else { + encoder.TranscodeAudio(*videoFile, options) + } + } else { + if audioCodec == ffmpeg.MissingUnsupported { + //ffmpeg fails if it trys to transcode an unsupported audio codec + encoder.TranscodeVideo(*videoFile, options) + } else { + encoder.Transcode(*videoFile, options) + } + } + if err := os.Rename(outputPath, instance.Paths.Scene.GetTranscodePath(t.Scene.Checksum)); err != nil { logger.Errorf("[transcode] error generating transcode: %s", err.Error()) return } + logger.Debugf("[transcode] <%s> created transcode: %s", t.Scene.Checksum, outputPath) return } +// return true if transcode is needed +// used only when counting files to generate, doesn't affect the actual transcode generation +// if container is missing from DB it is treated as non supported in order not to delay the user func (t *GenerateTranscodeTask) isTranscodeNeeded() bool { videoCodec := t.Scene.VideoCodec.String - hasTranscode, _ := HasTranscode(&t.Scene) + container := "" + audioCodec := ffmpeg.MissingUnsupported + if t.Scene.AudioCodec.Valid { + audioCodec = ffmpeg.AudioCodec(t.Scene.AudioCodec.String) + } - if ffmpeg.IsValidCodec(videoCodec) { + if t.Scene.Format.Valid { + container = t.Scene.Format.String + } + + if ffmpeg.IsValidCodec(videoCodec) && ffmpeg.IsValidCombo(videoCodec, ffmpeg.Container(container)) && ffmpeg.IsValidAudioForContainer(audioCodec, ffmpeg.Container(container)) { return false } + hasTranscode, _ := HasTranscode(&t.Scene) if hasTranscode { return false } diff --git a/pkg/manager/utils.go b/pkg/manager/utils.go index af767a291..38f747d95 100644 --- a/pkg/manager/utils.go +++ b/pkg/manager/utils.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/stashapp/stash/pkg/ffmpeg" + "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" ) @@ -12,12 +13,30 @@ func IsStreamable(scene *models.Scene) (bool, error) { if scene == nil { return false, fmt.Errorf("nil scene") } + var container ffmpeg.Container + if scene.Format.Valid { + container = ffmpeg.Container(scene.Format.String) + } else { // container isn't in the DB + // shouldn't happen, fallback to ffprobe reading from file + tmpVideoFile, err := ffmpeg.NewVideoFile(instance.FFProbePath, scene.Path) + if err != nil { + return false, fmt.Errorf("error reading video file: %s", err.Error()) + } + container = ffmpeg.MatchContainer(tmpVideoFile.Container, scene.Path) + } videoCodec := scene.VideoCodec.String - if ffmpeg.IsValidCodec(videoCodec) { + audioCodec := ffmpeg.MissingUnsupported + if scene.AudioCodec.Valid { + audioCodec = ffmpeg.AudioCodec(scene.AudioCodec.String) + } + + if ffmpeg.IsValidCodec(videoCodec) && ffmpeg.IsValidCombo(videoCodec, container) && ffmpeg.IsValidAudioForContainer(audioCodec, container) { + logger.Debugf("File is streamable %s, %s, %s\n", videoCodec, audioCodec, container) return true, nil } else { hasTranscode, _ := HasTranscode(scene) + logger.Debugf("File is not streamable , transcode is needed %s, %s, %s\n", videoCodec, audioCodec, container) return hasTranscode, nil } } diff --git a/pkg/models/model_scene.go b/pkg/models/model_scene.go index f1eb8dd56..1623533be 100644 --- a/pkg/models/model_scene.go +++ b/pkg/models/model_scene.go @@ -19,6 +19,7 @@ type Scene struct { Size sql.NullString `db:"size" json:"size"` Duration sql.NullFloat64 `db:"duration" json:"duration"` VideoCodec sql.NullString `db:"video_codec" json:"video_codec"` + Format sql.NullString `db:"format" json:"format_name"` AudioCodec sql.NullString `db:"audio_codec" json:"audio_codec"` Width sql.NullInt64 `db:"width" json:"width"` Height sql.NullInt64 `db:"height" json:"height"` diff --git a/pkg/models/querybuilder_scene.go b/pkg/models/querybuilder_scene.go index 413930f0c..aece66d76 100644 --- a/pkg/models/querybuilder_scene.go +++ b/pkg/models/querybuilder_scene.go @@ -50,10 +50,10 @@ func (qb *SceneQueryBuilder) Create(newScene Scene, tx *sqlx.Tx) (*Scene, error) ensureTx(tx) result, err := tx.NamedExec( `INSERT INTO scenes (checksum, path, title, details, url, date, rating, size, duration, video_codec, - audio_codec, width, height, framerate, bitrate, studio_id, cover, + audio_codec, format, width, height, framerate, bitrate, studio_id, cover, created_at, updated_at) VALUES (:checksum, :path, :title, :details, :url, :date, :rating, :size, :duration, :video_codec, - :audio_codec, :width, :height, :framerate, :bitrate, :studio_id, :cover, + :audio_codec, :format, :width, :height, :framerate, :bitrate, :studio_id, :cover, :created_at, :updated_at) `, newScene, @@ -534,3 +534,16 @@ func (qb *SceneQueryBuilder) queryScenes(query string, args []interface{}, tx *s return scenes, nil } + +func (qb *SceneQueryBuilder) UpdateFormat(id int, format string, tx *sqlx.Tx) error { + ensureTx(tx) + _, err := tx.Exec( + `UPDATE scenes SET format = ? WHERE scenes.id = ? `, + format, id, + ) + if err != nil { + return err + } + + return nil +} diff --git a/ui/v2.5/src/components/Settings/SettingsConfigurationPanel.tsx b/ui/v2.5/src/components/Settings/SettingsConfigurationPanel.tsx index 44f39ceff..1bd8ce865 100644 --- a/ui/v2.5/src/components/Settings/SettingsConfigurationPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsConfigurationPanel.tsx @@ -22,6 +22,8 @@ export const SettingsConfigurationPanel: React.FC = () => { const [maxStreamingTranscodeSize, setMaxStreamingTranscodeSize] = useState< GQL.StreamingResolutionEnum | undefined >(undefined); + const [forceMkv, setForceMkv] = useState(false); + const [forceHevc, setForceHevc] = useState(false); const [username, setUsername] = useState(undefined); const [password, setPassword] = useState(undefined); const [maxSessionAge, setMaxSessionAge] = useState(0); @@ -42,6 +44,8 @@ export const SettingsConfigurationPanel: React.FC = () => { generatedPath, maxTranscodeSize, maxStreamingTranscodeSize, + forceMkv, + forceHevc, username, password, maxSessionAge, @@ -65,6 +69,8 @@ export const SettingsConfigurationPanel: React.FC = () => { setMaxStreamingTranscodeSize( conf.general.maxStreamingTranscodeSize ?? undefined ); + setForceMkv(conf.general.forceMkv); + setForceHevc(conf.general.forceHevc); setUsername(conf.general.username); setPassword(conf.general.password); setMaxSessionAge(conf.general.maxSessionAge); @@ -293,6 +299,28 @@ export const SettingsConfigurationPanel: React.FC = () => { Maximum size for transcoded streams + + setForceMkv(!forceMkv)} + /> + + Treat Matroska (MKV) as a supported container. Recommended for Chromium based browsers + + + + setForceHevc(!forceHevc)} + /> + + Treat HEVC as a supported codec. Recommended for Safari or some Android based browsers + +
diff --git a/ui/v2/src/components/Settings/SettingsConfigurationPanel.tsx b/ui/v2/src/components/Settings/SettingsConfigurationPanel.tsx index 30638fc88..e9ca02c8b 100644 --- a/ui/v2/src/components/Settings/SettingsConfigurationPanel.tsx +++ b/ui/v2/src/components/Settings/SettingsConfigurationPanel.tsx @@ -16,7 +16,7 @@ import { ErrorUtils } from "../../utils/errors"; import { ToastUtils } from "../../utils/toasts"; import { FolderSelect } from "../Shared/FolderSelect/FolderSelect"; -interface IProps {} +interface IProps { } export const SettingsConfigurationPanel: FunctionComponent = (props: IProps) => { // Editing config state @@ -25,6 +25,8 @@ export const SettingsConfigurationPanel: FunctionComponent = (props: IPr const [generatedPath, setGeneratedPath] = useState(undefined); const [maxTranscodeSize, setMaxTranscodeSize] = useState(undefined); const [maxStreamingTranscodeSize, setMaxStreamingTranscodeSize] = useState(undefined); + const [forceMkv, setForceMkv] = useState(false); + const [forceHevc, setForceHevc] = useState(false); const [username, setUsername] = useState(undefined); const [password, setPassword] = useState(undefined); const [logFile, setLogFile] = useState(); @@ -42,6 +44,8 @@ export const SettingsConfigurationPanel: FunctionComponent = (props: IPr generatedPath, maxTranscodeSize, maxStreamingTranscodeSize, + forceMkv, + forceHevc, username, password, logFile, @@ -61,6 +65,8 @@ export const SettingsConfigurationPanel: FunctionComponent = (props: IPr setGeneratedPath(conf.general.generatedPath); setMaxTranscodeSize(conf.general.maxTranscodeSize); setMaxStreamingTranscodeSize(conf.general.maxStreamingTranscodeSize); + setForceMkv(conf.general.forceMkv); + setForceHevc(conf.general.forceHevc); setUsername(conf.general.username); setPassword(conf.general.password); setLogFile(conf.general.logFile); @@ -77,15 +83,15 @@ export const SettingsConfigurationPanel: FunctionComponent = (props: IPr } function excludeRegexChanged(idx: number, value: string) { - const newExcludes = excludes.map((regex, i)=> { - const ret = ( idx !== i ) ? regex : value ; + const newExcludes = excludes.map((regex, i) => { + const ret = (idx !== i) ? regex : value; return ret - }) + }) setExcludes(newExcludes); } function excludeRemoveRegex(idx: number) { - const newExcludes = excludes.filter((regex, i) => i!== idx ); + const newExcludes = excludes.filter((regex, i) => i !== idx); setExcludes(newExcludes); } @@ -117,7 +123,7 @@ export const SettingsConfigurationPanel: FunctionComponent = (props: IPr GQL.StreamingResolutionEnum.Original ].map(resolutionToString); - function resolutionToString(r : GQL.StreamingResolutionEnum | undefined) { + function resolutionToString(r: GQL.StreamingResolutionEnum | undefined) { switch (r) { case GQL.StreamingResolutionEnum.Low: return "240p"; case GQL.StreamingResolutionEnum.Standard: return "480p"; @@ -130,7 +136,7 @@ export const SettingsConfigurationPanel: FunctionComponent = (props: IPr return "Original"; } - function translateQuality(quality : string) { + function translateQuality(quality: string) { switch (quality) { case "240p": return GQL.StreamingResolutionEnum.Low; case "480p": return GQL.StreamingResolutionEnum.Standard; @@ -160,7 +166,7 @@ export const SettingsConfigurationPanel: FunctionComponent = (props: IPr /> - + = (props: IPr label="Excluded Patterns" > - { (excludes) ? excludes.map((regexp, i) => { - return( - excludeRegexChanged(i, e.target.value)} - rightElement={