Overhaul HLS streaming (#3274)

* Overhaul HLS streaming
* Fix streaming transcode ffmpeg zombie processes
* Add changelog and release notes
* Documentation
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
DingDongSoLong4
2023-02-24 05:55:46 +02:00
committed by GitHub
parent f767635080
commit 05669f5503
16 changed files with 1219 additions and 580 deletions

View File

@@ -1,51 +0,0 @@
package api
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) 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

@@ -123,6 +123,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
c.Set(config.Metadata, input.MetadataPath) c.Set(config.Metadata, input.MetadataPath)
} }
refreshStreamManager := false
existingCachePath := c.GetCachePath() existingCachePath := c.GetCachePath()
if input.CachePath != nil && existingCachePath != *input.CachePath { if input.CachePath != nil && existingCachePath != *input.CachePath {
if err := validateDir(config.Cache, *input.CachePath, true); err != nil { if err := validateDir(config.Cache, *input.CachePath, true); err != nil {
@@ -130,6 +131,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
} }
c.Set(config.Cache, input.CachePath) c.Set(config.Cache, input.CachePath)
refreshStreamManager = true
} }
if input.VideoFileNamingAlgorithm != nil && *input.VideoFileNamingAlgorithm != c.GetVideoFileNamingAlgorithm() { if input.VideoFileNamingAlgorithm != nil && *input.VideoFileNamingAlgorithm != c.GetVideoFileNamingAlgorithm() {
@@ -328,6 +330,9 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
if refreshScraperCache { if refreshScraperCache {
manager.GetInstance().RefreshScraperCache() manager.GetInstance().RefreshScraperCache()
} }
if refreshStreamManager {
manager.GetInstance().RefreshStreamManager()
}
return makeConfigGeneralResult(), nil return makeConfigGeneralResult(), nil
} }

View File

@@ -56,11 +56,11 @@ func (rs sceneRoutes) Routes() chi.Router {
// streaming endpoints // streaming endpoints
r.Get("/stream", rs.StreamDirect) 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("/stream.mp4", rs.StreamMp4)
r.Get("/stream.webm", rs.StreamWebM)
r.Get("/stream.mkv", rs.StreamMKV)
r.Get("/stream.m3u8", rs.StreamHLS)
r.Get("/stream.m3u8/{segment}.ts", rs.StreamHLSSegment)
r.Get("/screenshot", rs.Screenshot) r.Get("/screenshot", rs.Screenshot)
r.Get("/preview", rs.Preview) r.Get("/preview", rs.Preview)
@@ -85,11 +85,25 @@ func (rs sceneRoutes) Routes() chi.Router {
func (rs sceneRoutes) StreamDirect(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) StreamDirect(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene) scene := r.Context().Value(sceneKey).(*models.Scene)
ss := manager.SceneServer{ fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm()
TxnManager: rs.txnManager, hash := scene.GetHash(fileNamingAlgo)
SceneCoverGetter: rs.sceneFinder,
filepath := manager.GetInstance().Paths.Scene.GetStreamPath(scene.Path, hash)
streamRequestCtx := ffmpeg.NewStreamRequestContext(w, r)
// #2579 - hijacking and closing the connection here causes video playback to fail in Safari
// We trust that the request context will be closed, so we don't need to call Cancel on the
// returned context here.
_ = manager.GetInstance().ReadLockManager.ReadLock(streamRequestCtx, filepath)
http.ServeFile(w, r, filepath)
} }
ss.StreamSceneDirect(scene, w, r)
func (rs sceneRoutes) StreamMp4(w http.ResponseWriter, r *http.Request) {
rs.streamTranscode(w, r, ffmpeg.StreamTypeMP4)
}
func (rs sceneRoutes) StreamWebM(w http.ResponseWriter, r *http.Request) {
rs.streamTranscode(w, r, ffmpeg.StreamTypeWEBM)
} }
func (rs sceneRoutes) StreamMKV(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) StreamMKV(w http.ResponseWriter, r *http.Request) {
@@ -114,122 +128,107 @@ func (rs sceneRoutes) StreamMKV(w http.ResponseWriter, r *http.Request) {
return return
} }
rs.streamTranscode(w, r, ffmpeg.StreamFormatMKVAudio) rs.streamTranscode(w, r, ffmpeg.StreamTypeMKV)
} }
func (rs sceneRoutes) StreamWebM(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) streamTranscode(w http.ResponseWriter, r *http.Request, streamType ffmpeg.StreamFormat) {
rs.streamTranscode(w, r, ffmpeg.StreamFormatVP9)
}
func (rs sceneRoutes) StreamMp4(w http.ResponseWriter, r *http.Request) {
rs.streamTranscode(w, r, ffmpeg.StreamFormatH264)
}
func (rs sceneRoutes) StreamHLS(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene) scene := r.Context().Value(sceneKey).(*models.Scene)
pf := scene.Files.Primary() streamManager := manager.GetInstance().StreamManager
if pf == nil { if streamManager == nil {
http.Error(w, "Live transcoding disabled", http.StatusServiceUnavailable)
return return
} }
logger.Debug("Returning HLS playlist")
// getting the playlist manifest only
w.Header().Set("Content-Type", ffmpeg.MimeHLS)
var str strings.Builder
ffmpeg.WriteHLSPlaylist(pf.Duration, r.URL.String(), &str)
requestByteRange := 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)
if n, err := w.Write(ret); err != nil {
logger.Warnf("[stream] error writing stream (wrote %v bytes): %v", n, err)
}
}
func (rs sceneRoutes) StreamTS(w http.ResponseWriter, r *http.Request) {
rs.streamTranscode(w, r, ffmpeg.StreamFormatHLS)
}
func (rs sceneRoutes) streamTranscode(w http.ResponseWriter, r *http.Request, streamFormat ffmpeg.StreamFormat) {
scene := r.Context().Value(sceneKey).(*models.Scene)
f := scene.Files.Primary() f := scene.Files.Primary()
if f == nil { if f == nil {
return return
} }
logger.Debugf("Streaming as %s", streamFormat.MimeType)
// start stream based on query param, if provided
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
logger.Warnf("[stream] error parsing query form: %v", err) logger.Warnf("[transcode] error parsing query form: %v", err)
} }
startTime := r.Form.Get("start") startTime := r.Form.Get("start")
ss, _ := strconv.ParseFloat(startTime, 64) ss, _ := strconv.ParseFloat(startTime, 64)
requestedSize := r.Form.Get("resolution") resolution := r.Form.Get("resolution")
audioCodec := ffmpeg.MissingUnsupported
if f.AudioCodec != "" {
audioCodec = ffmpeg.ProbeAudioCodec(f.AudioCodec)
}
width := f.Width
height := f.Height
config := config.GetInstance()
options := ffmpeg.TranscodeStreamOptions{
Input: f.Path,
Codec: streamFormat,
VideoOnly: audioCodec == ffmpeg.MissingUnsupported,
VideoWidth: width,
VideoHeight: height,
options := ffmpeg.TranscodeOptions{
StreamType: streamType,
VideoFile: f,
Resolution: resolution,
StartTime: ss, StartTime: ss,
MaxTranscodeSize: config.GetMaxStreamingTranscodeSize().GetMaxResolution(),
ExtraInputArgs: config.GetLiveTranscodeInputArgs(),
ExtraOutputArgs: config.GetLiveTranscodeOutputArgs(),
} }
if requestedSize != "" { logger.Debugf("[transcode] streaming scene %d as %s", scene.ID, streamType.MimeType)
options.MaxTranscodeSize = models.StreamingResolutionEnum(requestedSize).GetMaxResolution() streamManager.ServeTranscode(w, r, options)
} }
encoder := manager.GetInstance().FFMPEG func (rs sceneRoutes) StreamHLS(w http.ResponseWriter, r *http.Request) {
rs.streamManifest(w, r, ffmpeg.StreamTypeHLS, "HLS")
lm := manager.GetInstance().ReadLockManager
streamRequestCtx := manager.NewStreamRequestContext(w, r)
lockCtx := lm.ReadLock(streamRequestCtx, f.Path)
// hijacking and closing the connection here causes video playback to hang in Chrome
// due to ERR_INCOMPLETE_CHUNKED_ENCODING
// We trust that the request context will be closed, so we don't need to call Cancel on the returned context here.
stream, err := encoder.GetTranscodeStream(lockCtx, options)
if err != nil {
logger.Errorf("[stream] error transcoding video file: %v", err)
w.WriteHeader(http.StatusBadRequest)
if _, err := w.Write([]byte(err.Error())); err != nil {
logger.Warnf("[stream] error writing response: %v", err)
} }
func (rs sceneRoutes) streamManifest(w http.ResponseWriter, r *http.Request, streamType *ffmpeg.StreamType, logName string) {
scene := r.Context().Value(sceneKey).(*models.Scene)
streamManager := manager.GetInstance().StreamManager
if streamManager == nil {
http.Error(w, "Live transcoding disabled", http.StatusServiceUnavailable)
return return
} }
lockCtx.AttachCommand(stream.Cmd) f := scene.Files.Primary()
if f == nil {
return
}
stream.Serve(w, r) if err := r.ParseForm(); err != nil {
w.(http.Flusher).Flush() logger.Warnf("[transcode] error parsing query form: %v", err)
}
resolution := r.Form.Get("resolution")
logger.Debugf("[transcode] returning %s manifest for scene %d", logName, scene.ID)
streamManager.ServeManifest(w, r, streamType, f, resolution)
}
func (rs sceneRoutes) StreamHLSSegment(w http.ResponseWriter, r *http.Request) {
rs.streamSegment(w, r, ffmpeg.StreamTypeHLS)
}
func (rs sceneRoutes) streamSegment(w http.ResponseWriter, r *http.Request, streamType *ffmpeg.StreamType) {
scene := r.Context().Value(sceneKey).(*models.Scene)
streamManager := manager.GetInstance().StreamManager
if streamManager == nil {
http.Error(w, "Live transcoding disabled", http.StatusServiceUnavailable)
return
}
f := scene.Files.Primary()
if f == nil {
return
}
if err := r.ParseForm(); err != nil {
logger.Warnf("[transcode] error parsing query form: %v", err)
}
fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm()
hash := scene.GetHash(fileNamingAlgo)
segment := chi.URLParam(r, "segment")
resolution := r.Form.Get("resolution")
options := ffmpeg.StreamOptions{
StreamType: streamType,
VideoFile: f,
Resolution: resolution,
Hash: hash,
Segment: segment,
}
streamManager.ServeSegment(w, r, options)
} }
func (rs sceneRoutes) Screenshot(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) Screenshot(w http.ResponseWriter, r *http.Request) {

View File

@@ -503,7 +503,7 @@ func SecurityHeadersMiddleware(next http.Handler) http.Handler {
} }
connectableOrigins += "; " connectableOrigins += "; "
cspDirectives := "default-src data: 'self' 'unsafe-inline';" + connectableOrigins + "img-src data: *; script-src 'self' https://cdn.jsdelivr.net 'unsafe-inline' 'unsafe-eval'; style-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; style-src-elem 'self' https://cdn.jsdelivr.net 'unsafe-inline'; media-src 'self' blob:; child-src 'none'; object-src 'none'; form-action 'self'" cspDirectives := "default-src data: 'self' 'unsafe-inline';" + connectableOrigins + "img-src data: *; script-src 'self' https://cdn.jsdelivr.net 'unsafe-inline' 'unsafe-eval'; style-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; style-src-elem 'self' https://cdn.jsdelivr.net 'unsafe-inline'; media-src 'self' blob:; child-src 'none'; worker-src blob:; object-src 'none'; form-action 'self'"
w.Header().Set("Referrer-Policy", "same-origin") w.Header().Set("Referrer-Policy", "same-origin")
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Type-Options", "nosniff")

