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

@@ -2,237 +2,129 @@ package ffmpeg
import (
"context"
"errors"
"io"
"net/http"
"os/exec"
"strings"
"syscall"
"sync"
"time"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
)
const (
MimeWebm string = "video/webm"
MimeMkv string = "video/x-matroska"
MimeMp4 string = "video/mp4"
MimeHLS string = "application/vnd.apple.mpegurl"
MimeMpegts string = "video/MP2T"
MimeWebmVideo string = "video/webm"
MimeWebmAudio string = "audio/webm"
MimeMkvVideo string = "video/x-matroska"
MimeMkvAudio string = "audio/x-matroska"
MimeMp4Video string = "video/mp4"
MimeMp4Audio string = "audio/mp4"
)
// Stream represents an ongoing transcoded stream.
type Stream struct {
Stdout io.ReadCloser
Cmd *exec.Cmd
mimeType string
type StreamManager struct {
cacheDir string
encoder FFMpeg
ffprobe FFProbe
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.
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)
// 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)
}
type StreamManagerConfig interface {
GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum
}
// StreamFormat represents a transcode stream format.
type StreamFormat struct {
MimeType string
codec VideoCodec
format Format
extraArgs []string
hls bool
}
var (
StreamFormatHLS = StreamFormat{
codec: VideoCodecLibX264,
format: FormatMpegTS,
MimeType: MimeMpegts,
extraArgs: []string{
"-acodec", "aac",
"-pix_fmt", "yuv420p",
"-preset", "veryfast",
"-crf", "25",
},
hls: true,
func NewStreamManager(cacheDir string, encoder FFMpeg, ffprobe FFProbe, config StreamManagerConfig, lockManager *fsutil.ReadLockManager) *StreamManager {
if cacheDir == "" {
logger.Warn("cache directory is not set. Live HLS transcoding will be disabled")
}
StreamFormatH264 = StreamFormat{
codec: VideoCodecLibX264,
format: FormatMP4,
MimeType: MimeMp4,
extraArgs: []string{
"-movflags", "frag_keyframe+empty_moov",
"-pix_fmt", "yuv420p",
"-preset", "veryfast",
"-crf", "25",
},
ctx, cancel := context.WithCancel(context.Background())
ret := &StreamManager{
cacheDir: cacheDir,
encoder: encoder,
ffprobe: ffprobe,
config: config,
lockManager: lockManager,
context: ctx,
cancelFunc: cancel,
runningStreams: make(map[string]*runningStream),
}
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() {
stderrData, _ := io.ReadAll(stderr)
stderrString := string(stderrData)
if len(stderrString) > 0 {
logger.Debugf("[stream] ffmpeg stderr: %s", stderrString)
for {
select {
case <-time.After(monitorInterval):
ret.monitorStreams()
case <-ctx.Done():
return
}
}
}()
ret := &Stream{
Stdout: stdout,
Cmd: cmd,
mimeType: options.Codec.MimeType,
}
return ret, nil
return ret
}
// 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()
}
}