mirror of
https://github.com/bluenviron/mediamtx
synced 2025-01-11 01:19:35 +00:00
488 lines
12 KiB
Go
488 lines
12 KiB
Go
package hls
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"math"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/aler9/gortsplib"
|
|
)
|
|
|
|
type muxerVariantFMP4SegmentOrGap interface {
|
|
getRenderedDuration() time.Duration
|
|
}
|
|
|
|
type muxerVariantFMP4Gap struct {
|
|
renderedDuration time.Duration
|
|
}
|
|
|
|
func (g muxerVariantFMP4Gap) getRenderedDuration() time.Duration {
|
|
return g.renderedDuration
|
|
}
|
|
|
|
func targetDuration(segments []muxerVariantFMP4SegmentOrGap) uint {
|
|
ret := uint(0)
|
|
|
|
// EXTINF, when rounded to the nearest integer, must be <= EXT-X-TARGETDURATION
|
|
for _, sog := range segments {
|
|
v := uint(math.Round(sog.getRenderedDuration().Seconds()))
|
|
if v > ret {
|
|
ret = v
|
|
}
|
|
}
|
|
|
|
return ret
|
|
}
|
|
|
|
func partTargetDuration(
|
|
segments []muxerVariantFMP4SegmentOrGap,
|
|
nextSegmentParts []*muxerVariantFMP4Part,
|
|
) time.Duration {
|
|
var ret time.Duration
|
|
|
|
for _, sog := range segments {
|
|
seg, ok := sog.(*muxerVariantFMP4Segment)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
for _, part := range seg.parts {
|
|
if part.renderedDuration > ret {
|
|
ret = part.renderedDuration
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, part := range nextSegmentParts {
|
|
if part.renderedDuration > ret {
|
|
ret = part.renderedDuration
|
|
}
|
|
}
|
|
|
|
return ret
|
|
}
|
|
|
|
type muxerVariantFMP4Playlist struct {
|
|
lowLatency bool
|
|
segmentCount int
|
|
videoTrack *gortsplib.TrackH264
|
|
audioTrack *gortsplib.TrackMPEG4Audio
|
|
|
|
mutex sync.Mutex
|
|
cond *sync.Cond
|
|
closed bool
|
|
segments []muxerVariantFMP4SegmentOrGap
|
|
segmentsByName map[string]*muxerVariantFMP4Segment
|
|
segmentDeleteCount int
|
|
parts []*muxerVariantFMP4Part
|
|
partsByName map[string]*muxerVariantFMP4Part
|
|
nextSegmentID uint64
|
|
nextSegmentParts []*muxerVariantFMP4Part
|
|
nextPartID uint64
|
|
}
|
|
|
|
func newMuxerVariantFMP4Playlist(
|
|
lowLatency bool,
|
|
segmentCount int,
|
|
videoTrack *gortsplib.TrackH264,
|
|
audioTrack *gortsplib.TrackMPEG4Audio,
|
|
) *muxerVariantFMP4Playlist {
|
|
p := &muxerVariantFMP4Playlist{
|
|
lowLatency: lowLatency,
|
|
segmentCount: segmentCount,
|
|
videoTrack: videoTrack,
|
|
audioTrack: audioTrack,
|
|
segmentsByName: make(map[string]*muxerVariantFMP4Segment),
|
|
partsByName: make(map[string]*muxerVariantFMP4Part),
|
|
}
|
|
p.cond = sync.NewCond(&p.mutex)
|
|
|
|
return p
|
|
}
|
|
|
|
func (p *muxerVariantFMP4Playlist) close() {
|
|
func() {
|
|
p.mutex.Lock()
|
|
defer p.mutex.Unlock()
|
|
p.closed = true
|
|
}()
|
|
|
|
p.cond.Broadcast()
|
|
}
|
|
|
|
func (p *muxerVariantFMP4Playlist) hasContent() bool {
|
|
if p.lowLatency {
|
|
return len(p.segments) >= 1
|
|
}
|
|
return len(p.segments) >= 2
|
|
}
|
|
|
|
func (p *muxerVariantFMP4Playlist) hasPart(segmentID uint64, partID uint64) bool {
|
|
if !p.hasContent() {
|
|
return false
|
|
}
|
|
|
|
for _, sop := range p.segments {
|
|
seg, ok := sop.(*muxerVariantFMP4Segment)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
if segmentID != seg.id {
|
|
continue
|
|
}
|
|
|
|
// If the Client requests a Part Index greater than that of the final
|
|
// Partial Segment of the Parent Segment, the Server MUST treat the
|
|
// request as one for Part Index 0 of the following Parent Segment.
|
|
if partID >= uint64(len(seg.parts)) {
|
|
segmentID++
|
|
partID = 0
|
|
continue
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
if segmentID != p.nextSegmentID {
|
|
return false
|
|
}
|
|
|
|
if partID >= uint64(len(p.nextSegmentParts)) {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func (p *muxerVariantFMP4Playlist) file(name string, msn string, part string, skip string) *MuxerFileResponse {
|
|
switch {
|
|
case name == "stream.m3u8":
|
|
return p.playlistReader(msn, part, skip)
|
|
|
|
case strings.HasSuffix(name, ".mp4"):
|
|
return p.segmentReader(name)
|
|
|
|
default:
|
|
return &MuxerFileResponse{Status: http.StatusNotFound}
|
|
}
|
|
}
|
|
|
|
func (p *muxerVariantFMP4Playlist) playlistReader(msn string, part string, skip string) *MuxerFileResponse {
|
|
isDeltaUpdate := false
|
|
|
|
if p.lowLatency {
|
|
isDeltaUpdate = skip == "YES" || skip == "v2"
|
|
|
|
var msnint uint64
|
|
if msn != "" {
|
|
var err error
|
|
msnint, err = strconv.ParseUint(msn, 10, 64)
|
|
if err != nil {
|
|
return &MuxerFileResponse{Status: http.StatusBadRequest}
|
|
}
|
|
}
|
|
|
|
var partint uint64
|
|
if part != "" {
|
|
var err error
|
|
partint, err = strconv.ParseUint(part, 10, 64)
|
|
if err != nil {
|
|
return &MuxerFileResponse{Status: http.StatusBadRequest}
|
|
}
|
|
}
|
|
|
|
if msn != "" {
|
|
p.mutex.Lock()
|
|
defer p.mutex.Unlock()
|
|
|
|
// If the _HLS_msn is greater than the Media Sequence Number of the last
|
|
// Media Segment in the current Playlist plus two, or if the _HLS_part
|
|
// exceeds the last Partial Segment in the current Playlist by the
|
|
// Advance Part Limit, then the server SHOULD immediately return Bad
|
|
// Request, such as HTTP 400.
|
|
if msnint > (p.nextSegmentID + 1) {
|
|
return &MuxerFileResponse{Status: http.StatusBadRequest}
|
|
}
|
|
|
|
for !p.closed && !p.hasPart(msnint, partint) {
|
|
p.cond.Wait()
|
|
}
|
|
|
|
if p.closed {
|
|
return &MuxerFileResponse{Status: http.StatusInternalServerError}
|
|
}
|
|
|
|
return &MuxerFileResponse{
|
|
Status: http.StatusOK,
|
|
Header: map[string]string{
|
|
"Content-Type": `application/x-mpegURL`,
|
|
},
|
|
Body: p.fullPlaylist(isDeltaUpdate),
|
|
}
|
|
}
|
|
|
|
// part without msn is not supported.
|
|
if part != "" {
|
|
return &MuxerFileResponse{Status: http.StatusBadRequest}
|
|
}
|
|
}
|
|
|
|
p.mutex.Lock()
|
|
defer p.mutex.Unlock()
|
|
|
|
for !p.closed && !p.hasContent() {
|
|
p.cond.Wait()
|
|
}
|
|
|
|
if p.closed {
|
|
return &MuxerFileResponse{Status: http.StatusInternalServerError}
|
|
}
|
|
|
|
return &MuxerFileResponse{
|
|
Status: http.StatusOK,
|
|
Header: map[string]string{
|
|
"Content-Type": `application/x-mpegURL`,
|
|
},
|
|
Body: p.fullPlaylist(isDeltaUpdate),
|
|
}
|
|
}
|
|
|
|
func (p *muxerVariantFMP4Playlist) fullPlaylist(isDeltaUpdate bool) io.Reader {
|
|
cnt := "#EXTM3U\n"
|
|
cnt += "#EXT-X-VERSION:9\n"
|
|
|
|
targetDuration := targetDuration(p.segments)
|
|
cnt += "#EXT-X-TARGETDURATION:" + strconv.FormatUint(uint64(targetDuration), 10) + "\n"
|
|
|
|
skipBoundary := float64(targetDuration * 6)
|
|
|
|
if p.lowLatency {
|
|
partTargetDuration := partTargetDuration(p.segments, p.nextSegmentParts)
|
|
|
|
// The value is an enumerated-string whose value is YES if the server
|
|
// supports Blocking Playlist Reload
|
|
cnt += "#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES"
|
|
|
|
// The value is a decimal-floating-point number of seconds that
|
|
// indicates the server-recommended minimum distance from the end of
|
|
// the Playlist at which clients should begin to play or to which
|
|
// they should seek when playing in Low-Latency Mode. Its value MUST
|
|
// be at least twice the Part Target Duration. Its value SHOULD be
|
|
// at least three times the Part Target Duration.
|
|
cnt += ",PART-HOLD-BACK=" + strconv.FormatFloat((partTargetDuration).Seconds()*2.5, 'f', 5, 64)
|
|
|
|
// Indicates that the Server can produce Playlist Delta Updates in
|
|
// response to the _HLS_skip Delivery Directive. Its value is the
|
|
// Skip Boundary, a decimal-floating-point number of seconds. The
|
|
// Skip Boundary MUST be at least six times the Target Duration.
|
|
cnt += ",CAN-SKIP-UNTIL=" + strconv.FormatFloat(skipBoundary, 'f', -1, 64)
|
|
|
|
cnt += "\n"
|
|
|
|
cnt += "#EXT-X-PART-INF:PART-TARGET=" + strconv.FormatFloat(partTargetDuration.Seconds(), 'f', -1, 64) + "\n"
|
|
}
|
|
|
|
cnt += "#EXT-X-MEDIA-SEQUENCE:" + strconv.FormatInt(int64(p.segmentDeleteCount), 10) + "\n"
|
|
|
|
skipped := 0
|
|
|
|
if !isDeltaUpdate {
|
|
cnt += "#EXT-X-MAP:URI=\"init.mp4\"\n"
|
|
} else {
|
|
var curDuration time.Duration
|
|
shown := 0
|
|
for _, segment := range p.segments {
|
|
curDuration += segment.getRenderedDuration()
|
|
if curDuration.Seconds() >= skipBoundary {
|
|
break
|
|
}
|
|
shown++
|
|
}
|
|
skipped = len(p.segments) - shown
|
|
cnt += "#EXT-X-SKIP:SKIPPED-SEGMENTS=" + strconv.FormatInt(int64(skipped), 10) + "\n"
|
|
}
|
|
|
|
for i, sog := range p.segments {
|
|
if i < skipped {
|
|
continue
|
|
}
|
|
|
|
switch seg := sog.(type) {
|
|
case *muxerVariantFMP4Segment:
|
|
if (len(p.segments) - i) <= 2 {
|
|
cnt += "#EXT-X-PROGRAM-DATE-TIME:" + seg.startTime.Format("2006-01-02T15:04:05.999Z07:00") + "\n"
|
|
}
|
|
|
|
if p.lowLatency && (len(p.segments)-i) <= 2 {
|
|
for _, part := range seg.parts {
|
|
cnt += "#EXT-X-PART:DURATION=" + strconv.FormatFloat(part.renderedDuration.Seconds(), 'f', 5, 64) +
|
|
",URI=\"" + part.name() + ".mp4\""
|
|
if part.isIndependent {
|
|
cnt += ",INDEPENDENT=YES"
|
|
}
|
|
cnt += "\n"
|
|
}
|
|
}
|
|
|
|
cnt += "#EXTINF:" + strconv.FormatFloat(seg.renderedDuration.Seconds(), 'f', 5, 64) + ",\n" +
|
|
seg.name + ".mp4\n"
|
|
|
|
case *muxerVariantFMP4Gap:
|
|
cnt += "#EXT-X-GAP\n" +
|
|
"#EXTINF:" + strconv.FormatFloat(seg.renderedDuration.Seconds(), 'f', 5, 64) + ",\n" +
|
|
"gap.mp4\n"
|
|
}
|
|
}
|
|
|
|
if p.lowLatency {
|
|
for _, part := range p.nextSegmentParts {
|
|
cnt += "#EXT-X-PART:DURATION=" + strconv.FormatFloat(part.renderedDuration.Seconds(), 'f', 5, 64) +
|
|
",URI=\"" + part.name() + ".mp4\""
|
|
if part.isIndependent {
|
|
cnt += ",INDEPENDENT=YES"
|
|
}
|
|
cnt += "\n"
|
|
}
|
|
|
|
// preload hint must always be present
|
|
// otherwise hls.js goes into a loop
|
|
cnt += "#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"" + fmp4PartName(p.nextPartID) + ".mp4\"\n"
|
|
}
|
|
|
|
return bytes.NewReader([]byte(cnt))
|
|
}
|
|
|
|
func (p *muxerVariantFMP4Playlist) segmentReader(fname string) *MuxerFileResponse {
|
|
switch {
|
|
case strings.HasPrefix(fname, "seg"):
|
|
base := strings.TrimSuffix(fname, ".mp4")
|
|
|
|
p.mutex.Lock()
|
|
segment, ok := p.segmentsByName[base]
|
|
p.mutex.Unlock()
|
|
|
|
if !ok {
|
|
return &MuxerFileResponse{Status: http.StatusNotFound}
|
|
}
|
|
|
|
return &MuxerFileResponse{
|
|
Status: http.StatusOK,
|
|
Header: map[string]string{
|
|
"Content-Type": "video/mp4",
|
|
},
|
|
Body: segment.reader(),
|
|
}
|
|
|
|
case strings.HasPrefix(fname, "part"):
|
|
base := strings.TrimSuffix(fname, ".mp4")
|
|
|
|
p.mutex.Lock()
|
|
part, ok := p.partsByName[base]
|
|
nextPartID := p.nextPartID
|
|
p.mutex.Unlock()
|
|
|
|
if ok {
|
|
return &MuxerFileResponse{
|
|
Status: http.StatusOK,
|
|
Header: map[string]string{
|
|
"Content-Type": "video/mp4",
|
|
},
|
|
Body: part.reader(),
|
|
}
|
|
}
|
|
|
|
if fname == fmp4PartName(p.nextPartID) {
|
|
p.mutex.Lock()
|
|
defer p.mutex.Unlock()
|
|
|
|
for {
|
|
if p.closed {
|
|
break
|
|
}
|
|
|
|
if p.nextPartID > nextPartID {
|
|
break
|
|
}
|
|
|
|
p.cond.Wait()
|
|
}
|
|
|
|
if p.closed {
|
|
return &MuxerFileResponse{Status: http.StatusInternalServerError}
|
|
}
|
|
|
|
return &MuxerFileResponse{
|
|
Status: http.StatusOK,
|
|
Header: map[string]string{
|
|
"Content-Type": "video/mp4",
|
|
},
|
|
Body: p.partsByName[fmp4PartName(nextPartID)].reader(),
|
|
}
|
|
}
|
|
|
|
return &MuxerFileResponse{Status: http.StatusNotFound}
|
|
|
|
default:
|
|
return &MuxerFileResponse{Status: http.StatusNotFound}
|
|
}
|
|
}
|
|
|
|
func (p *muxerVariantFMP4Playlist) onSegmentFinalized(segment *muxerVariantFMP4Segment) {
|
|
func() {
|
|
p.mutex.Lock()
|
|
defer p.mutex.Unlock()
|
|
|
|
// add initial gaps, required by iOS LL-HLS
|
|
if p.lowLatency && len(p.segments) == 0 {
|
|
for i := 0; i < 7; i++ {
|
|
p.segments = append(p.segments, &muxerVariantFMP4Gap{
|
|
renderedDuration: segment.renderedDuration,
|
|
})
|
|
}
|
|
}
|
|
|
|
p.segmentsByName[segment.name] = segment
|
|
p.segments = append(p.segments, segment)
|
|
p.nextSegmentID = segment.id + 1
|
|
p.nextSegmentParts = p.nextSegmentParts[:0]
|
|
|
|
if len(p.segments) > p.segmentCount {
|
|
toDelete := p.segments[0]
|
|
|
|
if toDeleteSeg, ok := toDelete.(*muxerVariantFMP4Segment); ok {
|
|
for _, part := range toDeleteSeg.parts {
|
|
delete(p.partsByName, part.name())
|
|
}
|
|
p.parts = p.parts[len(toDeleteSeg.parts):]
|
|
|
|
delete(p.segmentsByName, toDeleteSeg.name)
|
|
}
|
|
|
|
p.segments = p.segments[1:]
|
|
p.segmentDeleteCount++
|
|
}
|
|
}()
|
|
|
|
p.cond.Broadcast()
|
|
}
|
|
|
|
func (p *muxerVariantFMP4Playlist) onPartFinalized(part *muxerVariantFMP4Part) {
|
|
func() {
|
|
p.mutex.Lock()
|
|
defer p.mutex.Unlock()
|
|
|
|
p.partsByName[part.name()] = part
|
|
p.parts = append(p.parts, part)
|
|
p.nextSegmentParts = append(p.nextSegmentParts, part)
|
|
p.nextPartID = part.id + 1
|
|
}()
|
|
|
|
p.cond.Broadcast()
|
|
}
|