mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 12:24:38 +03:00
Transcode stream refactor (#609)
* Remove forceMkv and forceHEVC * Add HLS support and refactor * Add new streaming endpoints
This commit is contained in:
@@ -6,8 +6,6 @@ fragment ConfigGeneralData on ConfigGeneralResult {
|
|||||||
previewPreset
|
previewPreset
|
||||||
maxTranscodeSize
|
maxTranscodeSize
|
||||||
maxStreamingTranscodeSize
|
maxStreamingTranscodeSize
|
||||||
forceMkv
|
|
||||||
forceHevc
|
|
||||||
username
|
username
|
||||||
password
|
password
|
||||||
maxSessionAge
|
maxSessionAge
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ fragment SceneData on Scene {
|
|||||||
...SceneMarkerData
|
...SceneMarkerData
|
||||||
}
|
}
|
||||||
|
|
||||||
is_streamable
|
|
||||||
|
|
||||||
gallery {
|
gallery {
|
||||||
...GalleryData
|
...GalleryData
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,4 +53,12 @@ query ParseSceneFilenames($filter: FindFilterType!, $config: SceneParserInput!)
|
|||||||
tag_ids
|
tag_ids
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
query SceneStreams($id: ID!) {
|
||||||
|
sceneStreams(id: $id) {
|
||||||
|
url
|
||||||
|
mime_type
|
||||||
|
label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"""
|
||||||
|
|||||||
@@ -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"""
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -138,4 +137,10 @@ type SceneParserResult {
|
|||||||
type SceneParserResultType {
|
type SceneParserResultType {
|
||||||
count: Int!
|
count: Int!
|
||||||
results: [SceneParserResult!]!
|
results: [SceneParserResult!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SceneStreamEndpoint {
|
||||||
|
url: String!
|
||||||
|
mime_type: String
|
||||||
|
label: String
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
31
pkg/api/resolver_query_scene.go
Normal file
31
pkg/api/resolver_query_scene.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/stashapp/stash/pkg/api/urlbuilders"
|
||||||
|
"github.com/stashapp/stash/pkg/manager"
|
||||||
|
"github.com/stashapp/stash/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *queryResolver) SceneStreams(ctx context.Context, id *string) ([]*models.SceneStreamEndpoint, error) {
|
||||||
|
// find the scene
|
||||||
|
qb := models.NewSceneQueryBuilder()
|
||||||
|
idInt, _ := strconv.Atoi(*id)
|
||||||
|
scene, err := qb.Find(idInt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if scene == nil {
|
||||||
|
return nil, errors.New("nil scene")
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
|
||||||
|
builder := urlbuilders.NewSceneURLBuilder(baseURL, scene.ID)
|
||||||
|
|
||||||
|
return manager.GetSceneStreamPaths(scene, builder.GetStreamURL())
|
||||||
|
}
|
||||||
@@ -2,9 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
42
pkg/ffmpeg/hls.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package ffmpeg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const hlsSegmentLength = 10.0
|
||||||
|
|
||||||
|
func WriteHLSPlaylist(probeResult VideoFile, baseUrl string, w io.Writer) {
|
||||||
|
fmt.Fprint(w, "#EXTM3U\n")
|
||||||
|
fmt.Fprint(w, "#EXT-X-VERSION:3\n")
|
||||||
|
fmt.Fprint(w, "#EXT-X-MEDIA-SEQUENCE:0\n")
|
||||||
|
fmt.Fprint(w, "#EXT-X-ALLOW-CACHE:YES\n")
|
||||||
|
fmt.Fprintf(w, "#EXT-X-TARGETDURATION:%d\n", int(hlsSegmentLength))
|
||||||
|
fmt.Fprint(w, "#EXT-X-PLAYLIST-TYPE:VOD\n")
|
||||||
|
|
||||||
|
duration := probeResult.Duration
|
||||||
|
|
||||||
|
leftover := duration
|
||||||
|
upTo := 0.0
|
||||||
|
|
||||||
|
tsURL := baseUrl
|
||||||
|
i := strings.LastIndex(baseUrl, ".m3u8")
|
||||||
|
tsURL = baseUrl[0:i] + ".ts"
|
||||||
|
|
||||||
|
for leftover > 0 {
|
||||||
|
thisLength := hlsSegmentLength
|
||||||
|
if leftover < thisLength {
|
||||||
|
thisLength = leftover
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(w, "#EXTINF: %f,\n", thisLength)
|
||||||
|
fmt.Fprintf(w, "%s?start=%f\n", tsURL, upTo)
|
||||||
|
|
||||||
|
leftover -= thisLength
|
||||||
|
upTo += thisLength
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprint(w, "#EXT-X-ENDLIST\n")
|
||||||
|
}
|
||||||
245
pkg/ffmpeg/stream.go
Normal file
245
pkg/ffmpeg/stream.go
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
package ffmpeg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/stashapp/stash/pkg/logger"
|
||||||
|
"github.com/stashapp/stash/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const CopyStreamCodec = "copy"
|
||||||
|
|
||||||
|
type Stream struct {
|
||||||
|
Stdout io.ReadCloser
|
||||||
|
Process *os.Process
|
||||||
|
options TranscodeStreamOptions
|
||||||
|
mimeType string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) Serve(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", s.mimeType)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
logger.Infof("[stream] transcoding video file to %s", s.mimeType)
|
||||||
|
|
||||||
|
// handle if client closes the connection
|
||||||
|
notify := r.Context().Done()
|
||||||
|
go func() {
|
||||||
|
<-notify
|
||||||
|
s.Process.Kill()
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, err := io.Copy(w, s.Stdout)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("[stream] error serving transcoded video file: %s", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Codec struct {
|
||||||
|
Codec string
|
||||||
|
format string
|
||||||
|
MimeType string
|
||||||
|
extraArgs []string
|
||||||
|
hls bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var CodecHLS = Codec{
|
||||||
|
Codec: "libx264",
|
||||||
|
format: "mpegts",
|
||||||
|
MimeType: MimeMpegts,
|
||||||
|
extraArgs: []string{
|
||||||
|
"-acodec", "aac",
|
||||||
|
"-pix_fmt", "yuv420p",
|
||||||
|
"-preset", "veryfast",
|
||||||
|
"-crf", "25",
|
||||||
|
},
|
||||||
|
hls: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var CodecH264 = Codec{
|
||||||
|
Codec: "libx264",
|
||||||
|
format: "mp4",
|
||||||
|
MimeType: MimeMp4,
|
||||||
|
extraArgs: []string{
|
||||||
|
"-movflags", "frag_keyframe",
|
||||||
|
"-pix_fmt", "yuv420p",
|
||||||
|
"-preset", "veryfast",
|
||||||
|
"-crf", "25",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var CodecVP9 = Codec{
|
||||||
|
Codec: "libvpx-vp9",
|
||||||
|
format: "webm",
|
||||||
|
MimeType: MimeWebm,
|
||||||
|
extraArgs: []string{
|
||||||
|
"-deadline", "realtime",
|
||||||
|
"-cpu-used", "5",
|
||||||
|
"-row-mt", "1",
|
||||||
|
"-crf", "30",
|
||||||
|
"-b:v", "0",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var CodecVP8 = Codec{
|
||||||
|
Codec: "libvpx",
|
||||||
|
format: "webm",
|
||||||
|
MimeType: MimeWebm,
|
||||||
|
extraArgs: []string{
|
||||||
|
"-deadline", "realtime",
|
||||||
|
"-cpu-used", "5",
|
||||||
|
"-crf", "12",
|
||||||
|
"-b:v", "3M",
|
||||||
|
"-pix_fmt", "yuv420p",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var CodecHEVC = Codec{
|
||||||
|
Codec: "libx265",
|
||||||
|
format: "mp4",
|
||||||
|
MimeType: MimeMp4,
|
||||||
|
extraArgs: []string{
|
||||||
|
"-movflags", "frag_keyframe",
|
||||||
|
"-preset", "veryfast",
|
||||||
|
"-crf", "30",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// it is very common in MKVs to have just the audio codec unsupported
|
||||||
|
// copy the video stream, transcode the audio and serve as Matroska
|
||||||
|
var CodecMKVAudio = Codec{
|
||||||
|
Codec: CopyStreamCodec,
|
||||||
|
format: "matroska",
|
||||||
|
MimeType: MimeMkv,
|
||||||
|
extraArgs: []string{
|
||||||
|
"-c:a", "libopus",
|
||||||
|
"-b:a", "96k",
|
||||||
|
"-vbr", "on",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type TranscodeStreamOptions struct {
|
||||||
|
ProbeResult VideoFile
|
||||||
|
Codec Codec
|
||||||
|
StartTime string
|
||||||
|
MaxTranscodeSize models.StreamingResolutionEnum
|
||||||
|
// transcode the video, remove the audio
|
||||||
|
// in some videos where the audio codec is not supported by ffmpeg
|
||||||
|
// ffmpeg fails if you try to transcode the audio
|
||||||
|
VideoOnly bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTranscodeStreamOptions(probeResult VideoFile, videoCodec Codec, audioCodec AudioCodec) TranscodeStreamOptions {
|
||||||
|
options := TranscodeStreamOptions{
|
||||||
|
ProbeResult: probeResult,
|
||||||
|
Codec: videoCodec,
|
||||||
|
}
|
||||||
|
|
||||||
|
if audioCodec == MissingUnsupported {
|
||||||
|
// ffmpeg fails if it trys to transcode a non supported audio codec
|
||||||
|
options.VideoOnly = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o TranscodeStreamOptions) getStreamArgs() []string {
|
||||||
|
args := []string{
|
||||||
|
"-hide_banner",
|
||||||
|
"-v", "error",
|
||||||
|
}
|
||||||
|
|
||||||
|
if o.StartTime != "" {
|
||||||
|
args = append(args, "-ss", o.StartTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
if o.Codec.hls {
|
||||||
|
// we only serve a fixed segment length
|
||||||
|
args = append(args, "-t", strconv.Itoa(int(hlsSegmentLength)))
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args,
|
||||||
|
"-i", o.ProbeResult.Path,
|
||||||
|
)
|
||||||
|
|
||||||
|
if o.VideoOnly {
|
||||||
|
args = append(args, "-an")
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args,
|
||||||
|
"-c:v", o.Codec.Codec,
|
||||||
|
)
|
||||||
|
|
||||||
|
// don't set scale when copying video stream
|
||||||
|
if o.Codec.Codec != CopyStreamCodec {
|
||||||
|
scale := calculateTranscodeScale(o.ProbeResult, o.MaxTranscodeSize)
|
||||||
|
args = append(args,
|
||||||
|
"-vf", "scale="+scale,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(o.Codec.extraArgs) > 0 {
|
||||||
|
args = append(args, o.Codec.extraArgs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args,
|
||||||
|
// this is needed for 5-channel ac3 files
|
||||||
|
"-ac", "2",
|
||||||
|
"-f", o.Codec.format,
|
||||||
|
"pipe:",
|
||||||
|
)
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Encoder) GetTranscodeStream(options TranscodeStreamOptions) (*Stream, error) {
|
||||||
|
return e.stream(options.ProbeResult, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Encoder) stream(probeResult VideoFile, options TranscodeStreamOptions) (*Stream, error) {
|
||||||
|
args := options.getStreamArgs()
|
||||||
|
cmd := exec.Command(e.Path, args...)
|
||||||
|
logger.Debugf("Streaming via: %s", strings.Join(cmd.Args, " "))
|
||||||
|
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if nil != err {
|
||||||
|
logger.Error("FFMPEG stdout not available: " + err.Error())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if nil != err {
|
||||||
|
logger.Error("FFMPEG stderr not available: " + err.Error())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = cmd.Start(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
registerRunningEncoder(probeResult.Path, cmd.Process)
|
||||||
|
go waitAndDeregister(probeResult.Path, cmd)
|
||||||
|
|
||||||
|
// stderr must be consumed or the process deadlocks
|
||||||
|
go func() {
|
||||||
|
stderrData, _ := ioutil.ReadAll(stderr)
|
||||||
|
stderrString := string(stderrData)
|
||||||
|
if len(stderrString) > 0 {
|
||||||
|
logger.Debugf("[stream] ffmpeg stderr: %s", stderrString)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
ret := &Stream{
|
||||||
|
Stdout: stdout,
|
||||||
|
Process: cmd.Process,
|
||||||
|
options: options,
|
||||||
|
mimeType: options.Codec.MimeType,
|
||||||
|
}
|
||||||
|
return ret, nil
|
||||||
|
}
|
||||||
@@ -62,10 +62,6 @@ const ShowStudioAsText = "show_studio_as_text"
|
|||||||
const CSSEnabled = "cssEnabled"
|
const 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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
package manager
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/stashapp/stash/pkg/ffmpeg"
|
|
||||||
"github.com/stashapp/stash/pkg/logger"
|
|
||||||
"github.com/stashapp/stash/pkg/models"
|
|
||||||
"github.com/stashapp/stash/pkg/utils"
|
|
||||||
)
|
|
||||||
|
|
||||||
func IsStreamable(scene *models.Scene) (bool, error) {
|
|
||||||
if scene == nil {
|
|
||||||
return false, fmt.Errorf("nil scene")
|
|
||||||
}
|
|
||||||
var container ffmpeg.Container
|
|
||||||
if scene.Format.Valid {
|
|
||||||
container = ffmpeg.Container(scene.Format.String)
|
|
||||||
} else { // container isn't in the DB
|
|
||||||
// shouldn't happen, fallback to ffprobe reading from file
|
|
||||||
tmpVideoFile, err := ffmpeg.NewVideoFile(instance.FFProbePath, scene.Path)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("error reading video file: %s", err.Error())
|
|
||||||
}
|
|
||||||
container = ffmpeg.MatchContainer(tmpVideoFile.Container, scene.Path)
|
|
||||||
}
|
|
||||||
|
|
||||||
videoCodec := scene.VideoCodec.String
|
|
||||||
audioCodec := ffmpeg.MissingUnsupported
|
|
||||||
if scene.AudioCodec.Valid {
|
|
||||||
audioCodec = ffmpeg.AudioCodec(scene.AudioCodec.String)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ffmpeg.IsValidCodec(videoCodec) && ffmpeg.IsValidCombo(videoCodec, container) && ffmpeg.IsValidAudioForContainer(audioCodec, container) {
|
|
||||||
logger.Debugf("File is streamable %s, %s, %s\n", videoCodec, audioCodec, container)
|
|
||||||
return true, nil
|
|
||||||
} else {
|
|
||||||
hasTranscode, _ := HasTranscode(scene)
|
|
||||||
logger.Debugf("File is not streamable , transcode is needed %s, %s, %s\n", videoCodec, audioCodec, container)
|
|
||||||
return hasTranscode, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func HasTranscode(scene *models.Scene) (bool, error) {
|
|
||||||
if scene == nil {
|
|
||||||
return false, fmt.Errorf("nil scene")
|
|
||||||
}
|
|
||||||
transcodePath := instance.Paths.Scene.GetTranscodePath(scene.Checksum)
|
|
||||||
return utils.FileExists(transcodePath)
|
|
||||||
}
|
|
||||||
59
pkg/utils/byterange.go
Normal file
59
pkg/utils/byterange.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ByteRange struct {
|
||||||
|
Start int64
|
||||||
|
End *int64
|
||||||
|
RawString string
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateByteRange(s string) ByteRange {
|
||||||
|
// strip bytes=
|
||||||
|
r := strings.TrimPrefix(s, "bytes=")
|
||||||
|
e := strings.Split(r, "-")
|
||||||
|
|
||||||
|
ret := ByteRange{
|
||||||
|
RawString: s,
|
||||||
|
}
|
||||||
|
if len(e) > 0 {
|
||||||
|
ret.Start, _ = strconv.ParseInt(e[0], 10, 64)
|
||||||
|
}
|
||||||
|
if len(e) > 1 && e[1] != "" {
|
||||||
|
end, _ := strconv.ParseInt(e[1], 10, 64)
|
||||||
|
ret.End = &end
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r ByteRange) getBytesToRead() int64 {
|
||||||
|
if r.End == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return *r.End - r.Start + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r ByteRange) ToHeaderValue(fileLength int64) string {
|
||||||
|
if r.End == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
end := *r.End
|
||||||
|
return "bytes " + strconv.FormatInt(r.Start, 10) + "-" + strconv.FormatInt(end, 10) + "/" + strconv.FormatInt(fileLength, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r ByteRange) Apply(bytes []byte) []byte {
|
||||||
|
if r.End == nil {
|
||||||
|
return bytes[r.Start:]
|
||||||
|
}
|
||||||
|
|
||||||
|
end := *r.End + 1
|
||||||
|
if int(end) > len(bytes) {
|
||||||
|
end = int64(len(bytes))
|
||||||
|
}
|
||||||
|
return bytes[r.Start:end]
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/*!
|
/*!
|
||||||
JW Player version 8.11.5
|
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
@@ -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
@@ -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
|
||||||
|
|
||||||
* * *
|
* * *
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
* * *
|
* * *
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
* * *
|
* * *
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
* * *
|
* * *
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user