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
maxTranscodeSize
maxStreamingTranscodeSize
forceMkv
forceHevc
username
password
maxSessionAge

View File

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

View File

@@ -53,4 +53,12 @@ query ParseSceneFilenames($filter: FindFilterType!, $config: SceneParserInput!)
tag_ids
}
}
}
}
query SceneStreams($id: ID!) {
sceneStreams(id: $id) {
url
mime_type
label
}
}

View File

@@ -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"""

View File

@@ -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"""

View File

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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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(),

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 (
"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) {

View File

@@ -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 {

View File

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

View File

@@ -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)
}

View File

@@ -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
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 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 {

View File

@@ -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)
}

View File

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

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
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

View File

@@ -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

View File

@@ -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
* * *

View File

@@ -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

View File

@@ -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
* * *

View File

@@ -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
* * *

View File

@@ -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

View File

@@ -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
* * *

View File

@@ -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.

View File

@@ -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: {

View File

@@ -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>

View File

@@ -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 />

View File

@@ -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 });

View File

@@ -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,