mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 12:24:38 +03:00
Transcode stream refactor (#609)
* Remove forceMkv and forceHEVC * Add HLS support and refactor * Add new streaming endpoints
This commit is contained in:
@@ -6,8 +6,6 @@ fragment ConfigGeneralData on ConfigGeneralResult {
|
||||
previewPreset
|
||||
maxTranscodeSize
|
||||
maxStreamingTranscodeSize
|
||||
forceMkv
|
||||
forceHevc
|
||||
username
|
||||
password
|
||||
maxSessionAge
|
||||
|
||||
@@ -33,8 +33,6 @@ fragment SceneData on Scene {
|
||||
...SceneMarkerData
|
||||
}
|
||||
|
||||
is_streamable
|
||||
|
||||
gallery {
|
||||
...GalleryData
|
||||
}
|
||||
|
||||
@@ -54,3 +54,11 @@ query ParseSceneFilenames($filter: FindFilterType!, $config: SceneParserInput!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query SceneStreams($id: ID!) {
|
||||
sceneStreams(id: $id) {
|
||||
url
|
||||
mime_type
|
||||
label
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ type Query {
|
||||
|
||||
findScenesByPathRegex(filter: FindFilterType): FindScenesResultType!
|
||||
|
||||
"""Return valid stream paths"""
|
||||
sceneStreams(id: ID): [SceneStreamEndpoint!]!
|
||||
|
||||
parseSceneFilenames(filter: FindFilterType, config: SceneParserInput!): SceneParserResultType!
|
||||
|
||||
"""A function which queries SceneMarker objects"""
|
||||
|
||||
@@ -32,10 +32,6 @@ 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"""
|
||||
@@ -71,10 +67,6 @@ 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"""
|
||||
|
||||
@@ -36,7 +36,6 @@ type Scene {
|
||||
|
||||
file: SceneFileType! # Resolver
|
||||
paths: ScenePathsType! # Resolver
|
||||
is_streamable: Boolean! # Resolver
|
||||
|
||||
scene_markers: [SceneMarker!]!
|
||||
gallery: Gallery
|
||||
@@ -139,3 +138,9 @@ type SceneParserResultType {
|
||||
count: Int!
|
||||
results: [SceneParserResult!]!
|
||||
}
|
||||
|
||||
type SceneStreamEndpoint {
|
||||
url: String!
|
||||
mime_type: String
|
||||
label: String
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/stashapp/stash/pkg/api/urlbuilders"
|
||||
"github.com/stashapp/stash/pkg/manager"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
@@ -81,12 +80,6 @@ func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*models.S
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *sceneResolver) IsStreamable(ctx context.Context, obj *models.Scene) (bool, error) {
|
||||
// ignore error
|
||||
ret, _ := manager.IsStreamable(obj)
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *sceneResolver) SceneMarkers(ctx context.Context, obj *models.Scene) ([]*models.SceneMarker, error) {
|
||||
qb := models.NewSceneMarkerQueryBuilder()
|
||||
return qb.FindBySceneID(obj.ID, nil)
|
||||
|
||||
@@ -56,8 +56,6 @@ 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)
|
||||
|
||||
@@ -49,8 +49,6 @@ func makeConfigGeneralResult() *models.ConfigGeneralResult {
|
||||
PreviewPreset: config.GetPreviewPreset(),
|
||||
MaxTranscodeSize: &maxTranscodeSize,
|
||||
MaxStreamingTranscodeSize: &maxStreamingTranscodeSize,
|
||||
ForceMkv: config.GetForceMKV(),
|
||||
ForceHevc: config.GetForceHEVC(),
|
||||
Username: config.GetUsername(),
|
||||
Password: config.GetPasswordHash(),
|
||||
MaxSessionAge: config.GetMaxSessionAge(),
|
||||
|
||||
31
pkg/api/resolver_query_scene.go
Normal file
31
pkg/api/resolver_query_scene.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/api/urlbuilders"
|
||||
"github.com/stashapp/stash/pkg/manager"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
func (r *queryResolver) SceneStreams(ctx context.Context, id *string) ([]*models.SceneStreamEndpoint, error) {
|
||||
// find the scene
|
||||
qb := models.NewSceneQueryBuilder()
|
||||
idInt, _ := strconv.Atoi(*id)
|
||||
scene, err := qb.Find(idInt)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if scene == nil {
|
||||
return nil, errors.New("nil scene")
|
||||
}
|
||||
|
||||
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
|
||||
builder := urlbuilders.NewSceneURLBuilder(baseURL, scene.ID)
|
||||
|
||||
return manager.GetSceneStreamPaths(scene, builder.GetStreamURL())
|
||||
}
|
||||
@@ -2,9 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -24,8 +22,15 @@ func (rs sceneRoutes) Routes() chi.Router {
|
||||
|
||||
r.Route("/{sceneId}", func(r chi.Router) {
|
||||
r.Use(SceneCtx)
|
||||
r.Get("/stream", rs.Stream)
|
||||
r.Get("/stream.mp4", rs.Stream)
|
||||
|
||||
// streaming endpoints
|
||||
r.Get("/stream", rs.StreamDirect)
|
||||
r.Get("/stream.mkv", rs.StreamMKV)
|
||||
r.Get("/stream.webm", rs.StreamWebM)
|
||||
r.Get("/stream.m3u8", rs.StreamHLS)
|
||||
r.Get("/stream.ts", rs.StreamTS)
|
||||
r.Get("/stream.mp4", rs.StreamMp4)
|
||||
|
||||
r.Get("/screenshot", rs.Screenshot)
|
||||
r.Get("/preview", rs.Preview)
|
||||
r.Get("/webp", rs.Webp)
|
||||
@@ -42,41 +47,94 @@ 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 := ""
|
||||
func getSceneFileContainer(scene *models.Scene) ffmpeg.Container {
|
||||
var container ffmpeg.Container
|
||||
if scene.Format.Valid {
|
||||
container = scene.Format.String
|
||||
container = ffmpeg.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
|
||||
return ffmpeg.Container("")
|
||||
}
|
||||
|
||||
container = string(ffmpeg.MatchContainer(tmpVideoFile.Container, scene.Path))
|
||||
container = ffmpeg.MatchContainer(tmpVideoFile.Container, scene.Path)
|
||||
}
|
||||
|
||||
// detect if not a streamable file and try to transcode it instead
|
||||
return container
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) StreamDirect(w http.ResponseWriter, r *http.Request) {
|
||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
||||
|
||||
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) && 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)
|
||||
manager.RegisterStream(filepath, &w)
|
||||
http.ServeFile(w, r, filepath)
|
||||
manager.WaitAndDeregisterStream(filepath, &w, r)
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) StreamMKV(w http.ResponseWriter, r *http.Request) {
|
||||
// only allow mkv streaming if the scene container is an mkv already
|
||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
||||
|
||||
container := getSceneFileContainer(scene)
|
||||
if container != ffmpeg.Matroska {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("not an mkv file"))
|
||||
return
|
||||
}
|
||||
|
||||
rs.streamTranscode(w, r, ffmpeg.CodecMKVAudio)
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) StreamWebM(w http.ResponseWriter, r *http.Request) {
|
||||
rs.streamTranscode(w, r, ffmpeg.CodecVP9)
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) StreamMp4(w http.ResponseWriter, r *http.Request) {
|
||||
rs.streamTranscode(w, r, ffmpeg.CodecH264)
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) StreamHLS(w http.ResponseWriter, r *http.Request) {
|
||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
||||
|
||||
videoFile, err := ffmpeg.NewVideoFile(manager.GetInstance().FFProbePath, scene.Path)
|
||||
if err != nil {
|
||||
logger.Errorf("[stream] error reading video file: %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug("Returning HLS playlist")
|
||||
|
||||
// getting the playlist manifest only
|
||||
w.Header().Set("Content-Type", ffmpeg.MimeHLS)
|
||||
var str strings.Builder
|
||||
|
||||
ffmpeg.WriteHLSPlaylist(*videoFile, r.URL.String(), &str)
|
||||
|
||||
requestByteRange := utils.CreateByteRange(r.Header.Get("Range"))
|
||||
if requestByteRange.RawString != "" {
|
||||
logger.Debugf("Requested range: %s", requestByteRange.RawString)
|
||||
}
|
||||
|
||||
ret := requestByteRange.Apply([]byte(str.String()))
|
||||
rangeStr := requestByteRange.ToHeaderValue(int64(str.Len()))
|
||||
w.Header().Set("Content-Range", rangeStr)
|
||||
|
||||
w.Write(ret)
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) StreamTS(w http.ResponseWriter, r *http.Request) {
|
||||
rs.streamTranscode(w, r, ffmpeg.CodecHLS)
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) streamTranscode(w http.ResponseWriter, r *http.Request, videoCodec ffmpeg.Codec) {
|
||||
logger.Debugf("Streaming as %s", videoCodec.MimeType)
|
||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
||||
|
||||
// needs to be transcoded
|
||||
|
||||
videoFile, err := ffmpeg.NewVideoFile(manager.GetInstance().FFProbePath, scene.Path)
|
||||
if err != nil {
|
||||
logger.Errorf("[stream] error reading video file: %s", err.Error())
|
||||
@@ -87,65 +145,28 @@ func (rs sceneRoutes) Stream(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
startTime := r.Form.Get("start")
|
||||
|
||||
encoder := ffmpeg.NewEncoder(manager.GetInstance().FFMPEGPath)
|
||||
var stream *ffmpeg.Stream
|
||||
|
||||
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())
|
||||
}
|
||||
audioCodec := ffmpeg.MissingUnsupported
|
||||
if scene.AudioCodec.Valid {
|
||||
audioCodec = ffmpeg.AudioCodec(scene.AudioCodec.String)
|
||||
}
|
||||
|
||||
options := ffmpeg.GetTranscodeStreamOptions(*videoFile, videoCodec, audioCodec)
|
||||
options.StartTime = startTime
|
||||
options.MaxTranscodeSize = config.GetMaxStreamingTranscodeSize()
|
||||
|
||||
encoder := ffmpeg.NewEncoder(manager.GetInstance().FFMPEGPath)
|
||||
stream, err = encoder.GetTranscodeStream(options)
|
||||
|
||||
if err != nil {
|
||||
logger.Errorf("[stream] error transcoding video file: %s", err.Error())
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Header().Set("Content-Type", mimeType)
|
||||
|
||||
logger.Infof("[stream] transcoding video file to %s", mimeType)
|
||||
|
||||
// handle if client closes the connection
|
||||
notify := r.Context().Done()
|
||||
go func() {
|
||||
<-notify
|
||||
logger.Info("[stream] client closed the connection. Killing stream process.")
|
||||
process.Kill()
|
||||
}()
|
||||
|
||||
_, err = io.Copy(w, stream)
|
||||
if err != nil {
|
||||
logger.Errorf("[stream] error serving transcoded video file: %s", err.Error())
|
||||
}
|
||||
stream.Serve(w, r)
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) Screenshot(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -18,7 +18,7 @@ func NewSceneURLBuilder(baseURL string, sceneID int) SceneURLBuilder {
|
||||
}
|
||||
|
||||
func (b SceneURLBuilder) GetStreamURL() string {
|
||||
return b.BaseURL + "/scene/" + b.SceneID + "/stream.mp4"
|
||||
return b.BaseURL + "/scene/" + b.SceneID + "/stream"
|
||||
}
|
||||
|
||||
func (b SceneURLBuilder) GetStreamPreviewURL() string {
|
||||
|
||||
@@ -2,7 +2,6 @@ package ffmpeg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -133,21 +132,3 @@ func (e *Encoder) run(probeResult VideoFile, args []string) (string, error) {
|
||||
|
||||
return stdoutString, nil
|
||||
}
|
||||
|
||||
func (e *Encoder) stream(probeResult VideoFile, args []string) (io.ReadCloser, *os.Process, error) {
|
||||
cmd := exec.Command(e.Path, args...)
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if nil != err {
|
||||
logger.Error("FFMPEG stdout not available: " + err.Error())
|
||||
}
|
||||
|
||||
if err = cmd.Start(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
registerRunningEncoder(probeResult.Path, cmd.Process)
|
||||
go waitAndDeregister(probeResult.Path, cmd)
|
||||
|
||||
return stdout, cmd.Process, nil
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
@@ -111,77 +109,3 @@ func (e *Encoder) CopyVideo(probeResult VideoFile, options TranscodeOptions) {
|
||||
}
|
||||
_, _ = 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{}
|
||||
|
||||
if startTime != "" {
|
||||
args = append(args, "-ss", startTime)
|
||||
}
|
||||
|
||||
args = append(args,
|
||||
"-i", probeResult.Path,
|
||||
"-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)
|
||||
}
|
||||
|
||||
//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)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/manager/config"
|
||||
)
|
||||
|
||||
type Container string
|
||||
@@ -47,11 +46,17 @@ const (
|
||||
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"
|
||||
)
|
||||
|
||||
var ValidCodecs = []string{H264, H265, Vp8, Vp9}
|
||||
// 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}
|
||||
@@ -102,15 +107,8 @@ func MatchContainer(format string, filePath string) Container { // match ffprobe
|
||||
return container
|
||||
}
|
||||
|
||||
func IsValidCodec(codecName string) bool {
|
||||
forceHEVC := config.GetForceHEVC()
|
||||
if forceHEVC {
|
||||
if codecName == Hevc {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range ValidCodecs {
|
||||
func IsValidCodec(codecName string, supportedCodecs []string) bool {
|
||||
for _, c := range supportedCodecs {
|
||||
if c == codecName {
|
||||
return true
|
||||
}
|
||||
@@ -158,30 +156,31 @@ func IsValidForContainer(format Container, validContainers []Container) bool {
|
||||
}
|
||||
|
||||
//extend stream validation check to take into account container
|
||||
func IsValidCombo(codecName string, format Container) bool {
|
||||
forceMKV := config.GetForceMKV()
|
||||
forceHEVC := config.GetForceHEVC()
|
||||
func IsValidCombo(codecName string, format Container, supportedVideoCodecs []string) bool {
|
||||
supportMKV := IsValidCodec(Mkv, supportedVideoCodecs)
|
||||
supportHEVC := IsValidCodec(Hevc, supportedVideoCodecs)
|
||||
|
||||
switch codecName {
|
||||
case H264:
|
||||
if forceMKV {
|
||||
if supportMKV {
|
||||
return IsValidForContainer(format, validForH264Mkv)
|
||||
}
|
||||
return IsValidForContainer(format, validForH264)
|
||||
case H265:
|
||||
if forceMKV {
|
||||
if supportMKV {
|
||||
return IsValidForContainer(format, validForH265Mkv)
|
||||
}
|
||||
return IsValidForContainer(format, validForH265)
|
||||
case Vp8:
|
||||
return IsValidForContainer(format, validForVp8)
|
||||
case Vp9:
|
||||
if forceMKV {
|
||||
if supportMKV {
|
||||
return IsValidForContainer(format, validForVp9Mkv)
|
||||
}
|
||||
return IsValidForContainer(format, validForVp9)
|
||||
case Hevc:
|
||||
if forceHEVC {
|
||||
if forceMKV {
|
||||
if supportHEVC {
|
||||
if supportMKV {
|
||||
return IsValidForContainer(format, validForHevcMkv)
|
||||
}
|
||||
return IsValidForContainer(format, validForHevc)
|
||||
@@ -190,6 +189,13 @@ func IsValidCombo(codecName string, format Container) bool {
|
||||
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)
|
||||
}
|
||||
|
||||
type VideoFile struct {
|
||||
JSON FFProbeJSON
|
||||
AudioStream *FFProbeStream
|
||||
|
||||
42
pkg/ffmpeg/hls.go
Normal file
42
pkg/ffmpeg/hls.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const hlsSegmentLength = 10.0
|
||||
|
||||
func WriteHLSPlaylist(probeResult VideoFile, baseUrl string, w io.Writer) {
|
||||
fmt.Fprint(w, "#EXTM3U\n")
|
||||
fmt.Fprint(w, "#EXT-X-VERSION:3\n")
|
||||
fmt.Fprint(w, "#EXT-X-MEDIA-SEQUENCE:0\n")
|
||||
fmt.Fprint(w, "#EXT-X-ALLOW-CACHE:YES\n")
|
||||
fmt.Fprintf(w, "#EXT-X-TARGETDURATION:%d\n", int(hlsSegmentLength))
|
||||
fmt.Fprint(w, "#EXT-X-PLAYLIST-TYPE:VOD\n")
|
||||
|
||||
duration := probeResult.Duration
|
||||
|
||||
leftover := duration
|
||||
upTo := 0.0
|
||||
|
||||
tsURL := baseUrl
|
||||
i := strings.LastIndex(baseUrl, ".m3u8")
|
||||
tsURL = baseUrl[0:i] + ".ts"
|
||||
|
||||
for leftover > 0 {
|
||||
thisLength := hlsSegmentLength
|
||||
if leftover < thisLength {
|
||||
thisLength = leftover
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "#EXTINF: %f,\n", thisLength)
|
||||
fmt.Fprintf(w, "%s?start=%f\n", tsURL, upTo)
|
||||
|
||||
leftover -= thisLength
|
||||
upTo += thisLength
|
||||
}
|
||||
|
||||
fmt.Fprint(w, "#EXT-X-ENDLIST\n")
|
||||
}
|
||||
245
pkg/ffmpeg/stream.go
Normal file
245
pkg/ffmpeg/stream.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
const CopyStreamCodec = "copy"
|
||||
|
||||
type Stream struct {
|
||||
Stdout io.ReadCloser
|
||||
Process *os.Process
|
||||
options TranscodeStreamOptions
|
||||
mimeType string
|
||||
}
|
||||
|
||||
func (s *Stream) Serve(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", s.mimeType)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
logger.Infof("[stream] transcoding video file to %s", s.mimeType)
|
||||
|
||||
// handle if client closes the connection
|
||||
notify := r.Context().Done()
|
||||
go func() {
|
||||
<-notify
|
||||
s.Process.Kill()
|
||||
}()
|
||||
|
||||
_, err := io.Copy(w, s.Stdout)
|
||||
if err != nil {
|
||||
logger.Errorf("[stream] error serving transcoded video file: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
type Codec struct {
|
||||
Codec string
|
||||
format string
|
||||
MimeType string
|
||||
extraArgs []string
|
||||
hls bool
|
||||
}
|
||||
|
||||
var CodecHLS = Codec{
|
||||
Codec: "libx264",
|
||||
format: "mpegts",
|
||||
MimeType: MimeMpegts,
|
||||
extraArgs: []string{
|
||||
"-acodec", "aac",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-preset", "veryfast",
|
||||
"-crf", "25",
|
||||
},
|
||||
hls: true,
|
||||
}
|
||||
|
||||
var CodecH264 = Codec{
|
||||
Codec: "libx264",
|
||||
format: "mp4",
|
||||
MimeType: MimeMp4,
|
||||
extraArgs: []string{
|
||||
"-movflags", "frag_keyframe",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-preset", "veryfast",
|
||||
"-crf", "25",
|
||||
},
|
||||
}
|
||||
|
||||
var CodecVP9 = Codec{
|
||||
Codec: "libvpx-vp9",
|
||||
format: "webm",
|
||||
MimeType: MimeWebm,
|
||||
extraArgs: []string{
|
||||
"-deadline", "realtime",
|
||||
"-cpu-used", "5",
|
||||
"-row-mt", "1",
|
||||
"-crf", "30",
|
||||
"-b:v", "0",
|
||||
},
|
||||
}
|
||||
|
||||
var CodecVP8 = Codec{
|
||||
Codec: "libvpx",
|
||||
format: "webm",
|
||||
MimeType: MimeWebm,
|
||||
extraArgs: []string{
|
||||
"-deadline", "realtime",
|
||||
"-cpu-used", "5",
|
||||
"-crf", "12",
|
||||
"-b:v", "3M",
|
||||
"-pix_fmt", "yuv420p",
|
||||
},
|
||||
}
|
||||
|
||||
var CodecHEVC = Codec{
|
||||
Codec: "libx265",
|
||||
format: "mp4",
|
||||
MimeType: MimeMp4,
|
||||
extraArgs: []string{
|
||||
"-movflags", "frag_keyframe",
|
||||
"-preset", "veryfast",
|
||||
"-crf", "30",
|
||||
},
|
||||
}
|
||||
|
||||
// it is very common in MKVs to have just the audio codec unsupported
|
||||
// copy the video stream, transcode the audio and serve as Matroska
|
||||
var CodecMKVAudio = Codec{
|
||||
Codec: CopyStreamCodec,
|
||||
format: "matroska",
|
||||
MimeType: MimeMkv,
|
||||
extraArgs: []string{
|
||||
"-c:a", "libopus",
|
||||
"-b:a", "96k",
|
||||
"-vbr", "on",
|
||||
},
|
||||
}
|
||||
|
||||
type TranscodeStreamOptions struct {
|
||||
ProbeResult VideoFile
|
||||
Codec Codec
|
||||
StartTime string
|
||||
MaxTranscodeSize models.StreamingResolutionEnum
|
||||
// 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
|
||||
VideoOnly bool
|
||||
}
|
||||
|
||||
func GetTranscodeStreamOptions(probeResult VideoFile, videoCodec Codec, audioCodec AudioCodec) TranscodeStreamOptions {
|
||||
options := TranscodeStreamOptions{
|
||||
ProbeResult: probeResult,
|
||||
Codec: videoCodec,
|
||||
}
|
||||
|
||||
if audioCodec == MissingUnsupported {
|
||||
// ffmpeg fails if it trys to transcode a non supported audio codec
|
||||
options.VideoOnly = true
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
func (o TranscodeStreamOptions) getStreamArgs() []string {
|
||||
args := []string{
|
||||
"-hide_banner",
|
||||
"-v", "error",
|
||||
}
|
||||
|
||||
if o.StartTime != "" {
|
||||
args = append(args, "-ss", o.StartTime)
|
||||
}
|
||||
|
||||
if o.Codec.hls {
|
||||
// we only serve a fixed segment length
|
||||
args = append(args, "-t", strconv.Itoa(int(hlsSegmentLength)))
|
||||
}
|
||||
|
||||
args = append(args,
|
||||
"-i", o.ProbeResult.Path,
|
||||
)
|
||||
|
||||
if o.VideoOnly {
|
||||
args = append(args, "-an")
|
||||
}
|
||||
|
||||
args = append(args,
|
||||
"-c:v", o.Codec.Codec,
|
||||
)
|
||||
|
||||
// don't set scale when copying video stream
|
||||
if o.Codec.Codec != CopyStreamCodec {
|
||||
scale := calculateTranscodeScale(o.ProbeResult, o.MaxTranscodeSize)
|
||||
args = append(args,
|
||||
"-vf", "scale="+scale,
|
||||
)
|
||||
}
|
||||
|
||||
if len(o.Codec.extraArgs) > 0 {
|
||||
args = append(args, o.Codec.extraArgs...)
|
||||
}
|
||||
|
||||
args = append(args,
|
||||
// this is needed for 5-channel ac3 files
|
||||
"-ac", "2",
|
||||
"-f", o.Codec.format,
|
||||
"pipe:",
|
||||
)
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
func (e *Encoder) GetTranscodeStream(options TranscodeStreamOptions) (*Stream, error) {
|
||||
return e.stream(options.ProbeResult, options)
|
||||
}
|
||||
|
||||
func (e *Encoder) stream(probeResult VideoFile, options TranscodeStreamOptions) (*Stream, error) {
|
||||
args := options.getStreamArgs()
|
||||
cmd := exec.Command(e.Path, args...)
|
||||
logger.Debugf("Streaming via: %s", strings.Join(cmd.Args, " "))
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if nil != err {
|
||||
logger.Error("FFMPEG stdout not available: " + err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if nil != err {
|
||||
logger.Error("FFMPEG stderr not available: " + err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
registerRunningEncoder(probeResult.Path, cmd.Process)
|
||||
go waitAndDeregister(probeResult.Path, cmd)
|
||||
|
||||
// stderr must be consumed or the process deadlocks
|
||||
go func() {
|
||||
stderrData, _ := ioutil.ReadAll(stderr)
|
||||
stderrString := string(stderrData)
|
||||
if len(stderrString) > 0 {
|
||||
logger.Debugf("[stream] ffmpeg stderr: %s", stderrString)
|
||||
}
|
||||
}()
|
||||
|
||||
ret := &Stream{
|
||||
Stdout: stdout,
|
||||
Process: cmd.Process,
|
||||
options: options,
|
||||
mimeType: options.Codec.MimeType,
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
@@ -62,10 +62,6 @@ const ShowStudioAsText = "show_studio_as_text"
|
||||
const CSSEnabled = "cssEnabled"
|
||||
const WallPlayback = "wall_playback"
|
||||
|
||||
// Playback force codec,container
|
||||
const ForceMKV = "forceMKV"
|
||||
const ForceHEVC = "forceHEVC"
|
||||
|
||||
// Logging options
|
||||
const LogFile = "logFile"
|
||||
const LogOut = "logOut"
|
||||
@@ -326,15 +322,6 @@ 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 {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"github.com/stashapp/stash/pkg/ffmpeg"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
@@ -152,3 +154,92 @@ func DeleteSceneFile(scene *models.Scene) {
|
||||
logger.Warnf("Could not delete file %s: %s", scene.Path, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func GetSceneFileContainer(scene *models.Scene) (ffmpeg.Container, error) {
|
||||
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
|
||||
tmpVideoFile, err := ffmpeg.NewVideoFile(GetInstance().FFProbePath, scene.Path)
|
||||
if err != nil {
|
||||
return ffmpeg.Container(""), fmt.Errorf("error reading video file: %s", err.Error())
|
||||
}
|
||||
|
||||
container = ffmpeg.MatchContainer(tmpVideoFile.Container, scene.Path)
|
||||
}
|
||||
|
||||
return container, nil
|
||||
}
|
||||
|
||||
func GetSceneStreamPaths(scene *models.Scene, directStreamURL string) ([]*models.SceneStreamEndpoint, error) {
|
||||
if scene == nil {
|
||||
return nil, fmt.Errorf("nil scene")
|
||||
}
|
||||
|
||||
var ret []*models.SceneStreamEndpoint
|
||||
mimeWebm := ffmpeg.MimeWebm
|
||||
mimeHLS := ffmpeg.MimeHLS
|
||||
mimeMp4 := ffmpeg.MimeMp4
|
||||
|
||||
labelWebm := "webm"
|
||||
labelHLS := "HLS"
|
||||
|
||||
// direct stream should only apply when the audio codec is supported
|
||||
audioCodec := ffmpeg.MissingUnsupported
|
||||
if scene.AudioCodec.Valid {
|
||||
audioCodec = ffmpeg.AudioCodec(scene.AudioCodec.String)
|
||||
}
|
||||
container, err := GetSceneFileContainer(scene)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hasTranscode, _ := HasTranscode(scene)
|
||||
if hasTranscode || ffmpeg.IsValidAudioForContainer(audioCodec, container) {
|
||||
label := "Direct stream"
|
||||
ret = append(ret, &models.SceneStreamEndpoint{
|
||||
URL: directStreamURL,
|
||||
MimeType: &mimeMp4,
|
||||
Label: &label,
|
||||
})
|
||||
}
|
||||
|
||||
// only add mkv stream endpoint if the scene container is an mkv already
|
||||
if container == ffmpeg.Matroska {
|
||||
label := "mkv"
|
||||
ret = append(ret, &models.SceneStreamEndpoint{
|
||||
URL: directStreamURL + ".mkv",
|
||||
// set mkv to mp4 to trick the client, since many clients won't try mkv
|
||||
MimeType: &mimeMp4,
|
||||
Label: &label,
|
||||
})
|
||||
}
|
||||
|
||||
defaultStreams := []*models.SceneStreamEndpoint{
|
||||
{
|
||||
URL: directStreamURL + ".webm",
|
||||
MimeType: &mimeWebm,
|
||||
Label: &labelWebm,
|
||||
},
|
||||
{
|
||||
URL: directStreamURL + ".m3u8",
|
||||
MimeType: &mimeHLS,
|
||||
Label: &labelHLS,
|
||||
},
|
||||
}
|
||||
|
||||
ret = append(ret, defaultStreams...)
|
||||
|
||||
// TODO - at some point, look at streaming at various resolutions
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func HasTranscode(scene *models.Scene) (bool, error) {
|
||||
if scene == nil {
|
||||
return false, fmt.Errorf("nil scene")
|
||||
}
|
||||
transcodePath := instance.Paths.Scene.GetTranscodePath(scene.Checksum)
|
||||
return utils.FileExists(transcodePath)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func (t *GenerateTranscodeTask) Start(wg *sync.WaitGroup) {
|
||||
audioCodec = ffmpeg.AudioCodec(t.Scene.AudioCodec.String)
|
||||
}
|
||||
|
||||
if ffmpeg.IsValidCodec(videoCodec) && ffmpeg.IsValidCombo(videoCodec, container) && ffmpeg.IsValidAudioForContainer(audioCodec, container) {
|
||||
if ffmpeg.IsStreamable(videoCodec, audioCodec, container) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ func (t *GenerateTranscodeTask) isTranscodeNeeded() bool {
|
||||
container = t.Scene.Format.String
|
||||
}
|
||||
|
||||
if ffmpeg.IsValidCodec(videoCodec) && ffmpeg.IsValidCombo(videoCodec, ffmpeg.Container(container)) && ffmpeg.IsValidAudioForContainer(audioCodec, ffmpeg.Container(container)) {
|
||||
if ffmpeg.IsStreamable(videoCodec, audioCodec, ffmpeg.Container(container)) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package manager
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
func HasTranscode(scene *models.Scene) (bool, error) {
|
||||
if scene == nil {
|
||||
return false, fmt.Errorf("nil scene")
|
||||
}
|
||||
transcodePath := instance.Paths.Scene.GetTranscodePath(scene.Checksum)
|
||||
return utils.FileExists(transcodePath)
|
||||
}
|
||||
59
pkg/utils/byterange.go
Normal file
59
pkg/utils/byterange.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ByteRange struct {
|
||||
Start int64
|
||||
End *int64
|
||||
RawString string
|
||||
}
|
||||
|
||||
func CreateByteRange(s string) ByteRange {
|
||||
// strip bytes=
|
||||
r := strings.TrimPrefix(s, "bytes=")
|
||||
e := strings.Split(r, "-")
|
||||
|
||||
ret := ByteRange{
|
||||
RawString: s,
|
||||
}
|
||||
if len(e) > 0 {
|
||||
ret.Start, _ = strconv.ParseInt(e[0], 10, 64)
|
||||
}
|
||||
if len(e) > 1 && e[1] != "" {
|
||||
end, _ := strconv.ParseInt(e[1], 10, 64)
|
||||
ret.End = &end
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func (r ByteRange) getBytesToRead() int64 {
|
||||
if r.End == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return *r.End - r.Start + 1
|
||||
}
|
||||
|
||||
func (r ByteRange) ToHeaderValue(fileLength int64) string {
|
||||
if r.End == nil {
|
||||
return ""
|
||||
}
|
||||
end := *r.End
|
||||
return "bytes " + strconv.FormatInt(r.Start, 10) + "-" + strconv.FormatInt(end, 10) + "/" + strconv.FormatInt(fileLength, 10)
|
||||
}
|
||||
|
||||
func (r ByteRange) Apply(bytes []byte) []byte {
|
||||
if r.End == nil {
|
||||
return bytes[r.Start:]
|
||||
}
|
||||
|
||||
end := *r.End + 1
|
||||
if int(end) > len(bytes) {
|
||||
end = int64(len(bytes))
|
||||
}
|
||||
return bytes[r.Start:end]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/*!
|
||||
JW Player version 8.11.5
|
||||
Copyright (c) 2019, JW Player, All Rights Reserved
|
||||
Copyright (c) 2020, JW Player, All Rights Reserved
|
||||
https://github.com/jwplayer/jwplayer/blob/v8.11.5/README.md
|
||||
|
||||
This source code and its use and distribution is subject to the terms and conditions of the applicable license agreement.
|
||||
@@ -15,7 +15,7 @@ The following software is used under Apache License 2.0
|
||||
**************************************************************************************************
|
||||
|
||||
vtt.js v0.13.0
|
||||
Copyright (c) 2019 Mozilla (http://mozilla.org)
|
||||
Copyright (c) 2020 Mozilla (http://mozilla.org)
|
||||
https://github.com/mozilla/vtt.js/blob/v0.13.0/LICENSE
|
||||
|
||||
* * *
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
/*!
|
||||
JW Player version 8.11.5
|
||||
Copyright (c) 2019, JW Player, All Rights Reserved
|
||||
Copyright (c) 2020, JW Player, All Rights Reserved
|
||||
https://github.com/jwplayer/jwplayer/blob/v8.11.5/README.md
|
||||
|
||||
This source code and its use and distribution is subject to the terms and conditions of the applicable license agreement.
|
||||
@@ -15,7 +15,7 @@ The following software is used under Apache License 2.0
|
||||
**************************************************************************************************
|
||||
|
||||
vtt.js v0.13.0
|
||||
Copyright (c) 2019 Mozilla (http://mozilla.org)
|
||||
Copyright (c) 2020 Mozilla (http://mozilla.org)
|
||||
https://github.com/mozilla/vtt.js/blob/v0.13.0/LICENSE
|
||||
|
||||
* * *
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
/*!
|
||||
JW Player version 8.11.5
|
||||
Copyright (c) 2019, JW Player, All Rights Reserved
|
||||
Copyright (c) 2020, JW Player, All Rights Reserved
|
||||
https://github.com/jwplayer/jwplayer/blob/v8.11.5/README.md
|
||||
|
||||
This source code and its use and distribution is subject to the terms and conditions of the applicable license agreement.
|
||||
@@ -15,7 +15,7 @@ The following software is used under Apache License 2.0
|
||||
**************************************************************************************************
|
||||
|
||||
vtt.js v0.13.0
|
||||
Copyright (c) 2019 Mozilla (http://mozilla.org)
|
||||
Copyright (c) 2020 Mozilla (http://mozilla.org)
|
||||
https://github.com/mozilla/vtt.js/blob/v0.13.0/LICENSE
|
||||
|
||||
* * *
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*!
|
||||
JW Player version 8.11.5
|
||||
Copyright (c) 2019, JW Player, All Rights Reserved
|
||||
Copyright (c) 2020, JW Player, All Rights Reserved
|
||||
https://github.com/jwplayer/jwplayer/blob/v8.11.5/README.md
|
||||
|
||||
This source code and its use and distribution is subject to the terms and conditions of the applicable license agreement.
|
||||
@@ -15,7 +15,7 @@ The following software is used under Apache License 2.0
|
||||
**************************************************************************************************
|
||||
|
||||
vtt.js v0.13.0
|
||||
Copyright (c) 2019 Mozilla (http://mozilla.org)
|
||||
Copyright (c) 2020 Mozilla (http://mozilla.org)
|
||||
https://github.com/mozilla/vtt.js/blob/v0.13.0/LICENSE
|
||||
|
||||
* * *
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
JW Player version 8.11.5
|
||||
Copyright (c) 2019, JW Player, All Rights Reserved
|
||||
Copyright (c) 2020, JW Player, All Rights Reserved
|
||||
https://github.com/jwplayer/jwplayer/blob/v8.11.5/README.md
|
||||
|
||||
This source code and its use and distribution is subject to the terms and conditions of the applicable license agreement.
|
||||
@@ -14,7 +14,7 @@ The following software is used under Apache License 2.0
|
||||
**************************************************************************************************
|
||||
|
||||
vtt.js v0.13.0
|
||||
Copyright (c) 2019 Mozilla (http://mozilla.org)
|
||||
Copyright (c) 2020 Mozilla (http://mozilla.org)
|
||||
https://github.com/mozilla/vtt.js/blob/v0.13.0/LICENSE
|
||||
|
||||
* * *
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*!
|
||||
JW Player version 8.11.5
|
||||
Copyright (c) 2019, JW Player, All Rights Reserved
|
||||
Copyright (c) 2020, JW Player, All Rights Reserved
|
||||
https://github.com/jwplayer/jwplayer/blob/v8.11.5/README.md
|
||||
|
||||
This source code and its use and distribution is subject to the terms and conditions of the applicable license agreement.
|
||||
@@ -15,7 +15,7 @@ The following software is used under Apache License 2.0
|
||||
**************************************************************************************************
|
||||
|
||||
vtt.js v0.13.0
|
||||
Copyright (c) 2019 Mozilla (http://mozilla.org)
|
||||
Copyright (c) 2020 Mozilla (http://mozilla.org)
|
||||
https://github.com/mozilla/vtt.js/blob/v0.13.0/LICENSE
|
||||
|
||||
* * *
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*!
|
||||
JW Player version 8.11.5
|
||||
Copyright (c) 2019, JW Player, All Rights Reserved
|
||||
Copyright (c) 2020, JW Player, All Rights Reserved
|
||||
https://github.com/jwplayer/jwplayer/blob/v8.11.5/README.md
|
||||
|
||||
This source code and its use and distribution is subject to the terms and conditions of the applicable license agreement.
|
||||
@@ -15,7 +15,7 @@ The following software is used under Apache License 2.0
|
||||
**************************************************************************************************
|
||||
|
||||
vtt.js v0.13.0
|
||||
Copyright (c) 2019 Mozilla (http://mozilla.org)
|
||||
Copyright (c) 2020 Mozilla (http://mozilla.org)
|
||||
https://github.com/mozilla/vtt.js/blob/v0.13.0/LICENSE
|
||||
|
||||
* * *
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
/*!
|
||||
JW Player version 8.11.5
|
||||
Copyright (c) 2019, JW Player, All Rights Reserved
|
||||
Copyright (c) 2020, JW Player, All Rights Reserved
|
||||
https://github.com/jwplayer/jwplayer/blob/v8.11.5/README.md
|
||||
|
||||
This source code and its use and distribution is subject to the terms and conditions of the applicable license agreement.
|
||||
@@ -15,7 +15,7 @@ The following software is used under Apache License 2.0
|
||||
**************************************************************************************************
|
||||
|
||||
vtt.js v0.13.0
|
||||
Copyright (c) 2019 Mozilla (http://mozilla.org)
|
||||
Copyright (c) 2020 Mozilla (http://mozilla.org)
|
||||
https://github.com/mozilla/vtt.js/blob/v0.13.0/LICENSE
|
||||
|
||||
* * *
|
||||
|
||||
@@ -13,6 +13,7 @@ const markup = `
|
||||
* Add support for parent/child studios.
|
||||
|
||||
### 🎨 Improvements
|
||||
* Add support for live transcoding in Safari.
|
||||
* Add mapped and fixed post-processing scraping options.
|
||||
* Add random sorting for performers.
|
||||
* Search for files which have low or upper case supported filename extensions.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React from "react";
|
||||
import ReactJWPlayer from "react-jw-player";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
@@ -8,6 +9,7 @@ import { ScenePlayerScrubber } from "./ScenePlayerScrubber";
|
||||
interface IScenePlayerProps {
|
||||
className?: string;
|
||||
scene: GQL.SceneDataFragment;
|
||||
sceneStreams: GQL.SceneStreamEndpoint[];
|
||||
timestamp: number;
|
||||
autoplay?: boolean;
|
||||
onReady?: () => void;
|
||||
@@ -25,9 +27,23 @@ export class ScenePlayerImpl extends React.Component<
|
||||
IScenePlayerProps,
|
||||
IScenePlayerState
|
||||
> {
|
||||
private static isDirectStream(src?: string) {
|
||||
if (!src) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const startIndex = src.lastIndexOf("?start=");
|
||||
let srcCopy = src;
|
||||
if (startIndex !== -1) {
|
||||
srcCopy = srcCopy.substring(0, startIndex);
|
||||
}
|
||||
|
||||
return srcCopy.endsWith("/stream");
|
||||
}
|
||||
|
||||
// Typings for jwplayer are, unfortunately, very lacking
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private player: any;
|
||||
private playlist: any;
|
||||
private lastTime = 0;
|
||||
|
||||
constructor(props: IScenePlayerProps) {
|
||||
@@ -82,6 +98,23 @@ export class ScenePlayerImpl extends React.Component<
|
||||
if (this.props.timestamp > 0) {
|
||||
this.player.seek(this.props.timestamp);
|
||||
}
|
||||
|
||||
this.player.on("error", (err: any) => {
|
||||
if (err && err.code === 224003) {
|
||||
this.handleError();
|
||||
}
|
||||
});
|
||||
|
||||
this.player.on("meta", (metadata: any) => {
|
||||
if (
|
||||
metadata.metadataType === "media" &&
|
||||
!metadata.width &&
|
||||
!metadata.height
|
||||
) {
|
||||
// treat this as a decoding error and try the next source
|
||||
this.handleError();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private onSeeked() {
|
||||
@@ -107,6 +140,21 @@ export class ScenePlayerImpl extends React.Component<
|
||||
this.player.pause();
|
||||
}
|
||||
|
||||
private handleError() {
|
||||
const currentFile = this.player.getPlaylistItem();
|
||||
if (currentFile) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Source failed: ${currentFile.file}`);
|
||||
}
|
||||
|
||||
if (this.tryNextStream()) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Trying next source in playlist");
|
||||
this.player.load(this.playlist);
|
||||
this.player.play();
|
||||
}
|
||||
}
|
||||
|
||||
private shouldRepeat(scene: GQL.SceneDataFragment) {
|
||||
const maxLoopDuration = this.props?.config?.maximumLoopDuration ?? 0;
|
||||
return (
|
||||
@@ -116,52 +164,85 @@ export class ScenePlayerImpl extends React.Component<
|
||||
);
|
||||
}
|
||||
|
||||
private tryNextStream() {
|
||||
if (this.playlist.sources.length > 1) {
|
||||
this.playlist.sources.shift();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private makePlaylist() {
|
||||
return {
|
||||
tracks: [
|
||||
{
|
||||
file: this.props.scene.paths.vtt,
|
||||
kind: "thumbnails",
|
||||
},
|
||||
{
|
||||
file: this.props.scene.paths.chapters_vtt,
|
||||
kind: "chapters",
|
||||
},
|
||||
],
|
||||
sources: this.props.sceneStreams.map((s) => {
|
||||
return {
|
||||
file: s.url,
|
||||
type: s.mime_type,
|
||||
label: s.label,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private makeJWPlayerConfig(scene: GQL.SceneDataFragment) {
|
||||
if (!scene.paths.stream) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const repeat = this.shouldRepeat(scene);
|
||||
let getDurationHook: (() => GQL.Maybe<number>) | undefined;
|
||||
let seekHook:
|
||||
| ((seekToPosition: number, _videoTag: HTMLVideoElement) => void)
|
||||
| undefined;
|
||||
let getCurrentTimeHook:
|
||||
| ((_videoTag: HTMLVideoElement) => number)
|
||||
| undefined;
|
||||
const getDurationHook = () => {
|
||||
return this.props.scene.file.duration ?? null;
|
||||
};
|
||||
|
||||
if (!this.props.scene.is_streamable) {
|
||||
getDurationHook = () => {
|
||||
return this.props.scene.file.duration ?? null;
|
||||
};
|
||||
const seekHook = (seekToPosition: number, _videoTag: HTMLVideoElement) => {
|
||||
if (
|
||||
ScenePlayerImpl.isDirectStream(_videoTag.src) ||
|
||||
_videoTag.src.endsWith(".m3u8")
|
||||
) {
|
||||
// direct stream - fall back to default
|
||||
return false;
|
||||
}
|
||||
|
||||
seekHook = (seekToPosition: number, _videoTag: HTMLVideoElement) => {
|
||||
/* eslint-disable no-param-reassign */
|
||||
_videoTag.dataset.start = seekToPosition.toString();
|
||||
_videoTag.src = `${this.props.scene.paths.stream}?start=${seekToPosition}`;
|
||||
/* eslint-enable no-param-reassign */
|
||||
_videoTag.play();
|
||||
};
|
||||
// remove the start parameter
|
||||
let { src } = _videoTag;
|
||||
|
||||
getCurrentTimeHook = (_videoTag: HTMLVideoElement) => {
|
||||
const start = Number.parseInt(_videoTag.dataset?.start ?? "0", 10);
|
||||
return _videoTag.currentTime + start;
|
||||
};
|
||||
}
|
||||
const startIndex = src.lastIndexOf("?start=");
|
||||
if (startIndex !== -1) {
|
||||
src = src.substring(0, startIndex);
|
||||
}
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
_videoTag.dataset.start = seekToPosition.toString();
|
||||
|
||||
_videoTag.src = `${src}?start=${seekToPosition}`;
|
||||
/* eslint-enable no-param-reassign */
|
||||
_videoTag.play();
|
||||
|
||||
// return true to indicate not to fall through to default
|
||||
return true;
|
||||
};
|
||||
|
||||
const getCurrentTimeHook = (_videoTag: HTMLVideoElement) => {
|
||||
const start = Number.parseFloat(_videoTag.dataset?.start ?? "0");
|
||||
return _videoTag.currentTime + start;
|
||||
};
|
||||
|
||||
this.playlist = this.makePlaylist();
|
||||
|
||||
const ret = {
|
||||
file: scene.paths.stream,
|
||||
playlist: this.playlist,
|
||||
image: scene.paths.screenshot,
|
||||
tracks: [
|
||||
{
|
||||
file: scene.paths.vtt,
|
||||
kind: "thumbnails",
|
||||
},
|
||||
{
|
||||
file: scene.paths.chapters_vtt,
|
||||
kind: "chapters",
|
||||
},
|
||||
],
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
floating: {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useSceneIncrementO,
|
||||
useSceneDecrementO,
|
||||
useSceneResetO,
|
||||
useSceneStreams,
|
||||
useSceneGenerateScreenshot,
|
||||
} from "src/core/StashService";
|
||||
import { GalleryViewer } from "src/components/Galleries/GalleryViewer";
|
||||
@@ -35,6 +36,11 @@ export const Scene: React.FC = () => {
|
||||
|
||||
const [scene, setScene] = useState<GQL.SceneDataFragment | undefined>();
|
||||
const { data, error, loading } = useFindScene(id);
|
||||
const {
|
||||
data: sceneStreams,
|
||||
error: streamableError,
|
||||
loading: streamableLoading,
|
||||
} = useSceneStreams(id);
|
||||
const [oLoading, setOLoading] = useState(false);
|
||||
const [incrementO] = useSceneIncrementO(scene?.id ?? "0");
|
||||
const [decrementO] = useSceneDecrementO(scene?.id ?? "0");
|
||||
@@ -305,11 +311,12 @@ export const Scene: React.FC = () => {
|
||||
};
|
||||
});
|
||||
|
||||
if (loading || !scene || !data?.findScene) {
|
||||
if (loading || streamableLoading || !scene || !data?.findScene) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
if (error) return <div>{error.message}</div>;
|
||||
if (streamableError) return <div>{streamableError.message}</div>;
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
@@ -340,6 +347,7 @@ export const Scene: React.FC = () => {
|
||||
scene={scene}
|
||||
timestamp={timestamp}
|
||||
autoplay={autoplay}
|
||||
sceneStreams={sceneStreams?.sceneStreams ?? []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,8 +26,6 @@ export const SettingsConfigurationPanel: React.FC = () => {
|
||||
const [maxStreamingTranscodeSize, setMaxStreamingTranscodeSize] = useState<
|
||||
GQL.StreamingResolutionEnum | undefined
|
||||
>(undefined);
|
||||
const [forceMkv, setForceMkv] = useState<boolean>(false);
|
||||
const [forceHevc, setForceHevc] = useState<boolean>(false);
|
||||
const [username, setUsername] = useState<string | undefined>(undefined);
|
||||
const [password, setPassword] = useState<string | undefined>(undefined);
|
||||
const [maxSessionAge, setMaxSessionAge] = useState<number>(0);
|
||||
@@ -50,8 +48,6 @@ export const SettingsConfigurationPanel: React.FC = () => {
|
||||
previewPreset: (previewPreset as GQL.PreviewPreset) ?? undefined,
|
||||
maxTranscodeSize,
|
||||
maxStreamingTranscodeSize,
|
||||
forceMkv,
|
||||
forceHevc,
|
||||
username,
|
||||
password,
|
||||
maxSessionAge,
|
||||
@@ -77,8 +73,6 @@ 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);
|
||||
@@ -343,30 +337,6 @@ export const SettingsConfigurationPanel: React.FC = () => {
|
||||
Maximum size for transcoded streams
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
<Form.Group id="force-options-mkv">
|
||||
<Form.Check
|
||||
id="force-mkv"
|
||||
checked={forceMkv}
|
||||
label="Force Matroska as supported"
|
||||
onChange={() => setForceMkv(!forceMkv)}
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
Treat Matroska (MKV) as a supported container. Recommended for
|
||||
Chromium based browsers
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
<Form.Group id="force-options-hevc">
|
||||
<Form.Check
|
||||
id="force-hevc"
|
||||
checked={forceHevc}
|
||||
label="Force HEVC as supported"
|
||||
onChange={() => setForceHevc(!forceHevc)}
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
Treat HEVC as a supported codec. Recommended for Safari or some
|
||||
Android based browsers
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
</Form.Group>
|
||||
|
||||
<hr />
|
||||
|
||||
@@ -116,6 +116,9 @@ export const useFindGallery = (id: string) =>
|
||||
GQL.useFindGalleryQuery({ variables: { id } });
|
||||
export const useFindScene = (id: string) =>
|
||||
GQL.useFindSceneQuery({ variables: { id } });
|
||||
export const useSceneStreams = (id: string) =>
|
||||
GQL.useSceneStreamsQuery({ variables: { id } });
|
||||
|
||||
export const useFindPerformer = (id: string) => {
|
||||
const skip = id === "new";
|
||||
return GQL.useFindPerformerQuery({ variables: { id }, skip });
|
||||
|
||||
@@ -2,6 +2,8 @@ const playerID = "main-jwplayer";
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const getPlayer = () => (window as any).jwplayer(playerID);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
export default {
|
||||
playerID,
|
||||
getPlayer,
|
||||
|
||||
Reference in New Issue
Block a user