View File

@@ -110,6 +110,7 @@ type Manager struct {
FFMPEG ffmpeg.FFMpeg FFMPEG ffmpeg.FFMpeg
FFProbe ffmpeg.FFProbe FFProbe ffmpeg.FFProbe
StreamManager *ffmpeg.StreamManager
ReadLockManager *fsutil.ReadLockManager ReadLockManager *fsutil.ReadLockManager
@@ -430,6 +431,7 @@ func initFFMPEG(ctx context.Context) error {
instance.FFMPEG = ffmpeg.FFMpeg(ffmpegPath) instance.FFMPEG = ffmpeg.FFMpeg(ffmpegPath)
instance.FFProbe = ffmpeg.FFProbe(ffprobePath) instance.FFProbe = ffmpeg.FFProbe(ffprobePath)
instance.RefreshStreamManager()
} }
return nil return nil
@@ -564,6 +566,19 @@ func (s *Manager) RefreshScraperCache() {
s.ScraperCache = s.initScraperCache() s.ScraperCache = s.initScraperCache()
} }
// RefreshStreamManager refreshes the stream manager. Call this when cache directory
// changes.
func (s *Manager) RefreshStreamManager() {
// shutdown existing manager if needed
if s.StreamManager != nil {
s.StreamManager.Shutdown()
s.StreamManager = nil
}
cacheDir := s.Config.GetCachePath()
s.StreamManager = ffmpeg.NewStreamManager(cacheDir, s.FFMPEG, s.FFProbe, s.Config, s.ReadLockManager)
}
func setSetupDefaults(input *SetupInput) { func setSetupDefaults(input *SetupInput) {
if input.ConfigLocation == "" { if input.ConfigLocation == "" {
input.ConfigLocation = filepath.Join(fsutil.GetHomeDirectory(), ".stash", "config.yml") input.ConfigLocation = filepath.Join(fsutil.GetHomeDirectory(), ".stash", "config.yml")
@@ -735,6 +750,11 @@ func (s *Manager) Shutdown(code int) {
// stop any profiling at exit // stop any profiling at exit
pprof.StopCPUProfile() pprof.StopCPUProfile()
if s.StreamManager != nil {
s.StreamManager.Shutdown()
s.StreamManager = nil
}
// TODO: Each part of the manager needs to gracefully stop at some point // TODO: Each part of the manager needs to gracefully stop at some point
// for now, we just close the database. // for now, we just close the database.
err := s.Database.Close() err := s.Database.Close()

View File

@@ -5,10 +5,10 @@ import (
"errors" "errors"
"io" "io"
"net/http" "net/http"
"time"
"github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/internal/static" "github.com/stashapp/stash/internal/static"
"github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
@@ -16,58 +16,6 @@ import (
"github.com/stashapp/stash/pkg/utils" "github.com/stashapp/stash/pkg/utils"
) )
type StreamRequestContext struct {
context.Context
ResponseWriter http.ResponseWriter
}
func NewStreamRequestContext(w http.ResponseWriter, r *http.Request) *StreamRequestContext {
return &StreamRequestContext{
Context: r.Context(),
ResponseWriter: w,
}
}
func (c *StreamRequestContext) Cancel() {
hj, ok := (c.ResponseWriter).(http.Hijacker)
if !ok {
return
}
// hijack and close the connection
conn, bw, _ := hj.Hijack()
if conn != nil {
if bw != nil {
// notify end of stream
_, err := bw.WriteString("0\r\n")
if err != nil {
logger.Warnf("unable to write end of stream: %v", err)
}
_, err = bw.WriteString("\r\n")
if err != nil {
logger.Warnf("unable to write end of stream: %v", err)
}
// flush the buffer, but don't wait indefinitely
timeout := make(chan struct{}, 1)
go func() {
_ = bw.Flush()
close(timeout)
}()
const waitTime = time.Second
select {
case <-timeout:
case <-time.After(waitTime):
logger.Warnf("unable to flush buffer - closing connection")
}
}
conn.Close()
}
}
func KillRunningStreams(scene *models.Scene, fileNamingAlgo models.HashAlgorithm) { func KillRunningStreams(scene *models.Scene, fileNamingAlgo models.HashAlgorithm) {
instance.ReadLockManager.Cancel(scene.Path) instance.ReadLockManager.Cancel(scene.Path)
@@ -94,7 +42,7 @@ func (s *SceneServer) StreamSceneDirect(scene *models.Scene, w http.ResponseWrit
fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm() fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm()
filepath := GetInstance().Paths.Scene.GetStreamPath(scene.Path, scene.GetHash(fileNamingAlgo)) filepath := GetInstance().Paths.Scene.GetStreamPath(scene.Path, scene.GetHash(fileNamingAlgo))
streamRequestCtx := NewStreamRequestContext(w, r) streamRequestCtx := ffmpeg.NewStreamRequestContext(w, r)
// #2579 - hijacking and closing the connection here causes video playback to fail in Safari // #2579 - hijacking and closing the connection here causes video playback to fail in Safari
// We trust that the request context will be closed, so we don't need to call Cancel on the // We trust that the request context will be closed, so we don't need to call Cancel on the

View File

@@ -11,6 +11,47 @@ import (
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
) )
type SceneStreamEndpoint struct {
URL string `json:"url"`
MimeType *string `json:"mime_type"`
Label *string `json:"label"`
}
type endpointType struct {
label string
mimeType string
extension string
}
var (
directEndpointType = endpointType{
label: "Direct stream",
mimeType: ffmpeg.MimeMp4Video,
extension: "",
}
mp4EndpointType = endpointType{
label: "MP4",
mimeType: ffmpeg.MimeMp4Video,
extension: ".mp4",
}
mkvEndpointType = endpointType{
label: "MKV",
// use mp4 mimetype to trick the client, since many clients won't try mkv
mimeType: ffmpeg.MimeMp4Video,
extension: ".mkv",
}
webmEndpointType = endpointType{
label: "WEBM",
mimeType: ffmpeg.MimeWebmVideo,
extension: ".webm",
}
hlsEndpointType = endpointType{
label: "HLS",
mimeType: ffmpeg.MimeHLS,
extension: ".m3u8",
}
)
func GetVideoFileContainer(file *file.VideoFile) (ffmpeg.Container, error) { func GetVideoFileContainer(file *file.VideoFile) (ffmpeg.Container, error) {
var container ffmpeg.Container var container ffmpeg.Container
format := file.Format format := file.Format
@@ -30,48 +71,6 @@ func GetVideoFileContainer(file *file.VideoFile) (ffmpeg.Container, error) {
return container, nil return container, nil
} }
func includeSceneStreamPath(f *file.VideoFile, streamingResolution models.StreamingResolutionEnum, maxStreamingTranscodeSize models.StreamingResolutionEnum) bool {
// convert StreamingResolutionEnum to ResolutionEnum so we can get the min
// resolution
convertedRes := models.ResolutionEnum(streamingResolution)
minResolution := convertedRes.GetMinResolution()
sceneResolution := f.GetMinResolution()
// don't include if scene resolution is smaller than the streamingResolution
if sceneResolution != 0 && sceneResolution < minResolution {
return false
}
// if we always allow everything, then return true
if maxStreamingTranscodeSize == models.StreamingResolutionEnumOriginal {
return true
}
// convert StreamingResolutionEnum to ResolutionEnum
maxStreamingResolution := models.ResolutionEnum(maxStreamingTranscodeSize)
return maxStreamingResolution.GetMinResolution() >= minResolution
}
type SceneStreamEndpoint struct {
URL string `json:"url"`
MimeType *string `json:"mime_type"`
Label *string `json:"label"`
}
func makeStreamEndpoint(streamURL *url.URL, streamingResolution models.StreamingResolutionEnum, mimeType, label string) *SceneStreamEndpoint {
urlCopy := *streamURL
v := urlCopy.Query()
v.Set("resolution", streamingResolution.String())
urlCopy.RawQuery = v.Encode()
return &SceneStreamEndpoint{
URL: urlCopy.String(),
MimeType: &mimeType,
Label: &label,
}
}
func GetSceneStreamPaths(scene *models.Scene, directStreamURL *url.URL, maxStreamingTranscodeSize models.StreamingResolutionEnum) ([]*SceneStreamEndpoint, error) { func GetSceneStreamPaths(scene *models.Scene, directStreamURL *url.URL, maxStreamingTranscodeSize models.StreamingResolutionEnum) ([]*SceneStreamEndpoint, error) {
if scene == nil { if scene == nil {
return nil, fmt.Errorf("nil scene") return nil, fmt.Errorf("nil scene")
@@ -82,13 +81,66 @@ func GetSceneStreamPaths(scene *models.Scene, directStreamURL *url.URL, maxStrea
return nil, nil return nil, nil
} }
var ret []*SceneStreamEndpoint // convert StreamingResolutionEnum to ResolutionEnum
mimeWebm := ffmpeg.MimeWebm maxStreamingResolution := models.ResolutionEnum(maxStreamingTranscodeSize)
mimeHLS := ffmpeg.MimeHLS sceneResolution := pf.GetMinResolution()
mimeMp4 := ffmpeg.MimeMp4 includeSceneStreamPath := func(streamingResolution models.StreamingResolutionEnum) bool {
var minResolution int
if streamingResolution == models.StreamingResolutionEnumOriginal {
minResolution = sceneResolution
} else {
// convert StreamingResolutionEnum to ResolutionEnum so we can get the min
// resolution
convertedRes := models.ResolutionEnum(streamingResolution)
minResolution = convertedRes.GetMinResolution()
labelWebm := "webm" // don't include if scene resolution is smaller than the streamingResolution
labelHLS := "HLS" if sceneResolution != 0 && sceneResolution < minResolution {
return false
}
}
// if we always allow everything, then return true
if maxStreamingTranscodeSize == models.StreamingResolutionEnumOriginal {
return true
}
return maxStreamingResolution.GetMinResolution() >= minResolution
}
makeStreamEndpoint := func(t endpointType, resolution models.StreamingResolutionEnum) *SceneStreamEndpoint {
url := *directStreamURL
url.Path += t.extension
label := t.label
if resolution != "" {
v := url.Query()
v.Set("resolution", resolution.String())
url.RawQuery = v.Encode()
switch resolution {
case models.StreamingResolutionEnumFourK:
label += " 4K (2160p)"
case models.StreamingResolutionEnumFullHd:
label += " Full HD (1080p)"
case models.StreamingResolutionEnumStandardHd:
label += " HD (720p)"
case models.StreamingResolutionEnumStandard:
label += " Standard (480p)"
case models.StreamingResolutionEnumLow:
label += " Low (240p)"
}
}
return &SceneStreamEndpoint{
URL: url.String(),
MimeType: &t.mimeType,
Label: &label,
}
}
var endpoints []*SceneStreamEndpoint
// direct stream should only apply when the audio codec is supported // direct stream should only apply when the audio codec is supported
audioCodec := ffmpeg.MissingUnsupported audioCodec := ffmpeg.MissingUnsupported
@@ -99,99 +151,60 @@ func GetSceneStreamPaths(scene *models.Scene, directStreamURL *url.URL, maxStrea
// don't care if we can't get the container // don't care if we can't get the container
container, _ := GetVideoFileContainer(pf) container, _ := GetVideoFileContainer(pf)
replaceSuffix := func(suffix string) *url.URL {
urlCopy := *directStreamURL
urlCopy.Path += suffix
return &urlCopy
}
if HasTranscode(scene, config.GetInstance().GetVideoFileNamingAlgorithm()) || ffmpeg.IsValidAudioForContainer(audioCodec, container) { if HasTranscode(scene, config.GetInstance().GetVideoFileNamingAlgorithm()) || ffmpeg.IsValidAudioForContainer(audioCodec, container) {
label := "Direct stream" endpoints = append(endpoints, makeStreamEndpoint(directEndpointType, ""))
ret = append(ret, &SceneStreamEndpoint{
URL: directStreamURL.String(),
MimeType: &mimeMp4,
Label: &label,
})
} }
// only add mkv stream endpoint if the scene container is an mkv already // only add mkv stream endpoint if the scene container is an mkv already
if container == ffmpeg.Matroska { if container == ffmpeg.Matroska {
label := "mkv" endpoints = append(endpoints, makeStreamEndpoint(mkvEndpointType, ""))
ret = append(ret, &SceneStreamEndpoint{
URL: replaceSuffix(".mkv").String(),
// set mkv to mp4 to trick the client, since many clients won't try mkv
MimeType: &mimeMp4,
Label: &label,
})
} }
// WEBM quality transcoding options mp4Streams := []*SceneStreamEndpoint{}
// Note: These have the wrong mime type intentionally to allow jwplayer to selection between mp4/webm webmStreams := []*SceneStreamEndpoint{}
webmLabelFourK := "WEBM 4K (2160p)" // "FOUR_K" hlsStreams := []*SceneStreamEndpoint{}
webmLabelFullHD := "WEBM Full HD (1080p)" // "FULL_HD"
webmLabelStandardHD := "WEBM HD (720p)" // "STANDARD_HD"
webmLabelStandard := "WEBM Standard (480p)" // "STANDARD"
webmLabelLow := "WEBM Low (240p)" // "LOW"
// Setup up lower quality transcoding options (MP4) if includeSceneStreamPath(models.StreamingResolutionEnumOriginal) {
mp4LabelFourK := "MP4 4K (2160p)" // "FOUR_K" mp4Streams = append(mp4Streams, makeStreamEndpoint(mp4EndpointType, models.StreamingResolutionEnumOriginal))
mp4LabelFullHD := "MP4 Full HD (1080p)" // "FULL_HD" webmStreams = append(webmStreams, makeStreamEndpoint(webmEndpointType, models.StreamingResolutionEnumOriginal))
mp4LabelStandardHD := "MP4 HD (720p)" // "STANDARD_HD" hlsStreams = append(hlsStreams, makeStreamEndpoint(hlsEndpointType, models.StreamingResolutionEnumOriginal))
mp4LabelStandard := "MP4 Standard (480p)" // "STANDARD"
mp4LabelLow := "MP4 Low (240p)" // "LOW"
var webmStreams []*SceneStreamEndpoint
var mp4Streams []*SceneStreamEndpoint
webmURL := replaceSuffix(".webm")
mp4URL := replaceSuffix(".mp4")
if includeSceneStreamPath(pf, models.StreamingResolutionEnumFourK, maxStreamingTranscodeSize) {
webmStreams = append(webmStreams, makeStreamEndpoint(webmURL, models.StreamingResolutionEnumFourK, mimeMp4, webmLabelFourK))
mp4Streams = append(mp4Streams, makeStreamEndpoint(mp4URL, models.StreamingResolutionEnumFourK, mimeMp4, mp4LabelFourK))
} }
if includeSceneStreamPath(pf, models.StreamingResolutionEnumFullHd, maxStreamingTranscodeSize) { if includeSceneStreamPath(models.StreamingResolutionEnumFourK) {
webmStreams = append(webmStreams, makeStreamEndpoint(webmURL, models.StreamingResolutionEnumFullHd, mimeMp4, webmLabelFullHD)) mp4Streams = append(mp4Streams, makeStreamEndpoint(mp4EndpointType, models.StreamingResolutionEnumFourK))
mp4Streams = append(mp4Streams, makeStreamEndpoint(mp4URL, models.StreamingResolutionEnumFullHd, mimeMp4, mp4LabelFullHD)) webmStreams = append(webmStreams, makeStreamEndpoint(webmEndpointType, models.StreamingResolutionEnumFourK))
hlsStreams = append(hlsStreams, makeStreamEndpoint(hlsEndpointType, models.StreamingResolutionEnumFourK))
} }
if includeSceneStreamPath(pf, models.StreamingResolutionEnumStandardHd, maxStreamingTranscodeSize) { if includeSceneStreamPath(models.StreamingResolutionEnumFullHd) {
webmStreams = append(webmStreams, makeStreamEndpoint(webmURL, models.StreamingResolutionEnumStandardHd, mimeMp4, webmLabelStandardHD)) mp4Streams = append(mp4Streams, makeStreamEndpoint(mp4EndpointType, models.StreamingResolutionEnumFullHd))
mp4Streams = append(mp4Streams, makeStreamEndpoint(mp4URL, models.StreamingResolutionEnumStandardHd, mimeMp4, mp4LabelStandardHD)) webmStreams = append(webmStreams, makeStreamEndpoint(webmEndpointType, models.StreamingResolutionEnumFullHd))
hlsStreams = append(hlsStreams, makeStreamEndpoint(hlsEndpointType, models.StreamingResolutionEnumFullHd))
} }
if includeSceneStreamPath(pf, models.StreamingResolutionEnumStandard, maxStreamingTranscodeSize) { if includeSceneStreamPath(models.StreamingResolutionEnumStandardHd) {
webmStreams = append(webmStreams, makeStreamEndpoint(webmURL, models.StreamingResolutionEnumStandard, mimeMp4, webmLabelStandard)) mp4Streams = append(mp4Streams, makeStreamEndpoint(mp4EndpointType, models.StreamingResolutionEnumStandardHd))
mp4Streams = append(mp4Streams, makeStreamEndpoint(mp4URL, models.StreamingResolutionEnumStandard, mimeMp4, mp4LabelStandard)) webmStreams = append(webmStreams, makeStreamEndpoint(webmEndpointType, models.StreamingResolutionEnumStandardHd))
hlsStreams = append(hlsStreams, makeStreamEndpoint(hlsEndpointType, models.StreamingResolutionEnumStandardHd))
} }
if includeSceneStreamPath(pf, models.StreamingResolutionEnumLow, maxStreamingTranscodeSize) { if includeSceneStreamPath(models.StreamingResolutionEnumStandard) {
webmStreams = append(webmStreams, makeStreamEndpoint(webmURL, models.StreamingResolutionEnumLow, mimeMp4, webmLabelLow)) mp4Streams = append(mp4Streams, makeStreamEndpoint(mp4EndpointType, models.StreamingResolutionEnumStandard))
mp4Streams = append(mp4Streams, makeStreamEndpoint(mp4URL, models.StreamingResolutionEnumLow, mimeMp4, mp4LabelLow)) webmStreams = append(webmStreams, makeStreamEndpoint(webmEndpointType, models.StreamingResolutionEnumStandard))
hlsStreams = append(hlsStreams, makeStreamEndpoint(hlsEndpointType, models.StreamingResolutionEnumStandard))
} }
ret = append(ret, webmStreams...) if includeSceneStreamPath(models.StreamingResolutionEnumLow) {
ret = append(ret, mp4Streams...) mp4Streams = append(mp4Streams, makeStreamEndpoint(mp4EndpointType, models.StreamingResolutionEnumLow))
webmStreams = append(webmStreams, makeStreamEndpoint(webmEndpointType, models.StreamingResolutionEnumLow))
defaultStreams := []*SceneStreamEndpoint{ hlsStreams = append(hlsStreams, makeStreamEndpoint(hlsEndpointType, models.StreamingResolutionEnumLow))
{
URL: replaceSuffix(".webm").String(),
MimeType: &mimeWebm,
Label: &labelWebm,
},
} }
ret = append(ret, defaultStreams...) endpoints = append(endpoints, mp4Streams...)
endpoints = append(endpoints, webmStreams...)
endpoints = append(endpoints, hlsStreams...)
hls := SceneStreamEndpoint{ return endpoints, nil
URL: replaceSuffix(".m3u8").String(),
MimeType: &mimeHLS,
Label: &labelHLS,
}
ret = append(ret, &hls)
return ret, nil
} }
// HasTranscode returns true if a transcoded video exists for the provided // HasTranscode returns true if a transcoded video exists for the provided

View File

@@ -1,40 +0,0 @@
package ffmpeg
import (
"fmt"
"io"
"strings"
)
const hlsSegmentLength = 10.0
// WriteHLSPlaylist writes a HLS playlist to w using baseUrl as the base URL for TS streams.
func WriteHLSPlaylist(duration float64, 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")
leftover := duration
upTo := 0.0
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")
}

View File

@@ -2,237 +2,129 @@ package ffmpeg
import ( import (
"context" "context"
"errors"
"io"
"net/http" "net/http"
"os/exec" "sync"
"strings" "time"
"syscall"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
) )
const ( const (
MimeWebm string = "video/webm" MimeWebmVideo string = "video/webm"
MimeMkv string = "video/x-matroska" MimeWebmAudio string = "audio/webm"
MimeMp4 string = "video/mp4" MimeMkvVideo string = "video/x-matroska"
MimeHLS string = "application/vnd.apple.mpegurl" MimeMkvAudio string = "audio/x-matroska"
MimeMpegts string = "video/MP2T" MimeMp4Video string = "video/mp4"
MimeMp4Audio string = "audio/mp4"
) )
// Stream represents an ongoing transcoded stream. type StreamManager struct {
type Stream struct { cacheDir string
Stdout io.ReadCloser encoder FFMpeg
Cmd *exec.Cmd ffprobe FFProbe
mimeType string
config StreamManagerConfig
lockManager *fsutil.ReadLockManager
context context.Context
cancelFunc context.CancelFunc
runningStreams map[string]*runningStream
streamsMutex sync.Mutex
} }
// Serve is an http handler function that serves the stream. type StreamManagerConfig interface {
func (s *Stream) Serve(w http.ResponseWriter, r *http.Request) { GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum
w.Header().Set("Content-Type", s.mimeType)
w.WriteHeader(http.StatusOK)
logger.Infof("[stream] transcoding video file to %s", s.mimeType)
// process killing should be handled by command context
_, err := io.Copy(w, s.Stdout)
if err != nil && !errors.Is(err, syscall.EPIPE) {
logger.Errorf("[stream] error serving transcoded video file: %v", err)
}
} }
// StreamFormat represents a transcode stream format. func NewStreamManager(cacheDir string, encoder FFMpeg, ffprobe FFProbe, config StreamManagerConfig, lockManager *fsutil.ReadLockManager) *StreamManager {
type StreamFormat struct { if cacheDir == "" {
MimeType string logger.Warn("cache directory is not set. Live HLS transcoding will be disabled")
codec VideoCodec
format Format
extraArgs []string
hls bool
} }
var ( ctx, cancel := context.WithCancel(context.Background())
StreamFormatHLS = StreamFormat{
codec: VideoCodecLibX264, ret := &StreamManager{
format: FormatMpegTS, cacheDir: cacheDir,
MimeType: MimeMpegts, encoder: encoder,
extraArgs: []string{ ffprobe: ffprobe,
"-acodec", "aac", config: config,
"-pix_fmt", "yuv420p", lockManager: lockManager,
"-preset", "veryfast", context: ctx,
"-crf", "25", cancelFunc: cancel,
}, runningStreams: make(map[string]*runningStream),
hls: true,
} }
StreamFormatH264 = StreamFormat{
codec: VideoCodecLibX264,
format: FormatMP4,
MimeType: MimeMp4,
extraArgs: []string{
"-movflags", "frag_keyframe+empty_moov",
"-pix_fmt", "yuv420p",
"-preset", "veryfast",
"-crf", "25",
},
}
StreamFormatVP9 = StreamFormat{
codec: VideoCodecVP9,
format: FormatWebm,
MimeType: MimeWebm,
extraArgs: []string{
"-deadline", "realtime",
"-cpu-used", "5",
"-row-mt", "1",
"-crf", "30",
"-b:v", "0",
"-pix_fmt", "yuv420p",
},
}
StreamFormatVP8 = StreamFormat{
codec: VideoCodecVPX,
format: FormatWebm,
MimeType: MimeWebm,
extraArgs: []string{
"-deadline", "realtime",
"-cpu-used", "5",
"-crf", "12",
"-b:v", "3M",
"-pix_fmt", "yuv420p",
},
}
StreamFormatHEVC = StreamFormat{
codec: VideoCodecLibX265,
format: FormatMP4,
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
StreamFormatMKVAudio = StreamFormat{
codec: VideoCodecCopy,
format: FormatMatroska,
MimeType: MimeMkv,
extraArgs: []string{
"-c:a", "libopus",
"-b:a", "96k",
"-vbr", "on",
},
}
)
// TranscodeStreamOptions represents options for live transcoding a video file.
type TranscodeStreamOptions struct {
Input string
Codec StreamFormat
StartTime float64
MaxTranscodeSize int
// original video dimensions
VideoWidth int
VideoHeight int
// 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
// arguments added before the input argument
ExtraInputArgs []string
// arguments added before the output argument
ExtraOutputArgs []string
}
func (o TranscodeStreamOptions) getStreamArgs() Args {
var args Args
args = append(args, "-hide_banner")
args = append(args, o.ExtraInputArgs...)
args = args.LogLevel(LogLevelError)
if o.StartTime != 0 {
args = args.Seek(o.StartTime)
}
if o.Codec.hls {
// we only serve a fixed segment length
args = args.Duration(hlsSegmentLength)
}
args = args.Input(o.Input)
if o.VideoOnly {
args = args.SkipAudio()
}
args = args.VideoCodec(o.Codec.codec)
// don't set scale when copying video stream
if o.Codec.codec != VideoCodecCopy {
var videoFilter VideoFilter
videoFilter = videoFilter.ScaleMax(o.VideoWidth, o.VideoHeight, o.MaxTranscodeSize)
args = args.VideoFilter(videoFilter)
}
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",
)
args = append(args, o.ExtraOutputArgs...)
args = args.Format(o.Codec.format)
args = args.Output("pipe:")
return args
}
// GetTranscodeStream starts the live transcoding process using ffmpeg and returns a stream.
func (f *FFMpeg) GetTranscodeStream(ctx context.Context, options TranscodeStreamOptions) (*Stream, error) {
args := options.getStreamArgs()
cmd := f.Command(ctx, 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
}
// stderr must be consumed or the process deadlocks
go func() { go func() {
stderrData, _ := io.ReadAll(stderr) for {
stderrString := string(stderrData) select {
if len(stderrString) > 0 { case <-time.After(monitorInterval):
logger.Debugf("[stream] ffmpeg stderr: %s", stderrString) ret.monitorStreams()
case <-ctx.Done():
return
}
} }
}() }()
ret := &Stream{ return ret
Stdout: stdout, }
Cmd: cmd,
mimeType: options.Codec.MimeType, // Shutdown shuts down the stream manager, killing any running transcoding processes and removing all cached files.
func (sm *StreamManager) Shutdown() {
sm.cancelFunc()
sm.stopAndRemoveAll()
}
type StreamRequestContext struct {
context.Context
ResponseWriter http.ResponseWriter
}
func NewStreamRequestContext(w http.ResponseWriter, r *http.Request) *StreamRequestContext {
return &StreamRequestContext{
Context: r.Context(),
ResponseWriter: w,
}
}
func (c *StreamRequestContext) Cancel() {
hj, ok := (c.ResponseWriter).(http.Hijacker)
if !ok {
return
}
// hijack and close the connection
conn, bw, _ := hj.Hijack()
if conn != nil {
if bw != nil {
// notify end of stream
_, err := bw.WriteString("0\r\n")
if err != nil {
logger.Warnf("unable to write end of stream: %v", err)
}
_, err = bw.WriteString("\r\n")
if err != nil {
logger.Warnf("unable to write end of stream: %v", err)
}
// flush the buffer, but don't wait indefinitely
timeout := make(chan struct{}, 1)
go func() {
_ = bw.Flush()
close(timeout)
}()
const waitTime = time.Second
select {
case <-timeout:
case <-time.After(waitTime):
logger.Warnf("unable to flush buffer - closing connection")
}
}
conn.Close()
} }
return ret, nil
} }

View File

@@ -0,0 +1,639 @@
package ffmpeg
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"math"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/stashapp/stash/pkg/file"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
)
const (
MimeHLS string = "application/vnd.apple.mpegurl"
MimeMpegTS string = "video/MP2T"
segmentLength = 2
maxSegmentWait = 15 * time.Second
monitorInterval = 200 * time.Millisecond
// segment gap before counting a request as a seek and
// restarting the transcode process at the requested segment
maxSegmentGap = 5
// maximum number of segments to generate
// ahead of the currently streaming segment
maxSegmentBuffer = 15
// maximum idle time between segment requests before
// stopping transcode and deleting cache folder
maxIdleTime = 30 * time.Second
)
type StreamType struct {
Name string
SegmentType *SegmentType
ServeManifest func(sm *StreamManager, w http.ResponseWriter, r *http.Request, vf *file.VideoFile, resolution string)
Args func(segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) Args
}
var (
StreamTypeHLS = &StreamType{
Name: "hls",
SegmentType: SegmentTypeTS,
ServeManifest: serveHLSManifest,
Args: func(segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) {
args = append(args,
"-c:v", "libx264",
"-pix_fmt", "yuv420p",
"-preset", "veryfast",
"-crf", "25",
"-flags", "+cgop",
"-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d)", segmentLength),
"-sc_threshold", "0",
)
args = args.VideoFilter(videoFilter)
if videoOnly {
args = append(args, "-an")
} else {
args = append(args,
"-c:a", "aac",
"-ac", "2",
)
}
args = append(args,
"-sn",
"-copyts",
"-avoid_negative_ts", "disabled",
"-f", "hls",
"-start_number", fmt.Sprint(segment),
"-hls_time", "2",
"-hls_segment_type", "mpegts",
"-hls_playlist_type", "vod",
"-hls_segment_filename", filepath.Join(outputDir, ".%d.ts"),
filepath.Join(outputDir, "manifest.m3u8"),
)
return
},
}
StreamTypeHLSCopy = &StreamType{
Name: "hls-copy",
SegmentType: SegmentTypeTS,
ServeManifest: serveHLSManifest,
Args: func(segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) {
args = append(args,
"-c:v", "copy",
)
if videoOnly {
args = append(args, "-an")
} else {
args = append(args,
"-c:a", "aac",
"-ac", "2",
)
}
args = append(args,
"-sn",
"-copyts",
"-avoid_negative_ts", "disabled",
"-f", "hls",
"-start_number", fmt.Sprint(segment),
"-hls_time", "2",
"-hls_segment_type", "mpegts",
"-hls_playlist_type", "vod",
"-hls_segment_filename", filepath.Join(outputDir, ".%d.ts"),
filepath.Join(outputDir, "manifest.m3u8"),
)
return
},
}
)
type SegmentType struct {
Format string
MimeType string
MakeFilename func(segment int) string
ParseSegment func(str string) (int, error)
}
var (
SegmentTypeTS = &SegmentType{
Format: "%d.ts",
MimeType: MimeMpegTS,
MakeFilename: func(segment int) string {
return fmt.Sprintf("%d.ts", segment)
},
ParseSegment: func(str string) (int, error) {
segment, err := strconv.Atoi(str)
if err != nil || segment < 0 {
err = ErrInvalidSegment
}
return segment, err
},
}
)
var ErrInvalidSegment = errors.New("invalid segment")
type StreamOptions struct {
StreamType *StreamType
VideoFile *file.VideoFile
Resolution string
Hash string
Segment string
}
type transcodeProcess struct {
cmd *exec.Cmd
context context.Context
cancel context.CancelFunc
cancelled bool
outputDir string
segmentType *SegmentType
segment int
}
type waitingSegment struct {
segmentType *SegmentType
idx int
file string
path string
accessed time.Time
available chan error
done atomic.Bool
}
type runningStream struct {
dir string
streamType *StreamType
vf *file.VideoFile
maxTranscodeSize int
outputDir string
waitingSegments []*waitingSegment
tp *transcodeProcess
lastAccessed time.Time
lastSegment int
}
func (t StreamType) String() string {
return t.Name
}
func (t StreamType) FileDir(hash string, maxTranscodeSize int) string {
if maxTranscodeSize == 0 {
return fmt.Sprintf("%s_%s", hash, t)
} else {
return fmt.Sprintf("%s_%s_%d", hash, t, maxTranscodeSize)
}
}
func (s *runningStream) makeStreamArgs(segment int) Args {
args := Args{"-hide_banner"}
args = args.LogLevel(LogLevelError)
if segment > 0 {
args = args.Seek(float64(segment * segmentLength))
}
args = args.Input(s.vf.Path)
videoOnly := ProbeAudioCodec(s.vf.AudioCodec) == MissingUnsupported
var videoFilter VideoFilter
videoFilter = videoFilter.ScaleMax(s.vf.Width, s.vf.Height, s.maxTranscodeSize)
args = append(args, s.streamType.Args(segment, videoFilter, videoOnly, s.outputDir)...)
return args
}
// checkSegments renames temp segments that have been completely generated.
// existing segments are not replaced - if a segment is generated
// multiple times, then only the first one is kept.
func (tp *transcodeProcess) checkSegments() {
doSegment := func(filename string) {
if filename != "" {
oldPath := filepath.Join(tp.outputDir, filename)
newPath := filepath.Join(tp.outputDir, filename[1:])
if !segmentExists(newPath) {
_ = os.Rename(oldPath, newPath)
} else {
os.Remove(oldPath)
}
}
}
processState := tp.cmd.ProcessState
var lastFilename string
for i := tp.segment; ; i++ {
filename := fmt.Sprintf("."+tp.segmentType.Format, i)
if segmentExists(filepath.Join(tp.outputDir, filename)) {
// this segment exists so the previous segment is valid
doSegment(lastFilename)
} else {
// if the transcode process has exited then
// we need to do something with the last segment
if processState != nil {
if processState.Success() {
// if the process exited successfully then
// count the last segment as valid
doSegment(lastFilename)
} else if lastFilename != "" {
// if the process exited unsuccessfully then just delete
// the last segment, it's probably incomplete
os.Remove(filepath.Join(tp.outputDir, lastFilename))
}
}
break
}
lastFilename = filename
tp.segment = i
}
}
func lastSegment(vf *file.VideoFile) int {
return int(math.Ceil(vf.Duration/segmentLength)) - 1
}
func segmentExists(path string) bool {
exists, _ := fsutil.FileExists(path)
return exists
}
// serveHLSManifest serves a generated HLS playlist. The URLs for the segments
// are of the form {r.URL}/%d.ts{?urlQuery} where %d is the segment index.
func serveHLSManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request, vf *file.VideoFile, resolution string) {
if sm.cacheDir == "" {
logger.Error("[transcode] cannot live transcode with HLS because cache dir is unset")
http.Error(w, "cannot live transcode with HLS because cache dir is unset", http.StatusServiceUnavailable)
return
}
probeResult, err := sm.ffprobe.NewVideoFile(vf.Path)
if err != nil {
logger.Warnf("[transcode] error generating HLS manifest: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
baseUrl := *r.URL
baseUrl.RawQuery = ""
baseURL := baseUrl.String()
var urlQuery string
if resolution != "" {
urlQuery = fmt.Sprintf("?resolution=%s", resolution)
}
var buf bytes.Buffer
fmt.Fprint(&buf, "#EXTM3U\n")
fmt.Fprint(&buf, "#EXT-X-VERSION:3\n")
fmt.Fprint(&buf, "#EXT-X-MEDIA-SEQUENCE:0\n")
fmt.Fprintf(&buf, "#EXT-X-TARGETDURATION:%d\n", segmentLength)
fmt.Fprint(&buf, "#EXT-X-PLAYLIST-TYPE:VOD\n")
leftover := probeResult.FileDuration
segment := 0
for leftover > 0 {
thisLength := float64(segmentLength)
if leftover < thisLength {
thisLength = leftover
}
fmt.Fprintf(&buf, "#EXTINF:%f,\n", thisLength)
fmt.Fprintf(&buf, "%s/%d.ts%s\n", baseURL, segment, urlQuery)
leftover -= thisLength
segment++
}
fmt.Fprint(&buf, "#EXT-X-ENDLIST\n")
w.Header().Set("Content-Type", MimeHLS)
http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(buf.Bytes()))
}
func (sm *StreamManager) ServeManifest(w http.ResponseWriter, r *http.Request, streamType *StreamType, vf *file.VideoFile, resolution string) {
streamType.ServeManifest(sm, w, r, vf, resolution)
}
func (sm *StreamManager) serveWaitingSegment(w http.ResponseWriter, r *http.Request, segment *waitingSegment) {
select {
case <-r.Context().Done():
break
case err := <-segment.available:
if err == nil {
logger.Tracef("[transcode] streaming segment file %s", segment.file)
w.Header().Set("Content-Type", segment.segmentType.MimeType)
// Prevent caching as segments are generated on the fly
w.Header().Add("Cache-Control", "no-cache")
http.ServeFile(w, r, segment.path)
} else if !errors.Is(err, context.Canceled) {
logger.Errorf("[transcode] %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
segment.done.Store(true)
}
func (sm *StreamManager) ServeSegment(w http.ResponseWriter, r *http.Request, options StreamOptions) {
if sm.cacheDir == "" {
logger.Error("[transcode] cannot live transcode files because cache dir is unset")
http.Error(w, "cannot live transcode files because cache dir is unset", http.StatusServiceUnavailable)
return
}
if options.Hash == "" {
http.Error(w, "invalid hash", http.StatusBadRequest)
return
}
streamType := options.StreamType
segment, err := streamType.SegmentType.ParseSegment(options.Segment)
// error if segment is past the end of the video
if err != nil || segment > lastSegment(options.VideoFile) {
http.Error(w, "invalid segment", http.StatusBadRequest)
return
}
maxTranscodeSize := sm.config.GetMaxStreamingTranscodeSize().GetMaxResolution()
if options.Resolution != "" {
maxTranscodeSize = models.StreamingResolutionEnum(options.Resolution).GetMaxResolution()
}
dir := options.StreamType.FileDir(options.Hash, maxTranscodeSize)
outputDir := filepath.Join(sm.cacheDir, dir)
name := streamType.SegmentType.MakeFilename(segment)
file := filepath.Join(dir, name)
sm.streamsMutex.Lock()
stream := sm.runningStreams[dir]
if stream == nil {
stream = &runningStream{
dir: dir,
streamType: options.StreamType,
vf: options.VideoFile,
maxTranscodeSize: maxTranscodeSize,
outputDir: outputDir,
// initialize to cap 10 to avoid reallocations
waitingSegments: make([]*waitingSegment, 0, 10),
}
sm.runningStreams[dir] = stream
}
now := time.Now()
stream.lastAccessed = now
if segment != -1 {
stream.lastSegment = segment
}
waitingSegment := &waitingSegment{
segmentType: streamType.SegmentType,
idx: segment,
file: file,
path: filepath.Join(sm.cacheDir, file),
accessed: now,
available: make(chan error, 1),
}
stream.waitingSegments = append(stream.waitingSegments, waitingSegment)
sm.streamsMutex.Unlock()
sm.serveWaitingSegment(w, r, waitingSegment)
}
// assume lock is held
func (sm *StreamManager) startTranscode(stream *runningStream, segment int, done chan<- error) {
// generate segment 0 if init segment requested
if segment == -1 {
segment = 0
}
logger.Debugf("[transcode] starting transcode for %s at segment #%d", stream.dir, segment)
if err := os.MkdirAll(stream.outputDir, os.ModePerm); err != nil {
done <- err
return
}
lockCtx := sm.lockManager.ReadLock(sm.context, stream.vf.Path)
args := stream.makeStreamArgs(segment)
cmd := sm.encoder.Command(lockCtx, args)
stderr, err := cmd.StderrPipe()
if err != nil {
logger.Errorf("[transcode] ffmpeg stderr not available: %v", err)
}
stdout, err := cmd.StdoutPipe()
if nil != err {
logger.Errorf("[transcode] ffmpeg stdout not available: %v", err)
}
logger.Tracef("[transcode] running %s", cmd)
if err := cmd.Start(); err != nil {
lockCtx.Cancel()
done <- fmt.Errorf("error starting transcode process: %w", err)
return
}
tp := &transcodeProcess{
cmd: cmd,
context: lockCtx,
cancel: lockCtx.Cancel,
outputDir: stream.outputDir,
segmentType: stream.streamType.SegmentType,
segment: segment,
}
stream.tp = tp
go func() {
errStr, _ := io.ReadAll(stderr)
outStr, _ := io.ReadAll(stdout)
errCmd := cmd.Wait()
var err error
// don't log error if cancelled
if !tp.cancelled {
e := string(errStr)
if e == "" {
e = string(outStr)
}
if e != "" {
err = errors.New(e)
} else {
err = errCmd
}
if err != nil {
err = fmt.Errorf("[transcode] ffmpeg error when running command <%s>: %w", strings.Join(cmd.Args, " "), err)
}
}
sm.streamsMutex.Lock()
// make sure that cancel is called to prevent memory leaks
tp.cancel()
// clear remaining segments after ffmpeg exit
tp.checkSegments()
if stream.tp == tp {
stream.tp = nil
}
sm.streamsMutex.Unlock()
done <- err
}()
}
// assume lock is held
func (sm *StreamManager) stopTranscode(stream *runningStream) {
tp := stream.tp
if tp != nil {
tp.cancel()
tp.cancelled = true
}
}
func (sm *StreamManager) checkTranscode(stream *runningStream, now time.Time) {
if len(stream.waitingSegments) == 0 && stream.lastAccessed.Add(maxIdleTime).Before(now) {
// Stream expired. Cancel the transcode process and delete the files
logger.Debugf("[transcode] stream for %s not accessed recently. Cancelling transcode and removing files", stream.dir)
sm.stopTranscode(stream)
sm.removeTranscodeFiles(stream)
delete(sm.runningStreams, stream.dir)
return
}
if stream.tp != nil {
segmentType := stream.streamType.SegmentType
segment := stream.lastSegment
// if all segments up to maxSegmentBuffer exist, stop transcode
for i := segment; i < segment+maxSegmentBuffer; i++ {
if !segmentExists(filepath.Join(stream.outputDir, segmentType.MakeFilename(i))) {
return
}
}
logger.Debugf("[transcode] stopping transcode for %s, buffer is full", stream.dir)
sm.stopTranscode(stream)
}
}
func (s *waitingSegment) checkAvailable(now time.Time) bool {
if segmentExists(s.path) {
s.available <- nil
return true
} else if s.accessed.Add(maxSegmentWait).Before(now) {
s.available <- fmt.Errorf("timed out waiting for segment file %s to be generated", s.file)
return true
}
return false
}
// ensureTranscode will start a new transcode process if the transcode
// is more than maxSegmentGap behind the requested segment
func (sm *StreamManager) ensureTranscode(stream *runningStream, segment *waitingSegment) bool {
segmentIdx := segment.idx
tp := stream.tp
if tp == nil {
sm.startTranscode(stream, segmentIdx, segment.available)
return true
} else if segmentIdx < tp.segment || tp.segment+maxSegmentGap < segmentIdx {
// only stop the transcode process here - it will be restarted only
// after the old process exits as stream.tp will then be nil.
sm.stopTranscode(stream)
return true
}
return false
}
// runs every monitorInterval
func (sm *StreamManager) monitorStreams() {
sm.streamsMutex.Lock()
defer sm.streamsMutex.Unlock()
now := time.Now()
for _, stream := range sm.runningStreams {
if stream.tp != nil {
stream.tp.checkSegments()
}
transcodeStarted := false
temp := stream.waitingSegments[:0]
for _, segment := range stream.waitingSegments {
remove := false
if segment.done.Load() || segment.checkAvailable(now) {
remove = true
} else if !transcodeStarted {
transcodeStarted = sm.ensureTranscode(stream, segment)
}
if !remove {
temp = append(temp, segment)
}
}
stream.waitingSegments = temp
if !transcodeStarted {
sm.checkTranscode(stream, now)
}
}
}
// assume lock is held
func (sm *StreamManager) removeTranscodeFiles(stream *runningStream) {
path := stream.outputDir
if err := os.RemoveAll(path); err != nil {
logger.Warnf("[transcode] error removing segment directory %s: %v", path, err)
}
}
// stopAndRemoveAll stops all current streams and removes all cache files
func (sm *StreamManager) stopAndRemoveAll() {
sm.streamsMutex.Lock()
defer sm.streamsMutex.Unlock()
for _, stream := range sm.runningStreams {
for _, segment := range stream.waitingSegments {
if len(segment.available) == 0 {
segment.available <- context.Canceled
}
}
sm.stopTranscode(stream)
sm.removeTranscodeFiles(stream)
}
// ensure nothing else can use the map
sm.runningStreams = nil
}

View File

@@ -0,0 +1,199 @@
package ffmpeg
import (
"errors"
"io"
"net/http"
"os/exec"
"strings"
"syscall"
"github.com/stashapp/stash/pkg/file"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
)
type StreamFormat struct {
MimeType string
Args func(videoFilter VideoFilter, videoOnly bool) Args
}
var (
StreamTypeMP4 = StreamFormat{
MimeType: MimeMp4Video,
Args: func(videoFilter VideoFilter, videoOnly bool) (args Args) {
args = args.VideoCodec(VideoCodecLibX264)
args = append(args,
"-movflags", "frag_keyframe+empty_moov",
"-pix_fmt", "yuv420p",
"-preset", "veryfast",
"-crf", "25",
)
args = args.VideoFilter(videoFilter)
if videoOnly {
args = args.SkipAudio()
} else {
args = append(args, "-ac", "2")
}
args = args.Format(FormatMP4)
return
},
}
StreamTypeWEBM = StreamFormat{
MimeType: MimeWebmVideo,
Args: func(videoFilter VideoFilter, videoOnly bool) (args Args) {
args = args.VideoCodec(VideoCodecVP9)
args = append(args,
"-pix_fmt", "yuv420p",
"-deadline", "realtime",
"-cpu-used", "5",
"-row-mt", "1",
"-crf", "30",
"-b:v", "0",
)
args = args.VideoFilter(videoFilter)
if videoOnly {
args = args.SkipAudio()
} else {
args = append(args, "-ac", "2")
}
args = args.Format(FormatWebm)
return
},
}
StreamTypeMKV = StreamFormat{
MimeType: MimeMkvVideo,
Args: func(videoFilter VideoFilter, videoOnly bool) (args Args) {
args = args.VideoCodec(VideoCodecCopy)
if videoOnly {
args = args.SkipAudio()
} else {
args = args.AudioCodec(AudioCodecLibOpus)
args = append(args,
"-b:a", "96k",
"-vbr", "on",
"-ac", "2",
)
}
args = args.Format(FormatMatroska)
return
},
}
)
type TranscodeOptions struct {
StreamType StreamFormat
VideoFile *file.VideoFile
Resolution string
StartTime float64
}
func (o TranscodeOptions) makeStreamArgs(vf *file.VideoFile, maxScale int, startTime float64) Args {
args := Args{"-hide_banner"}
args = args.LogLevel(LogLevelError)
if startTime != 0 {
args = args.Seek(startTime)
}
args = args.Input(vf.Path)
videoOnly := ProbeAudioCodec(vf.AudioCodec) == MissingUnsupported
var videoFilter VideoFilter
videoFilter = videoFilter.ScaleMax(vf.Width, vf.Height, maxScale)
args = append(args, o.StreamType.Args(videoFilter, videoOnly)...)
args = args.Output("pipe:")
return args
}
func (sm *StreamManager) ServeTranscode(w http.ResponseWriter, r *http.Request, options TranscodeOptions) {
streamRequestCtx := NewStreamRequestContext(w, r)
lockCtx := sm.lockManager.ReadLock(streamRequestCtx, options.VideoFile.Path)
// hijacking and closing the connection here causes video playback to hang in Chrome
// due to ERR_INCOMPLETE_CHUNKED_ENCODING
// We trust that the request context will be closed, so we don't need to call Cancel on the returned context here.
handler, err := sm.getTranscodeStream(lockCtx, options)
if err != nil {
logger.Errorf("[transcode] error transcoding video file: %v", err)
w.WriteHeader(http.StatusBadRequest)
if _, err := w.Write([]byte(err.Error())); err != nil {
logger.Warnf("[transcode] error writing response: %v", err)
}
return
}
handler(w, r)
}
func (sm *StreamManager) getTranscodeStream(ctx *fsutil.LockContext, options TranscodeOptions) (http.HandlerFunc, error) {
maxTranscodeSize := sm.config.GetMaxStreamingTranscodeSize().GetMaxResolution()
if options.Resolution != "" {
maxTranscodeSize = models.StreamingResolutionEnum(options.Resolution).GetMaxResolution()
}
args := options.makeStreamArgs(options.VideoFile, maxTranscodeSize, options.StartTime)
cmd := sm.encoder.Command(ctx, args)
stdout, err := cmd.StdoutPipe()
if nil != err {
logger.Errorf("[transcode] ffmpeg stdout not available: %v", err)
return nil, err
}
stderr, err := cmd.StderrPipe()
if nil != err {
logger.Errorf("[transcode] ffmpeg stderr not available: %v", err)
return nil, err
}
if err = cmd.Start(); err != nil {
return nil, err
}
ctx.AttachCommand(cmd)
// stderr must be consumed or the process deadlocks
go func() {
errStr, _ := io.ReadAll(stderr)
errCmd := cmd.Wait()
var err error
e := string(errStr)
if e != "" {
err = errors.New(e)
} else {
err = errCmd
}
// ignore ExitErrors, the process is always forcibly killed
var exitError *exec.ExitError
if err != nil && !errors.As(err, &exitError) {
logger.Errorf("[transcode] ffmpeg error when running command <%s>: %v", strings.Join(cmd.Args, " "), err)
}
}()
mimeType := options.StreamType.MimeType
handler := func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", mimeType)
w.WriteHeader(http.StatusOK)
// process killing should be handled by command context
_, err := io.Copy(w, stdout)
if err != nil && !errors.Is(err, syscall.EPIPE) {
logger.Errorf("[transcode] error serving transcoded video file: %v", err)
}
w.(http.Flusher).Flush()
}
return handler, nil
}

View File

@@ -1,3 +1,5 @@
##### 💥 Note: The cache directory is now required if using HLS streaming. Please set the cache directory in the System Settings page.
### ✨ New Features ### ✨ New Features
* Add configuration option to perform generation operations sequentially after scanning a new video file. ([#3378](https://github.com/stashapp/stash/pull/3378)) * Add configuration option to perform generation operations sequentially after scanning a new video file. ([#3378](https://github.com/stashapp/stash/pull/3378))
* Optionally show range in generated funscript heatmaps. ([#3373](https://github.com/stashapp/stash/pull/3373)) * Optionally show range in generated funscript heatmaps. ([#3373](https://github.com/stashapp/stash/pull/3373))
@@ -6,6 +8,9 @@
* Added tenth-place rating precision option. ([#3343](https://github.com/stashapp/stash/pull/3343)) * Added tenth-place rating precision option. ([#3343](https://github.com/stashapp/stash/pull/3343))
* Added toggleable favorite button to Performer cards. ([#3369](https://github.com/stashapp/stash/pull/3369)) * Added toggleable favorite button to Performer cards. ([#3369](https://github.com/stashapp/stash/pull/3369))
### 🎨 Improvements
* Overhauled and improved HLS streaming. ([#3274](https://github.com/stashapp/stash/pull/3274))
### 🐛 Bug fixes ### 🐛 Bug fixes
* Fixed URL not being during stash-box scrape if the Studio URL is not set. ([#3439](https://github.com/stashapp/stash/pull/3439)) * Fixed URL not being during stash-box scrape if the Studio URL is not set. ([#3439](https://github.com/stashapp/stash/pull/3439))
* Fixed generating previews for variable frame rate videos. ([#3376](https://github.com/stashapp/stash/pull/3376)) * Fixed generating previews for variable frame rate videos. ([#3376](https://github.com/stashapp/stash/pull/3376))

View File

@@ -77,6 +77,10 @@ This setting can be used to increase/decrease overall CPU utilisation in two sce
Note: If this is set too high it will decrease overall performance and causes failures (out of memory). Note: If this is set too high it will decrease overall performance and causes failures (out of memory).
## HLS Streaming
If using HLS streaming (such as on Apple devices), the Cache path must be set. This directory is used to store temporary files during the live-transcoding process. The Cache path can be set in the System settings page.
## ffmpeg arguments ## ffmpeg arguments
Additional arguments can be injected into ffmpeg when generating previews and sprites, and when live-transcoding videos. Additional arguments can be injected into ffmpeg when generating previews and sprites, and when live-transcoding videos.

View File

@@ -1,4 +1,5 @@
import v0170 from "./v0170.md"; import v0170 from "./v0170.md";
import v0200 from "./v0200.md";
interface IReleaseNotes { interface IReleaseNotes {
// handle should be in the form of YYYYMMDD // handle should be in the form of YYYYMMDD
@@ -11,4 +12,8 @@ export const releaseNotes: IReleaseNotes[] = [
date: 20220906, date: 20220906,
content: v0170, content: v0170,
}, },
{
date: 20230224,
content: v0200,
},
]; ];

View File

@@ -0,0 +1 @@
The cache directory is now required if using HLS streaming. Please set the cache directory in the System Settings page.

View File

@@ -256,7 +256,7 @@
"description": "Directory location for SQLite database file backups", "description": "Directory location for SQLite database file backups",
"heading": "Backup Directory Path" "heading": "Backup Directory Path"
}, },
"cache_location": "Directory location of the cache", "cache_location": "Directory location of the cache. Required if streaming using HLS (such as on Apple devices).",
"cache_path_head": "Cache Path", "cache_path_head": "Cache Path",
"calculate_md5_and_ohash_desc": "Calculate MD5 checksum in addition to oshash. Enabling will cause initial scans to be slower. File naming hash must be set to oshash to disable MD5 calculation.", "calculate_md5_and_ohash_desc": "Calculate MD5 checksum in addition to oshash. Enabling will cause initial scans to be slower. File naming hash must be set to oshash to disable MD5 calculation.",
"calculate_md5_and_ohash_label": "Calculate MD5 for videos", "calculate_md5_and_ohash_label": "Calculate MD5 for videos",