30 changed files with 1411 additions and 345 deletions
@ -0,0 +1,410 @@
@@ -0,0 +1,410 @@
|
||||
package playback |
||||
|
||||
import ( |
||||
"bytes" |
||||
"errors" |
||||
"fmt" |
||||
"io" |
||||
"os" |
||||
"time" |
||||
|
||||
"github.com/abema/go-mp4" |
||||
"github.com/bluenviron/mediacommon/pkg/formats/fmp4" |
||||
"github.com/bluenviron/mediacommon/pkg/formats/fmp4/seekablebuffer" |
||||
) |
||||
|
||||
const ( |
||||
sampleFlagIsNonSyncSample = 1 << 16 |
||||
) |
||||
|
||||
func durationGoToMp4(v time.Duration, timeScale uint32) uint64 { |
||||
timeScale64 := uint64(timeScale) |
||||
secs := v / time.Second |
||||
dec := v % time.Second |
||||
return uint64(secs)*timeScale64 + uint64(dec)*timeScale64/uint64(time.Second) |
||||
} |
||||
|
||||
func durationMp4ToGo(v uint64, timeScale uint32) time.Duration { |
||||
timeScale64 := uint64(timeScale) |
||||
secs := v / timeScale64 |
||||
dec := v % timeScale64 |
||||
return time.Duration(secs)*time.Second + time.Duration(dec)*time.Second/time.Duration(timeScale64) |
||||
} |
||||
|
||||
var errTerminated = errors.New("terminated") |
||||
|
||||
func fmp4ReadInit(r io.ReadSeeker) ([]byte, error) { |
||||
buf := make([]byte, 8) |
||||
_, err := io.ReadFull(r, buf) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if !bytes.Equal(buf[4:], []byte{'f', 't', 'y', 'p'}) { |
||||
return nil, fmt.Errorf("ftyp box not found") |
||||
} |
||||
|
||||
ftypSize := uint32(buf[0])<<24 | uint32(buf[1])<<16 | uint32(buf[2])<<8 | uint32(buf[3]) |
||||
|
||||
_, err = r.Seek(int64(ftypSize), io.SeekStart) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
_, err = io.ReadFull(r, buf) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if !bytes.Equal(buf[4:], []byte{'m', 'o', 'o', 'v'}) { |
||||
return nil, fmt.Errorf("moov box not found") |
||||
} |
||||
|
||||
moovSize := uint32(buf[0])<<24 | uint32(buf[1])<<16 | uint32(buf[2])<<8 | uint32(buf[3]) |
||||
|
||||
_, err = r.Seek(0, io.SeekStart) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
buf = make([]byte, ftypSize+moovSize) |
||||
|
||||
_, err = io.ReadFull(r, buf) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return buf, nil |
||||
} |
||||
|
||||
func seekAndMuxParts( |
||||
r io.ReadSeeker, |
||||
init []byte, |
||||
minTime time.Duration, |
||||
maxTime time.Duration, |
||||
w io.Writer, |
||||
) (time.Duration, error) { |
||||
minTimeMP4 := durationGoToMp4(minTime, 90000) |
||||
maxTimeMP4 := durationGoToMp4(maxTime, 90000) |
||||
moofOffset := uint64(0) |
||||
var tfhd *mp4.Tfhd |
||||
var tfdt *mp4.Tfdt |
||||
var outPart *fmp4.Part |
||||
var outTrack *fmp4.PartTrack |
||||
var outBuf seekablebuffer.Buffer |
||||
elapsed := uint64(0) |
||||
initWritten := false |
||||
firstSampleWritten := make(map[uint32]struct{}) |
||||
gop := make(map[uint32][]*fmp4.PartSample) |
||||
|
||||
_, err := mp4.ReadBoxStructure(r, func(h *mp4.ReadHandle) (interface{}, error) { |
||||
switch h.BoxInfo.Type.String() { |
||||
case "moof": |
||||
moofOffset = h.BoxInfo.Offset |
||||
outPart = &fmp4.Part{} |
||||
return h.Expand() |
||||
|
||||
case "traf": |
||||
return h.Expand() |
||||
|
||||
case "tfhd": |
||||
box, _, err := h.ReadPayload() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
tfhd = box.(*mp4.Tfhd) |
||||
|
||||
case "tfdt": |
||||
box, _, err := h.ReadPayload() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
tfdt = box.(*mp4.Tfdt) |
||||
|
||||
if tfdt.BaseMediaDecodeTimeV1 >= maxTimeMP4 { |
||||
return nil, errTerminated |
||||
} |
||||
|
||||
outTrack = &fmp4.PartTrack{ID: int(tfhd.TrackID)} |
||||
|
||||
case "trun": |
||||
box, _, err := h.ReadPayload() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
trun := box.(*mp4.Trun) |
||||
|
||||
dataOffset := moofOffset + uint64(trun.DataOffset) |
||||
|
||||
_, err = r.Seek(int64(dataOffset), io.SeekStart) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
elapsed = tfdt.BaseMediaDecodeTimeV1 |
||||
baseTimeSet := false |
||||
|
||||
for _, e := range trun.Entries { |
||||
payload := make([]byte, e.SampleSize) |
||||
_, err := io.ReadFull(r, payload) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if elapsed >= maxTimeMP4 { |
||||
break |
||||
} |
||||
|
||||
isRandom := (e.SampleFlags & sampleFlagIsNonSyncSample) == 0 |
||||
_, fsw := firstSampleWritten[tfhd.TrackID] |
||||
|
||||
sa := &fmp4.PartSample{ |
||||
Duration: e.SampleDuration, |
||||
PTSOffset: e.SampleCompositionTimeOffsetV1, |
||||
IsNonSyncSample: !isRandom, |
||||
Payload: payload, |
||||
} |
||||
|
||||
if !fsw { |
||||
if isRandom { |
||||
gop[tfhd.TrackID] = []*fmp4.PartSample{sa} |
||||
} else { |
||||
gop[tfhd.TrackID] = append(gop[tfhd.TrackID], sa) |
||||
} |
||||
} |
||||
|
||||
if elapsed >= minTimeMP4 { |
||||
if !baseTimeSet { |
||||
outTrack.BaseTime = elapsed - minTimeMP4 |
||||
|
||||
if !fsw { |
||||
if !isRandom { |
||||
for _, sa2 := range gop[tfhd.TrackID][:len(gop[tfhd.TrackID])-1] { |
||||
sa2.Duration = 0 |
||||
sa2.PTSOffset = 0 |
||||
outTrack.Samples = append(outTrack.Samples, sa2) |
||||
} |
||||
} |
||||
|
||||
delete(gop, tfhd.TrackID) |
||||
firstSampleWritten[tfhd.TrackID] = struct{}{} |
||||
} |
||||
} |
||||
|
||||
outTrack.Samples = append(outTrack.Samples, sa) |
||||
} |
||||
|
||||
elapsed += uint64(e.SampleDuration) |
||||
} |
||||
|
||||
if outTrack.Samples != nil { |
||||
outPart.Tracks = append(outPart.Tracks, outTrack) |
||||
} |
||||
|
||||
outTrack = nil |
||||
|
||||
case "mdat": |
||||
if outPart.Tracks != nil { |
||||
if !initWritten { |
||||
initWritten = true |
||||
_, err := w.Write(init) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
} |
||||
|
||||
err := outPart.Marshal(&outBuf) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
_, err = w.Write(outBuf.Bytes()) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
outBuf.Reset() |
||||
} |
||||
|
||||
outPart = nil |
||||
} |
||||
return nil, nil |
||||
}) |
||||
if err != nil && !errors.Is(err, errTerminated) { |
||||
return 0, err |
||||
} |
||||
|
||||
if !initWritten { |
||||
return 0, errNoSegmentsFound |
||||
} |
||||
|
||||
elapsed -= minTimeMP4 |
||||
|
||||
return durationMp4ToGo(elapsed, 90000), nil |
||||
} |
||||
|
||||
func muxParts( |
||||
r io.ReadSeeker, |
||||
startTime time.Duration, |
||||
maxTime time.Duration, |
||||
w io.Writer, |
||||
) (time.Duration, error) { |
||||
maxTimeMP4 := durationGoToMp4(maxTime, 90000) |
||||
moofOffset := uint64(0) |
||||
var tfhd *mp4.Tfhd |
||||
var tfdt *mp4.Tfdt |
||||
var outPart *fmp4.Part |
||||
var outTrack *fmp4.PartTrack |
||||
var outBuf seekablebuffer.Buffer |
||||
elapsed := uint64(0) |
||||
|
||||
_, err := mp4.ReadBoxStructure(r, func(h *mp4.ReadHandle) (interface{}, error) { |
||||
switch h.BoxInfo.Type.String() { |
||||
case "moof": |
||||
moofOffset = h.BoxInfo.Offset |
||||
outPart = &fmp4.Part{} |
||||
return h.Expand() |
||||
|
||||
case "traf": |
||||
return h.Expand() |
||||
|
||||
case "tfhd": |
||||
box, _, err := h.ReadPayload() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
tfhd = box.(*mp4.Tfhd) |
||||
|
||||
case "tfdt": |
||||
box, _, err := h.ReadPayload() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
tfdt = box.(*mp4.Tfdt) |
||||
|
||||
if tfdt.BaseMediaDecodeTimeV1 >= maxTimeMP4 { |
||||
return nil, errTerminated |
||||
} |
||||
|
||||
outTrack = &fmp4.PartTrack{ |
||||
ID: int(tfhd.TrackID), |
||||
BaseTime: tfdt.BaseMediaDecodeTimeV1 + durationGoToMp4(startTime, 90000), |
||||
} |
||||
|
||||
case "trun": |
||||
box, _, err := h.ReadPayload() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
trun := box.(*mp4.Trun) |
||||
|
||||
dataOffset := moofOffset + uint64(trun.DataOffset) |
||||
|
||||
_, err = r.Seek(int64(dataOffset), io.SeekStart) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
elapsed = tfdt.BaseMediaDecodeTimeV1 |
||||
|
||||
for _, e := range trun.Entries { |
||||
payload := make([]byte, e.SampleSize) |
||||
_, err := io.ReadFull(r, payload) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if elapsed >= maxTimeMP4 { |
||||
break |
||||
} |
||||
|
||||
isRandom := (e.SampleFlags & sampleFlagIsNonSyncSample) == 0 |
||||
|
||||
sa := &fmp4.PartSample{ |
||||
Duration: e.SampleDuration, |
||||
PTSOffset: e.SampleCompositionTimeOffsetV1, |
||||
IsNonSyncSample: !isRandom, |
||||
Payload: payload, |
||||
} |
||||
|
||||
outTrack.Samples = append(outTrack.Samples, sa) |
||||
|
||||
elapsed += uint64(e.SampleDuration) |
||||
} |
||||
|
||||
if outTrack.Samples != nil { |
||||
outPart.Tracks = append(outPart.Tracks, outTrack) |
||||
} |
||||
|
||||
outTrack = nil |
||||
|
||||
case "mdat": |
||||
if outPart.Tracks != nil { |
||||
err := outPart.Marshal(&outBuf) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
_, err = w.Write(outBuf.Bytes()) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
outBuf.Reset() |
||||
} |
||||
|
||||
outPart = nil |
||||
} |
||||
return nil, nil |
||||
}) |
||||
if err != nil && !errors.Is(err, errTerminated) { |
||||
return 0, err |
||||
} |
||||
|
||||
return durationMp4ToGo(elapsed, 90000), nil |
||||
} |
||||
|
||||
func fmp4SeekAndMux( |
||||
fpath string, |
||||
minTime time.Duration, |
||||
maxTime time.Duration, |
||||
w io.Writer, |
||||
) (time.Duration, error) { |
||||
f, err := os.Open(fpath) |
||||
if err != nil { |
||||
return 0, err |
||||
} |
||||
defer f.Close() |
||||
|
||||
init, err := fmp4ReadInit(f) |
||||
if err != nil { |
||||
return 0, err |
||||
} |
||||
|
||||
elapsed, err := seekAndMuxParts(f, init, minTime, maxTime, w) |
||||
if err != nil { |
||||
return 0, err |
||||
} |
||||
|
||||
return elapsed, nil |
||||
} |
||||
|
||||
func fmp4Mux( |
||||
fpath string, |
||||
startTime time.Duration, |
||||
maxTime time.Duration, |
||||
w io.Writer, |
||||
) (time.Duration, error) { |
||||
f, err := os.Open(fpath) |
||||
if err != nil { |
||||
return 0, err |
||||
} |
||||
defer f.Close() |
||||
|
||||
elapsed, err := muxParts(f, startTime, maxTime, w) |
||||
if err != nil { |
||||
return 0, err |
||||
} |
||||
|
||||
return elapsed, nil |
||||
} |
@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
|
||||
package playback |
||||
|
||||
import ( |
||||
"io" |
||||
"os" |
||||
"testing" |
||||
|
||||
"github.com/bluenviron/mediacommon/pkg/codecs/mpeg4audio" |
||||
"github.com/bluenviron/mediacommon/pkg/formats/fmp4" |
||||
) |
||||
|
||||
func writeBenchInit(f io.WriteSeeker) { |
||||
init := fmp4.Init{ |
||||
Tracks: []*fmp4.InitTrack{ |
||||
{ |
||||
ID: 1, |
||||
TimeScale: 90000, |
||||
Codec: &fmp4.CodecH264{ |
||||
SPS: []byte{ |
||||
0x67, 0x42, 0xc0, 0x28, 0xd9, 0x00, 0x78, 0x02, |
||||
0x27, 0xe5, 0x84, 0x00, 0x00, 0x03, 0x00, 0x04, |
||||
0x00, 0x00, 0x03, 0x00, 0xf0, 0x3c, 0x60, 0xc9, |
||||
0x20, |
||||
}, |
||||
PPS: []byte{0x08}, |
||||
}, |
||||
}, |
||||
{ |
||||
ID: 2, |
||||
TimeScale: 90000, |
||||
Codec: &fmp4.CodecMPEG4Audio{ |
||||
Config: mpeg4audio.Config{ |
||||
Type: mpeg4audio.ObjectTypeAACLC, |
||||
SampleRate: 48000, |
||||
ChannelCount: 2, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
} |
||||
|
||||
err := init.Marshal(f) |
||||
if err != nil { |
||||
panic(err) |
||||
} |
||||
|
||||
_, err = f.Write([]byte{ |
||||
'm', 'o', 'o', 'f', 0x00, 0x00, 0x00, 0x10, |
||||
}) |
||||
if err != nil { |
||||
panic(err) |
||||
} |
||||
} |
||||
|
||||
func BenchmarkFMP4ReadInit(b *testing.B) { |
||||
f, err := os.CreateTemp(os.TempDir(), "mediamtx-playback-fmp4-") |
||||
if err != nil { |
||||
panic(err) |
||||
} |
||||
defer os.Remove(f.Name()) |
||||
|
||||
writeBenchInit(f) |
||||
f.Close() |
||||
|
||||
for n := 0; n < b.N; n++ { |
||||
func() { |
||||
f, err := os.Open(f.Name()) |
||||
if err != nil { |
||||
panic(err) |
||||
} |
||||
defer f.Close() |
||||
|
||||
_, err = fmp4ReadInit(f) |
||||
if err != nil { |
||||
panic(err) |
||||
} |
||||
}() |
||||
} |
||||
} |
@ -0,0 +1,299 @@
@@ -0,0 +1,299 @@
|
||||
// Package playback contains the playback server.
|
||||
package playback |
||||
|
||||
import ( |
||||
"errors" |
||||
"fmt" |
||||
"io/fs" |
||||
"net" |
||||
"net/http" |
||||
"path/filepath" |
||||
"sort" |
||||
"strings" |
||||
"sync" |
||||
"time" |
||||
|
||||
"github.com/bluenviron/mediamtx/internal/conf" |
||||
"github.com/bluenviron/mediamtx/internal/logger" |
||||
"github.com/bluenviron/mediamtx/internal/protocols/httpserv" |
||||
"github.com/bluenviron/mediamtx/internal/record" |
||||
"github.com/bluenviron/mediamtx/internal/restrictnetwork" |
||||
"github.com/gin-gonic/gin" |
||||
) |
||||
|
||||
const ( |
||||
concatenationTolerance = 1 * time.Second |
||||
) |
||||
|
||||
var errNoSegmentsFound = errors.New("no recording segments found for the given timestamp") |
||||
|
||||
type writerWrapper struct { |
||||
ctx *gin.Context |
||||
written bool |
||||
} |
||||
|
||||
func (w *writerWrapper) Write(p []byte) (int, error) { |
||||
if !w.written { |
||||
w.written = true |
||||
w.ctx.Header("Accept-Ranges", "none") |
||||
w.ctx.Header("Content-Type", "video/mp4") |
||||
} |
||||
return w.ctx.Writer.Write(p) |
||||
} |
||||
|
||||
type segment struct { |
||||
fpath string |
||||
start time.Time |
||||
} |
||||
|
||||
func findSegments( |
||||
pathConf *conf.Path, |
||||
pathName string, |
||||
start time.Time, |
||||
duration time.Duration, |
||||
) ([]segment, error) { |
||||
if !pathConf.Playback { |
||||
return nil, fmt.Errorf("playback is disabled on path '%s'", pathName) |
||||
} |
||||
|
||||
recordPath := record.PathAddExtension( |
||||
strings.ReplaceAll(pathConf.RecordPath, "%path", pathName), |
||||
pathConf.RecordFormat, |
||||
) |
||||
|
||||
// we have to convert to absolute paths
|
||||
// otherwise, recordPath and fpath inside Walk() won't have common elements
|
||||
recordPath, _ = filepath.Abs(recordPath) |
||||
|
||||
commonPath := record.CommonPath(recordPath) |
||||
end := start.Add(duration) |
||||
var segments []segment |
||||
|
||||
// gather all segments that starts before the end of the playback
|
||||
err := filepath.Walk(commonPath, func(fpath string, info fs.FileInfo, err error) error { |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if !info.IsDir() { |
||||
var pa record.Path |
||||
ok := pa.Decode(recordPath, fpath) |
||||
if ok && !end.Before(time.Time(pa)) { |
||||
segments = append(segments, segment{ |
||||
fpath: fpath, |
||||
start: time.Time(pa), |
||||
}) |
||||
} |
||||
} |
||||
|
||||
return nil |
||||
}) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if segments == nil { |
||||
return nil, errNoSegmentsFound |
||||
} |
||||
|
||||
sort.Slice(segments, func(i, j int) bool { |
||||
return segments[i].start.Before(segments[j].start) |
||||
}) |
||||
|
||||
// find the segment that may contain the start of the playback and remove all previous ones
|
||||
found := false |
||||
for i := 0; i < len(segments)-1; i++ { |
||||
if !start.Before(segments[i].start) && start.Before(segments[i+1].start) { |
||||
segments = segments[i:] |
||||
found = true |
||||
break |
||||
} |
||||
} |
||||
|
||||
// otherwise, keep the last segment only and check whether it may contain the start of the playback
|
||||
if !found { |
||||
segments = segments[len(segments)-1:] |
||||
if segments[len(segments)-1].start.After(start) { |
||||
return nil, errNoSegmentsFound |
||||
} |
||||
} |
||||
|
||||
return segments, nil |
||||
} |
||||
|
||||
// Server is the playback server.
|
||||
type Server struct { |
||||
Address string |
||||
ReadTimeout conf.StringDuration |
||||
PathConfs map[string]*conf.Path |
||||
Parent logger.Writer |
||||
|
||||
httpServer *httpserv.WrappedServer |
||||
mutex sync.RWMutex |
||||
} |
||||
|
||||
// Initialize initializes API.
|
||||
func (p *Server) Initialize() error { |
||||
router := gin.New() |
||||
router.SetTrustedProxies(nil) //nolint:errcheck
|
||||
|
||||
group := router.Group("/") |
||||
|
||||
group.GET("/get", p.onGet) |
||||
|
||||
network, address := restrictnetwork.Restrict("tcp", p.Address) |
||||
|
||||
var err error |
||||
p.httpServer, err = httpserv.NewWrappedServer( |
||||
network, |
||||
address, |
||||
time.Duration(p.ReadTimeout), |
||||
"", |
||||
"", |
||||
router, |
||||
p, |
||||
) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
p.Log(logger.Info, "listener opened on "+address) |
||||
|
||||
return nil |
||||
} |
||||
|
||||
// Close closes Server.
|
||||
func (p *Server) Close() { |
||||
p.Log(logger.Info, "listener is closing") |
||||
p.httpServer.Close() |
||||
} |
||||
|
||||
// Log implements logger.Writer.
|
||||
func (p *Server) Log(level logger.Level, format string, args ...interface{}) { |
||||
p.Parent.Log(level, "[playback] "+format, args...) |
||||
} |
||||
|
||||
// ReloadPathConfs is called by core.Core.
|
||||
func (p *Server) ReloadPathConfs(pathConfs map[string]*conf.Path) { |
||||
p.mutex.Lock() |
||||
defer p.mutex.Unlock() |
||||
p.PathConfs = pathConfs |
||||
} |
||||
|
||||
func (p *Server) writeError(ctx *gin.Context, status int, err error) { |
||||
// show error in logs
|
||||
p.Log(logger.Error, err.Error()) |
||||
|
||||
// add error to response
|
||||
ctx.String(status, err.Error()) |
||||
} |
||||
|
||||
func (p *Server) safeFindPathConf(name string) (*conf.Path, error) { |
||||
p.mutex.RLock() |
||||
defer p.mutex.RUnlock() |
||||
|
||||
_, pathConf, _, err := conf.FindPathConf(p.PathConfs, name) |
||||
return pathConf, err |
||||
} |
||||
|
||||
func (p *Server) onGet(ctx *gin.Context) { |
||||
pathName := ctx.Query("path") |
||||
|
||||
start, err := time.Parse(time.RFC3339, ctx.Query("start")) |
||||
if err != nil { |
||||
p.writeError(ctx, http.StatusBadRequest, fmt.Errorf("invalid start: %w", err)) |
||||
return |
||||
} |
||||
|
||||
duration, err := time.ParseDuration(ctx.Query("duration")) |
||||
if err != nil { |
||||
p.writeError(ctx, http.StatusBadRequest, fmt.Errorf("invalid duration: %w", err)) |
||||
return |
||||
} |
||||
|
||||
format := ctx.Query("format") |
||||
if format != "fmp4" { |
||||
p.writeError(ctx, http.StatusBadRequest, fmt.Errorf("invalid format: %s", format)) |
||||
return |
||||
} |
||||
|
||||
pathConf, err := p.safeFindPathConf(pathName) |
||||
if err != nil { |
||||
p.writeError(ctx, http.StatusBadRequest, err) |
||||
return |
||||
} |
||||
|
||||
segments, err := findSegments(pathConf, pathName, start, duration) |
||||
if err != nil { |
||||
if errors.Is(err, errNoSegmentsFound) { |
||||
p.writeError(ctx, http.StatusNotFound, err) |
||||
} else { |
||||
p.writeError(ctx, http.StatusBadRequest, err) |
||||
} |
||||
return |
||||
} |
||||
|
||||
if pathConf.RecordFormat != conf.RecordFormatFMP4 { |
||||
p.writeError(ctx, http.StatusBadRequest, fmt.Errorf("format of recording segments is not fmp4")) |
||||
return |
||||
} |
||||
|
||||
ww := &writerWrapper{ctx: ctx} |
||||
minTime := start.Sub(segments[0].start) |
||||
maxTime := minTime + duration |
||||
|
||||
elapsed, err := fmp4SeekAndMux( |
||||
segments[0].fpath, |
||||
minTime, |
||||
maxTime, |
||||
ww) |
||||
if err != nil { |
||||
// user aborted the download
|
||||
var neterr *net.OpError |
||||
if errors.As(err, &neterr) { |
||||
return |
||||
} |
||||
|
||||
// nothing has been written yet; send back JSON
|
||||
if !ww.written { |
||||
if errors.Is(err, errNoSegmentsFound) { |
||||
p.writeError(ctx, http.StatusNotFound, err) |
||||
} else { |
||||
p.writeError(ctx, http.StatusBadRequest, err) |
||||
} |
||||
return |
||||
} |
||||
|
||||
// something has been already written: abort and write to logs only
|
||||
p.Log(logger.Error, err.Error()) |
||||
return |
||||
} |
||||
|
||||
start = start.Add(elapsed) |
||||
duration -= elapsed |
||||
overallElapsed := elapsed |
||||
|
||||
for _, seg := range segments[1:] { |
||||
// there's a gap between segments; stop serving the recording.
|
||||
if seg.start.Before(start.Add(-concatenationTolerance)) || seg.start.After(start.Add(concatenationTolerance)) { |
||||
return |
||||
} |
||||
|
||||
elapsed, err := fmp4Mux(seg.fpath, overallElapsed, duration, ctx.Writer) |
||||
if err != nil { |
||||
// user aborted the download
|
||||
var neterr *net.OpError |
||||
if errors.As(err, &neterr) { |
||||
return |
||||
} |
||||
|
||||
// something has been already written: abort and write to logs only
|
||||
p.Log(logger.Error, err.Error()) |
||||
return |
||||
} |
||||
|
||||
start = seg.start.Add(elapsed) |
||||
duration -= elapsed |
||||
overallElapsed += elapsed |
||||
} |
||||
} |
@ -0,0 +1,228 @@
@@ -0,0 +1,228 @@
|
||||
package playback |
||||
|
||||
import ( |
||||
"io" |
||||
"net/http" |
||||
"net/url" |
||||
"os" |
||||
"path/filepath" |
||||
"testing" |
||||
"time" |
||||
|
||||
"github.com/bluenviron/mediacommon/pkg/formats/fmp4" |
||||
"github.com/bluenviron/mediacommon/pkg/formats/fmp4/seekablebuffer" |
||||
"github.com/bluenviron/mediamtx/internal/conf" |
||||
"github.com/bluenviron/mediamtx/internal/logger" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
type nilLogger struct{} |
||||
|
||||
func (nilLogger) Log(_ logger.Level, _ string, _ ...interface{}) { |
||||
} |
||||
|
||||
func writeSegment1(t *testing.T, fpath string) { |
||||
init := fmp4.Init{ |
||||
Tracks: []*fmp4.InitTrack{{ |
||||
ID: 1, |
||||
TimeScale: 90000, |
||||
Codec: &fmp4.CodecH264{ |
||||
SPS: []byte{ |
||||
0x67, 0x42, 0xc0, 0x28, 0xd9, 0x00, 0x78, 0x02, |
||||
0x27, 0xe5, 0x84, 0x00, 0x00, 0x03, 0x00, 0x04, |
||||
0x00, 0x00, 0x03, 0x00, 0xf0, 0x3c, 0x60, 0xc9, |
||||
0x20, |
||||
}, |
||||
PPS: []byte{0x08}, |
||||
}, |
||||
}}, |
||||
} |
||||
|
||||
var buf1 seekablebuffer.Buffer |
||||
err := init.Marshal(&buf1) |
||||
require.NoError(t, err) |
||||
|
||||
var buf2 seekablebuffer.Buffer |
||||
parts := fmp4.Parts{ |
||||
{ |
||||
SequenceNumber: 1, |
||||
Tracks: []*fmp4.PartTrack{{ |
||||
ID: 1, |
||||
BaseTime: 0, |
||||
Samples: []*fmp4.PartSample{}, |
||||
}}, |
||||
}, |
||||
{ |
||||
SequenceNumber: 1, |
||||
Tracks: []*fmp4.PartTrack{{ |
||||
ID: 1, |
||||
BaseTime: 30 * 90000, |
||||
Samples: []*fmp4.PartSample{ |
||||
{ |
||||
Duration: 30 * 90000, |
||||
IsNonSyncSample: false, |
||||
Payload: []byte{1, 2}, |
||||
}, |
||||
{ |
||||
Duration: 1 * 90000, |
||||
IsNonSyncSample: false, |
||||
Payload: []byte{3, 4}, |
||||
}, |
||||
{ |
||||
Duration: 1 * 90000, |
||||
IsNonSyncSample: true, |
||||
Payload: []byte{5, 6}, |
||||
}, |
||||
}, |
||||
}}, |
||||
}, |
||||
} |
||||
err = parts.Marshal(&buf2) |
||||
require.NoError(t, err) |
||||
|
||||
err = os.WriteFile(fpath, append(buf1.Bytes(), buf2.Bytes()...), 0o644) |
||||
require.NoError(t, err) |
||||
} |
||||
|
||||
func writeSegment2(t *testing.T, fpath string) { |
||||
init := fmp4.Init{ |
||||
Tracks: []*fmp4.InitTrack{{ |
||||
ID: 1, |
||||
TimeScale: 90000, |
||||
Codec: &fmp4.CodecH264{ |
||||
SPS: []byte{ |
||||
0x67, 0x42, 0xc0, 0x28, 0xd9, 0x00, 0x78, 0x02, |
||||
0x27, 0xe5, 0x84, 0x00, 0x00, 0x03, 0x00, 0x04, |
||||
0x00, 0x00, 0x03, 0x00, 0xf0, 0x3c, 0x60, 0xc9, |
||||
0x20, |
||||
}, |
||||
PPS: []byte{0x08}, |
||||
}, |
||||
}}, |
||||
} |
||||
|
||||
var buf1 seekablebuffer.Buffer |
||||
err := init.Marshal(&buf1) |
||||
require.NoError(t, err) |
||||
|
||||
var buf2 seekablebuffer.Buffer |
||||
parts := fmp4.Parts{ |
||||
{ |
||||
SequenceNumber: 1, |
||||
Tracks: []*fmp4.PartTrack{{ |
||||
ID: 1, |
||||
BaseTime: 0, |
||||
Samples: []*fmp4.PartSample{ |
||||
{ |
||||
Duration: 1 * 90000, |
||||
IsNonSyncSample: false, |
||||
Payload: []byte{7, 8}, |
||||
}, |
||||
{ |
||||
Duration: 1 * 90000, |
||||
IsNonSyncSample: false, |
||||
Payload: []byte{9, 10}, |
||||
}, |
||||
}, |
||||
}}, |
||||
}, |
||||
} |
||||
err = parts.Marshal(&buf2) |
||||
require.NoError(t, err) |
||||
|
||||
err = os.WriteFile(fpath, append(buf1.Bytes(), buf2.Bytes()...), 0o644) |
||||
require.NoError(t, err) |
||||
} |
||||
|
||||
func TestServer(t *testing.T) { |
||||
dir, err := os.MkdirTemp("", "mediamtx-playback") |
||||
require.NoError(t, err) |
||||
defer os.RemoveAll(dir) |
||||
|
||||
err = os.Mkdir(filepath.Join(dir, "mypath"), 0o755) |
||||
require.NoError(t, err) |
||||
|
||||
writeSegment1(t, filepath.Join(dir, "mypath", "2008-11-07_11-22-00-000000.mp4")) |
||||
writeSegment2(t, filepath.Join(dir, "mypath", "2008-11-07_11-23-02-000000.mp4")) |
||||
|
||||
s := &Server{ |
||||
Address: "127.0.0.1:9996", |
||||
ReadTimeout: conf.StringDuration(10 * time.Second), |
||||
PathConfs: map[string]*conf.Path{ |
||||
"mypath": { |
||||
Playback: true, |
||||
RecordPath: filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f"), |
||||
}, |
||||
}, |
||||
Parent: &nilLogger{}, |
||||
} |
||||
err = s.Initialize() |
||||
require.NoError(t, err) |
||||
defer s.Close() |
||||
|
||||
v := url.Values{} |
||||
v.Set("path", "mypath") |
||||
v.Set("start", time.Date(2008, 11, 0o7, 11, 23, 1, 0, time.Local).Format(time.RFC3339)) |
||||
v.Set("duration", "2s") |
||||
v.Set("format", "fmp4") |
||||
|
||||
u := &url.URL{ |
||||
Scheme: "http", |
||||
Host: "localhost:9996", |
||||
Path: "/get", |
||||
RawQuery: v.Encode(), |
||||
} |
||||
|
||||
req, err := http.NewRequest(http.MethodGet, u.String(), nil) |
||||
require.NoError(t, err) |
||||
|
||||
res, err := http.DefaultClient.Do(req) |
||||
require.NoError(t, err) |
||||
defer res.Body.Close() |
||||
|
||||
require.Equal(t, http.StatusOK, res.StatusCode) |
||||
|
||||
buf, err := io.ReadAll(res.Body) |
||||
require.NoError(t, err) |
||||
|
||||
var parts fmp4.Parts |
||||
err = parts.Unmarshal(buf) |
||||
require.NoError(t, err) |
||||
|
||||
require.Equal(t, fmp4.Parts{ |
||||
{ |
||||
SequenceNumber: 0, |
||||
Tracks: []*fmp4.PartTrack{ |
||||
{ |
||||
ID: 1, |
||||
Samples: []*fmp4.PartSample{ |
||||
{ |
||||
Duration: 0, |
||||
Payload: []byte{3, 4}, |
||||
}, |
||||
{ |
||||
Duration: 90000, |
||||
IsNonSyncSample: true, |
||||
Payload: []byte{5, 6}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
{ |
||||
SequenceNumber: 0, |
||||
Tracks: []*fmp4.PartTrack{ |
||||
{ |
||||
ID: 1, |
||||
BaseTime: 90000, |
||||
Samples: []*fmp4.PartSample{ |
||||
{ |
||||
Duration: 90000, |
||||
Payload: []byte{7, 8}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, parts) |
||||
} |
Loading…
Reference in new issue