Transcode stream refactor (#609)

* Remove forceMkv and forceHEVC
* Add HLS support and refactor
* Add new streaming endpoints
This commit is contained in:
WithoutPants
2020-07-23 11:56:08 +10:00
committed by GitHub
parent 274d84ce93
commit 37be146a9d
40 changed files with 769 additions and 374 deletions

View File

@@ -6,8 +6,6 @@ fragment ConfigGeneralData on ConfigGeneralResult {
previewPreset previewPreset
maxTranscodeSize maxTranscodeSize
maxStreamingTranscodeSize maxStreamingTranscodeSize
forceMkv
forceHevc
username username
password password
maxSessionAge maxSessionAge

View File

@@ -33,8 +33,6 @@ fragment SceneData on Scene {
...SceneMarkerData ...SceneMarkerData
} }
is_streamable
gallery { gallery {
...GalleryData ...GalleryData
} }

View File

@@ -54,3 +54,11 @@ query ParseSceneFilenames($filter: FindFilterType!, $config: SceneParserInput!)
} }
} }
} }
query SceneStreams($id: ID!) {
sceneStreams(id: $id) {
url
mime_type
label
}
}

View File

@@ -7,6 +7,9 @@ type Query {
findScenesByPathRegex(filter: FindFilterType): FindScenesResultType! findScenesByPathRegex(filter: FindFilterType): FindScenesResultType!
"""Return valid stream paths"""
sceneStreams(id: ID): [SceneStreamEndpoint!]!
parseSceneFilenames(filter: FindFilterType, config: SceneParserInput!): SceneParserResultType! parseSceneFilenames(filter: FindFilterType, config: SceneParserInput!): SceneParserResultType!
"""A function which queries SceneMarker objects""" """A function which queries SceneMarker objects"""

View File

@@ -32,10 +32,6 @@ input ConfigGeneralInput {
maxTranscodeSize: StreamingResolutionEnum maxTranscodeSize: StreamingResolutionEnum
"""Max streaming transcode size""" """Max streaming transcode size"""
maxStreamingTranscodeSize: StreamingResolutionEnum maxStreamingTranscodeSize: StreamingResolutionEnum
"""Force MKV as supported format"""
forceMkv: Boolean!
"""Force HEVC as a supported codec"""
forceHevc: Boolean!
"""Username""" """Username"""
username: String username: String
"""Password""" """Password"""
@@ -71,10 +67,6 @@ type ConfigGeneralResult {
maxTranscodeSize: StreamingResolutionEnum maxTranscodeSize: StreamingResolutionEnum
"""Max streaming transcode size""" """Max streaming transcode size"""
maxStreamingTranscodeSize: StreamingResolutionEnum maxStreamingTranscodeSize: StreamingResolutionEnum
"""Force MKV as supported format"""
forceMkv: Boolean!
"""Force HEVC as a supported codec"""
forceHevc: Boolean!
"""Username""" """Username"""
username: String! username: String!
"""Password""" """Password"""

View File

@@ -36,7 +36,6 @@ type Scene {
file: SceneFileType! # Resolver file: SceneFileType! # Resolver
paths: ScenePathsType! # Resolver paths: ScenePathsType! # Resolver
is_streamable: Boolean! # Resolver
scene_markers: [SceneMarker!]! scene_markers: [SceneMarker!]!
gallery: Gallery gallery: Gallery
@@ -139,3 +138,9 @@ type SceneParserResultType {
count: Int! count: Int!
results: [SceneParserResult!]! results: [SceneParserResult!]!
} }
type SceneStreamEndpoint {
url: String!
mime_type: String
label: String
}

View File

@@ -4,7 +4,6 @@ import (
"context" "context"
"github.com/stashapp/stash/pkg/api/urlbuilders" "github.com/stashapp/stash/pkg/api/urlbuilders"
"github.com/stashapp/stash/pkg/manager"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils" "github.com/stashapp/stash/pkg/utils"
) )
@@ -81,12 +80,6 @@ func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*models.S
}, nil }, 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) { func (r *sceneResolver) SceneMarkers(ctx context.Context, obj *models.Scene) ([]*models.SceneMarker, error) {
qb := models.NewSceneMarkerQueryBuilder() qb := models.NewSceneMarkerQueryBuilder()
return qb.FindBySceneID(obj.ID, nil) return qb.FindBySceneID(obj.ID, nil)

View File

@@ -56,8 +56,6 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co
if input.MaxStreamingTranscodeSize != nil { if input.MaxStreamingTranscodeSize != nil {
config.Set(config.MaxStreamingTranscodeSize, input.MaxStreamingTranscodeSize.String()) config.Set(config.MaxStreamingTranscodeSize, input.MaxStreamingTranscodeSize.String())
} }
config.Set(config.ForceMKV, input.ForceMkv)
config.Set(config.ForceHEVC, input.ForceHevc)
if input.Username != nil { if input.Username != nil {
config.Set(config.Username, input.Username) config.Set(config.Username, input.Username)

View File

@@ -49,8 +49,6 @@ func makeConfigGeneralResult() *models.ConfigGeneralResult {
PreviewPreset: config.GetPreviewPreset(), PreviewPreset: config.GetPreviewPreset(),
MaxTranscodeSize: &maxTranscodeSize, MaxTranscodeSize: &maxTranscodeSize,
MaxStreamingTranscodeSize: &maxStreamingTranscodeSize, MaxStreamingTranscodeSize: &maxStreamingTranscodeSize,
ForceMkv: config.GetForceMKV(),
ForceHevc: config.GetForceHEVC(),
Username: config.GetUsername(), Username: config.GetUsername(),
Password: config.GetPasswordHash(), Password: config.GetPasswordHash(),
MaxSessionAge: config.GetMaxSessionAge(), MaxSessionAge: config.GetMaxSessionAge(),

View 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())
}

View File

@@ -2,9 +2,7 @@ package api
import ( import (
"context" "context"
"io"
"net/http" "net/http"
"os"
"strconv" "strconv"
"strings" "strings"
@@ -24,8 +22,15 @@ func (rs sceneRoutes) Routes() chi.Router {
r.Route("/{sceneId}", func(r chi.Router) { r.Route("/{sceneId}", func(r chi.Router) {
r.Use(SceneCtx) 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("/screenshot", rs.Screenshot)
r.Get("/preview", rs.Preview) r.Get("/preview", rs.Preview)
r.Get("/webp", rs.Webp) r.Get("/webp", rs.Webp)
@@ -42,41 +47,94 @@ func (rs sceneRoutes) Routes() chi.Router {
// region Handlers // region Handlers
func (rs sceneRoutes) Stream(w http.ResponseWriter, r *http.Request) { func getSceneFileContainer(scene *models.Scene) ffmpeg.Container {
var container ffmpeg.Container
scene := r.Context().Value(sceneKey).(*models.Scene)
container := ""
if scene.Format.Valid { if scene.Format.Valid {
container = scene.Format.String container = ffmpeg.Container(scene.Format.String)
} else { // container isn't in the DB } else { // container isn't in the DB
// shouldn't happen, fallback to ffprobe // shouldn't happen, fallback to ffprobe
tmpVideoFile, err := ffmpeg.NewVideoFile(manager.GetInstance().FFProbePath, scene.Path) tmpVideoFile, err := ffmpeg.NewVideoFile(manager.GetInstance().FFProbePath, scene.Path)
if err != nil { if err != nil {
logger.Errorf("[transcode] error reading video file: %s", err.Error()) 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) filepath := manager.GetInstance().Paths.Scene.GetStreamPath(scene.Path, scene.Checksum)
videoCodec := scene.VideoCodec.String manager.RegisterStream(filepath, &w)
audioCodec := ffmpeg.MissingUnsupported http.ServeFile(w, r, filepath)
if scene.AudioCodec.Valid { manager.WaitAndDeregisterStream(filepath, &w, r)
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)
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 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 // needs to be transcoded
videoFile, err := ffmpeg.NewVideoFile(manager.GetInstance().FFProbePath, scene.Path) videoFile, err := ffmpeg.NewVideoFile(manager.GetInstance().FFProbePath, scene.Path)
if err != nil { if err != nil {
logger.Errorf("[stream] error reading video file: %s", err.Error()) 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() r.ParseForm()
startTime := r.Form.Get("start") startTime := r.Form.Get("start")
encoder := ffmpeg.NewEncoder(manager.GetInstance().FFMPEGPath) var stream *ffmpeg.Stream
var stream io.ReadCloser audioCodec := ffmpeg.MissingUnsupported
var process *os.Process if scene.AudioCodec.Valid {
mimeType := ffmpeg.MimeWebm audioCodec = ffmpeg.AudioCodec(scene.AudioCodec.String)
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())
}
} }
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 { if err != nil {
logger.Errorf("[stream] error transcoding video file: %s", err.Error()) logger.Errorf("[stream] error transcoding video file: %s", err.Error())
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return return
} }
w.WriteHeader(http.StatusOK) stream.Serve(w, r)
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())
}
} }
func (rs sceneRoutes) Screenshot(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) Screenshot(w http.ResponseWriter, r *http.Request) {

View File

@@ -18,7 +18,7 @@ func NewSceneURLBuilder(baseURL string, sceneID int) SceneURLBuilder {
} }
func (b SceneURLBuilder) GetStreamURL() string { 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 { func (b SceneURLBuilder) GetStreamPreviewURL() string {

View File

@@ -2,7 +2,6 @@ package ffmpeg
import ( import (
"fmt" "fmt"
"io"
"io/ioutil" "io/ioutil"
"os" "os"
"os/exec" "os/exec"
@@ -133,21 +132,3 @@ func (e *Encoder) run(probeResult VideoFile, args []string) (string, error) {
return stdoutString, nil 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
}

View File

@@ -1,8 +1,6 @@
package ffmpeg package ffmpeg
import ( import (
"io"
"os"
"strconv" "strconv"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
@@ -111,77 +109,3 @@ func (e *Encoder) CopyVideo(probeResult VideoFile, options TranscodeOptions) {
} }
_, _ = e.run(probeResult, args) _, _ = 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)
}

View File

@@ -12,7 +12,6 @@ import (
"time" "time"
"github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/manager/config"
) )
type Container string type Container string
@@ -47,11 +46,17 @@ const (
Hevc string = "hevc" Hevc string = "hevc"
Vp8 string = "vp8" Vp8 string = "vp8"
Vp9 string = "vp9" 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" MimeWebm string = "video/webm"
MimeMkv string = "video/x-matroska" 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 validForH264Mkv = []Container{Mp4, Matroska}
var validForH264 = []Container{Mp4} var validForH264 = []Container{Mp4}
@@ -102,15 +107,8 @@ func MatchContainer(format string, filePath string) Container { // match ffprobe
return container return container
} }
func IsValidCodec(codecName string) bool { func IsValidCodec(codecName string, supportedCodecs []string) bool {
forceHEVC := config.GetForceHEVC() for _, c := range supportedCodecs {
if forceHEVC {
if codecName == Hevc {
return true
}
}
for _, c := range ValidCodecs {
if c == codecName { if c == codecName {
return true return true
} }
@@ -158,30 +156,31 @@ func IsValidForContainer(format Container, validContainers []Container) bool {
} }
//extend stream validation check to take into account container //extend stream validation check to take into account container
func IsValidCombo(codecName string, format Container) bool { func IsValidCombo(codecName string, format Container, supportedVideoCodecs []string) bool {
forceMKV := config.GetForceMKV() supportMKV := IsValidCodec(Mkv, supportedVideoCodecs)
forceHEVC := config.GetForceHEVC() supportHEVC := IsValidCodec(Hevc, supportedVideoCodecs)
switch codecName { switch codecName {
case H264: case H264:
if forceMKV { if supportMKV {
return IsValidForContainer(format, validForH264Mkv) return IsValidForContainer(format, validForH264Mkv)
} }
return IsValidForContainer(format, validForH264) return IsValidForContainer(format, validForH264)
case H265: case H265:
if forceMKV { if supportMKV {
return IsValidForContainer(format, validForH265Mkv) return IsValidForContainer(format, validForH265Mkv)
} }
return IsValidForContainer(format, validForH265) return IsValidForContainer(format, validForH265)
case Vp8: case Vp8:
return IsValidForContainer(format, validForVp8) return IsValidForContainer(format, validForVp8)
case Vp9: case Vp9:
if forceMKV { if supportMKV {
return IsValidForContainer(format, validForVp9Mkv) return IsValidForContainer(format, validForVp9Mkv)
} }
return IsValidForContainer(format, validForVp9) return IsValidForContainer(format, validForVp9)
case Hevc: case Hevc:
if forceHEVC { if supportHEVC {
if forceMKV { if supportMKV {
return IsValidForContainer(format, validForHevcMkv) return IsValidForContainer(format, validForHevcMkv)
} }
return IsValidForContainer(format, validForHevc) return IsValidForContainer(format, validForHevc)
@@ -190,6 +189,13 @@ func IsValidCombo(codecName string, format Container) bool {
return false 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 { type VideoFile struct {
JSON FFProbeJSON JSON FFProbeJSON
AudioStream *FFProbeStream AudioStream *FFProbeStream

42
pkg/ffmpeg/hls.go Normal file
View 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
View 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
}

View File

@@ -62,10 +62,6 @@ const ShowStudioAsText = "show_studio_as_text"
const CSSEnabled = "cssEnabled" const CSSEnabled = "cssEnabled"
const WallPlayback = "wall_playback" const WallPlayback = "wall_playback"
// Playback force codec,container
const ForceMKV = "forceMKV"
const ForceHEVC = "forceHEVC"
// Logging options // Logging options
const LogFile = "logFile" const LogFile = "logFile"
const LogOut = "logOut" const LogOut = "logOut"
@@ -326,15 +322,6 @@ func GetCSSEnabled() bool {
return viper.GetBool(CSSEnabled) 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. // GetLogFile returns the filename of the file to output logs to.
// An empty string means that file logging will be disabled. // An empty string means that file logging will be disabled.
func GetLogFile() string { func GetLogFile() string {

View File

@@ -1,12 +1,14 @@
package manager package manager
import ( import (
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils" "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()) 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)
}

View File

@@ -45,7 +45,7 @@ func (t *GenerateTranscodeTask) Start(wg *sync.WaitGroup) {
audioCodec = ffmpeg.AudioCodec(t.Scene.AudioCodec.String) 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 return
} }
@@ -103,7 +103,7 @@ func (t *GenerateTranscodeTask) isTranscodeNeeded() bool {
container = t.Scene.Format.String 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 return false
} }

View File

@@ -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
View 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]
}

View File

@@ -1,6 +1,6 @@
/*! /*!
JW Player version 8.11.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 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. 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 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 https://github.com/mozilla/vtt.js/blob/v0.13.0/LICENSE
* * * * * *

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
/*! /*!
JW Player version 8.11.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 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. 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 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 https://github.com/mozilla/vtt.js/blob/v0.13.0/LICENSE
* * * * * *

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
/*! /*!
JW Player version 8.11.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 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. 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 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 https://github.com/mozilla/vtt.js/blob/v0.13.0/LICENSE
* * * * * *

View File

@@ -1,6 +1,6 @@
/*! /*!
JW Player version 8.11.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 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. 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 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 https://github.com/mozilla/vtt.js/blob/v0.13.0/LICENSE
* * * * * *

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
JW Player version 8.11.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 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. 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 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 https://github.com/mozilla/vtt.js/blob/v0.13.0/LICENSE
* * * * * *

View File

@@ -1,6 +1,6 @@
/*! /*!
JW Player version 8.11.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 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. 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 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 https://github.com/mozilla/vtt.js/blob/v0.13.0/LICENSE
* * * * * *

View File

@@ -1,6 +1,6 @@
/*! /*!
JW Player version 8.11.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 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. 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 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 https://github.com/mozilla/vtt.js/blob/v0.13.0/LICENSE
* * * * * *

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
/*! /*!
JW Player version 8.11.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 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. 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 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 https://github.com/mozilla/vtt.js/blob/v0.13.0/LICENSE
* * * * * *

View File

@@ -13,6 +13,7 @@ const markup = `
* Add support for parent/child studios. * Add support for parent/child studios.
### 🎨 Improvements ### 🎨 Improvements
* Add support for live transcoding in Safari.
* Add mapped and fixed post-processing scraping options. * Add mapped and fixed post-processing scraping options.
* Add random sorting for performers. * Add random sorting for performers.
* Search for files which have low or upper case supported filename extensions. * Search for files which have low or upper case supported filename extensions.

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from "react"; import React from "react";
import ReactJWPlayer from "react-jw-player"; import ReactJWPlayer from "react-jw-player";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
@@ -8,6 +9,7 @@ import { ScenePlayerScrubber } from "./ScenePlayerScrubber";
interface IScenePlayerProps { interface IScenePlayerProps {
className?: string; className?: string;
scene: GQL.SceneDataFragment; scene: GQL.SceneDataFragment;
sceneStreams: GQL.SceneStreamEndpoint[];
timestamp: number; timestamp: number;
autoplay?: boolean; autoplay?: boolean;
onReady?: () => void; onReady?: () => void;
@@ -25,9 +27,23 @@ export class ScenePlayerImpl extends React.Component<
IScenePlayerProps, IScenePlayerProps,
IScenePlayerState 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 // Typings for jwplayer are, unfortunately, very lacking
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private player: any; private player: any;
private playlist: any;
private lastTime = 0; private lastTime = 0;
constructor(props: IScenePlayerProps) { constructor(props: IScenePlayerProps) {
@@ -82,6 +98,23 @@ export class ScenePlayerImpl extends React.Component<
if (this.props.timestamp > 0) { if (this.props.timestamp > 0) {
this.player.seek(this.props.timestamp); 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() { private onSeeked() {
@@ -107,6 +140,21 @@ export class ScenePlayerImpl extends React.Component<
this.player.pause(); 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) { private shouldRepeat(scene: GQL.SceneDataFragment) {
const maxLoopDuration = this.props?.config?.maximumLoopDuration ?? 0; const maxLoopDuration = this.props?.config?.maximumLoopDuration ?? 0;
return ( 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) { private makeJWPlayerConfig(scene: GQL.SceneDataFragment) {
if (!scene.paths.stream) { if (!scene.paths.stream) {
return {}; return {};
} }
const repeat = this.shouldRepeat(scene); const repeat = this.shouldRepeat(scene);
let getDurationHook: (() => GQL.Maybe<number>) | undefined; const getDurationHook = () => {
let seekHook: return this.props.scene.file.duration ?? null;
| ((seekToPosition: number, _videoTag: HTMLVideoElement) => void) };
| undefined;
let getCurrentTimeHook:
| ((_videoTag: HTMLVideoElement) => number)
| undefined;
if (!this.props.scene.is_streamable) { const seekHook = (seekToPosition: number, _videoTag: HTMLVideoElement) => {
getDurationHook = () => { if (
return this.props.scene.file.duration ?? null; ScenePlayerImpl.isDirectStream(_videoTag.src) ||
}; _videoTag.src.endsWith(".m3u8")
) {
// direct stream - fall back to default
return false;
}
seekHook = (seekToPosition: number, _videoTag: HTMLVideoElement) => { // remove the start parameter
/* eslint-disable no-param-reassign */ let { src } = _videoTag;
_videoTag.dataset.start = seekToPosition.toString();
_videoTag.src = `${this.props.scene.paths.stream}?start=${seekToPosition}`;
/* eslint-enable no-param-reassign */
_videoTag.play();
};
getCurrentTimeHook = (_videoTag: HTMLVideoElement) => { const startIndex = src.lastIndexOf("?start=");
const start = Number.parseInt(_videoTag.dataset?.start ?? "0", 10); if (startIndex !== -1) {
return _videoTag.currentTime + start; 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 = { const ret = {
file: scene.paths.stream, playlist: this.playlist,
image: scene.paths.screenshot, image: scene.paths.screenshot,
tracks: [
{
file: scene.paths.vtt,
kind: "thumbnails",
},
{
file: scene.paths.chapters_vtt,
kind: "chapters",
},
],
width: "100%", width: "100%",
height: "100%", height: "100%",
floating: { floating: {

View File

@@ -8,6 +8,7 @@ import {
useSceneIncrementO, useSceneIncrementO,
useSceneDecrementO, useSceneDecrementO,
useSceneResetO, useSceneResetO,
useSceneStreams,
useSceneGenerateScreenshot, useSceneGenerateScreenshot,
} from "src/core/StashService"; } from "src/core/StashService";
import { GalleryViewer } from "src/components/Galleries/GalleryViewer"; import { GalleryViewer } from "src/components/Galleries/GalleryViewer";
@@ -35,6 +36,11 @@ export const Scene: React.FC = () => {
const [scene, setScene] = useState<GQL.SceneDataFragment | undefined>(); const [scene, setScene] = useState<GQL.SceneDataFragment | undefined>();
const { data, error, loading } = useFindScene(id); const { data, error, loading } = useFindScene(id);
const {
data: sceneStreams,
error: streamableError,
loading: streamableLoading,
} = useSceneStreams(id);
const [oLoading, setOLoading] = useState(false); const [oLoading, setOLoading] = useState(false);
const [incrementO] = useSceneIncrementO(scene?.id ?? "0"); const [incrementO] = useSceneIncrementO(scene?.id ?? "0");
const [decrementO] = useSceneDecrementO(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 />; return <LoadingIndicator />;
} }
if (error) return <div>{error.message}</div>; if (error) return <div>{error.message}</div>;
if (streamableError) return <div>{streamableError.message}</div>;
return ( return (
<div className="row"> <div className="row">
@@ -340,6 +347,7 @@ export const Scene: React.FC = () => {
scene={scene} scene={scene}
timestamp={timestamp} timestamp={timestamp}
autoplay={autoplay} autoplay={autoplay}
sceneStreams={sceneStreams?.sceneStreams ?? []}
/> />
</div> </div>
</div> </div>

View File

@@ -26,8 +26,6 @@ export const SettingsConfigurationPanel: React.FC = () => {
const [maxStreamingTranscodeSize, setMaxStreamingTranscodeSize] = useState< const [maxStreamingTranscodeSize, setMaxStreamingTranscodeSize] = useState<
GQL.StreamingResolutionEnum | undefined GQL.StreamingResolutionEnum | undefined
>(undefined); >(undefined);
const [forceMkv, setForceMkv] = useState<boolean>(false);
const [forceHevc, setForceHevc] = useState<boolean>(false);
const [username, setUsername] = useState<string | undefined>(undefined); const [username, setUsername] = useState<string | undefined>(undefined);
const [password, setPassword] = useState<string | undefined>(undefined); const [password, setPassword] = useState<string | undefined>(undefined);
const [maxSessionAge, setMaxSessionAge] = useState<number>(0); const [maxSessionAge, setMaxSessionAge] = useState<number>(0);
@@ -50,8 +48,6 @@ export const SettingsConfigurationPanel: React.FC = () => {
previewPreset: (previewPreset as GQL.PreviewPreset) ?? undefined, previewPreset: (previewPreset as GQL.PreviewPreset) ?? undefined,
maxTranscodeSize, maxTranscodeSize,
maxStreamingTranscodeSize, maxStreamingTranscodeSize,
forceMkv,
forceHevc,
username, username,
password, password,
maxSessionAge, maxSessionAge,
@@ -77,8 +73,6 @@ export const SettingsConfigurationPanel: React.FC = () => {
setMaxStreamingTranscodeSize( setMaxStreamingTranscodeSize(
conf.general.maxStreamingTranscodeSize ?? undefined conf.general.maxStreamingTranscodeSize ?? undefined
); );
setForceMkv(conf.general.forceMkv);
setForceHevc(conf.general.forceHevc);
setUsername(conf.general.username); setUsername(conf.general.username);
setPassword(conf.general.password); setPassword(conf.general.password);
setMaxSessionAge(conf.general.maxSessionAge); setMaxSessionAge(conf.general.maxSessionAge);
@@ -343,30 +337,6 @@ export const SettingsConfigurationPanel: React.FC = () => {
Maximum size for transcoded streams Maximum size for transcoded streams
</Form.Text> </Form.Text>
</Form.Group> </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> </Form.Group>
<hr /> <hr />

View File

@@ -116,6 +116,9 @@ export const useFindGallery = (id: string) =>
GQL.useFindGalleryQuery({ variables: { id } }); GQL.useFindGalleryQuery({ variables: { id } });
export const useFindScene = (id: string) => export const useFindScene = (id: string) =>
GQL.useFindSceneQuery({ variables: { id } }); GQL.useFindSceneQuery({ variables: { id } });
export const useSceneStreams = (id: string) =>
GQL.useSceneStreamsQuery({ variables: { id } });
export const useFindPerformer = (id: string) => { export const useFindPerformer = (id: string) => {
const skip = id === "new"; const skip = id === "new";
return GQL.useFindPerformerQuery({ variables: { id }, skip }); return GQL.useFindPerformerQuery({ variables: { id }, skip });

View File

@@ -2,6 +2,8 @@ const playerID = "main-jwplayer";
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const getPlayer = () => (window as any).jwplayer(playerID); const getPlayer = () => (window as any).jwplayer(playerID);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default { export default {
playerID, playerID,
getPlayer, getPlayer,