playback: allow filtering timespans by start and end date (#3637) (#3489) (#4085)

This commit is contained in:
Alessandro Ros 2025-01-02 12:43:18 +01:00 committed by GitHub
parent f21604399c
commit d38b7e95fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 222 additions and 152 deletions

View File

@ -1578,10 +1578,16 @@ playbackAddress: :9996
The server provides an endpoint to list recorded timespans:
```
http://localhost:9996/list?path=[mypath]
http://localhost:9996/list?path=[mypath]&start=[start]&end=[end]
```
Where [mypath] is the name of a path. The server will return a list of timespans in JSON format:
Where:
* [mypath] is the name of a path
* [start] (optional) is the start date in [RFC3339 format](https://www.utctime.net/)
* [end] (optional) is the end date in [RFC3339 format](https://www.utctime.net/)
The server will return a list of timespans in JSON format:
```json
[
@ -1601,13 +1607,13 @@ Where [mypath] is the name of a path. The server will return a list of timespans
The server provides an endpoint to download recordings:
```
http://localhost:9996/get?path=[mypath]&start=[start_date]&duration=[duration]&format=[format]
http://localhost:9996/get?path=[mypath]&start=[start]&duration=[duration]&format=[format]
```
Where:
* [mypath] is the path name
* [start_date] is the start date in [RFC3339 format](https://www.utctime.net/)
* [start] is the start date in [RFC3339 format](https://www.utctime.net/)
* [duration] is the maximum duration of the recording in seconds
* [format] (optional) is the output format of the stream. Available values are "fmp4" (default) and "mp4"

View File

@ -64,7 +64,7 @@ func recordingsOfPath(
Name: pathName,
}
segments, _ := recordstore.FindSegments(pathConf, pathName)
segments, _ := recordstore.FindSegments(pathConf, pathName, nil, nil)
ret.Segments = make([]*defs.APIRecordingSegment, len(segments))

View File

@ -153,7 +153,8 @@ func (s *Server) onGet(ctx *gin.Context) {
return
}
segments, err := recordstore.FindSegmentsInTimespan(pathConf, pathName, start, duration)
end := start.Add(duration)
segments, err := recordstore.FindSegments(pathConf, pathName, &start, &end)
if err != nil {
if errors.Is(err, recordstore.ErrNoSegmentsFound) {
s.writeError(ctx, http.StatusNotFound, err)

View File

@ -55,7 +55,7 @@ func computeDurationAndConcatenate(
return err
}
maxDuration, err := segmentFMP4ReadMaxDuration(f, init)
maxDuration, err := segmentFMP4ReadDuration(f, init)
if err != nil {
return err
}
@ -103,7 +103,31 @@ func (s *Server) onList(ctx *gin.Context) {
return
}
segments, err := recordstore.FindSegments(pathConf, pathName)
var start *time.Time
rawStart := ctx.Query("start")
if rawStart != "" {
var tmp time.Time
tmp, err = time.Parse(time.RFC3339, rawStart)
if err != nil {
s.writeError(ctx, http.StatusBadRequest, fmt.Errorf("invalid start: %w", err))
return
}
start = &tmp
}
var end *time.Time
rawEnd := ctx.Query("end")
if rawEnd != "" {
var tmp time.Time
tmp, err = time.Parse(time.RFC3339, rawEnd)
if err != nil {
s.writeError(ctx, http.StatusBadRequest, fmt.Errorf("invalid end: %w", err))
return
}
end = &tmp
}
segments, err := recordstore.FindSegments(pathConf, pathName, start, end)
if err != nil {
if errors.Is(err, recordstore.ErrNoSegmentsFound) {
s.writeError(ctx, http.StatusNotFound, err)
@ -119,6 +143,21 @@ func (s *Server) onList(ctx *gin.Context) {
return
}
if start != nil {
firstEntry := entries[0]
if firstEntry.Start.Before(*start) {
entries[0].Duration -= listEntryDuration(start.Sub(firstEntry.Start))
entries[0].Start = *start
}
}
if end != nil {
lastEntry := entries[len(entries)-1]
if lastEntry.Start.Add(time.Duration(lastEntry.Duration)).After(*end) {
entries[len(entries)-1].Duration = listEntryDuration(end.Sub(lastEntry.Start))
}
}
var scheme string
if s.Encryption {
scheme = "https"

View File

@ -14,7 +14,7 @@ import (
"github.com/stretchr/testify/require"
)
func TestOnList(t *testing.T) {
func TestOnListUnfiltered(t *testing.T) {
dir, err := os.MkdirTemp("", "mediamtx-playback")
require.NoError(t, err)
defer os.RemoveAll(dir)
@ -78,6 +78,72 @@ func TestOnList(t *testing.T) {
}, out)
}
func TestOnListFiltered(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-500000.mp4"))
writeSegment2(t, filepath.Join(dir, "mypath", "2008-11-07_11-23-02-500000.mp4"))
writeSegment2(t, filepath.Join(dir, "mypath", "2009-11-07_11-23-02-500000.mp4"))
s := &Server{
Address: "127.0.0.1:9996",
ReadTimeout: conf.StringDuration(10 * time.Second),
PathConfs: map[string]*conf.Path{
"mypath": {
Name: "mypath",
RecordPath: filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f"),
},
},
AuthManager: test.NilAuthManager,
Parent: test.NilLogger,
}
err = s.Initialize()
require.NoError(t, err)
defer s.Close()
u, err := url.Parse("http://myuser:mypass@localhost:9996/list?start=")
require.NoError(t, err)
v := url.Values{}
v.Set("path", "mypath")
v.Set("start", time.Date(2008, 11, 0o7, 11, 22, 1, 500000000, time.Local).Format(time.RFC3339Nano))
v.Set("end", time.Date(2009, 11, 0o7, 11, 23, 4, 500000000, time.Local).Format(time.RFC3339Nano))
u.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)
var out interface{}
err = json.NewDecoder(res.Body).Decode(&out)
require.NoError(t, err)
require.Equal(t, []interface{}{
map[string]interface{}{
"duration": float64(64),
"start": time.Date(2008, 11, 0o7, 11, 22, 1, 500000000, time.Local).Format(time.RFC3339Nano),
"url": "http://localhost:9996/get?duration=64&path=mypath&start=" +
url.QueryEscape(time.Date(2008, 11, 0o7, 11, 22, 1, 500000000, time.Local).Format(time.RFC3339Nano)),
},
map[string]interface{}{
"duration": float64(2),
"start": time.Date(2009, 11, 0o7, 11, 23, 2, 500000000, time.Local).Format(time.RFC3339Nano),
"url": "http://localhost:9996/get?duration=2&path=mypath&start=" +
url.QueryEscape(time.Date(2009, 11, 0o7, 11, 23, 2, 500000000, time.Local).Format(time.RFC3339Nano)),
},
}, out)
}
func TestOnListDifferentInit(t *testing.T) {
dir, err := os.MkdirTemp("", "mediamtx-playback")
require.NoError(t, err)

View File

@ -114,7 +114,7 @@ func segmentFMP4ReadInit(r io.ReadSeeker) (*fmp4.Init, error) {
return &init, nil
}
func segmentFMP4ReadMaxDuration(
func segmentFMP4ReadDuration(
r io.ReadSeeker,
init *fmp4.Init,
) (time.Duration, error) {

View File

@ -118,16 +118,15 @@ func (c *Cleaner) processPath(now time.Time, pathName string) error {
return nil
}
segments, err := recordstore.FindSegments(pathConf, pathName)
end := now.Add(-time.Duration(pathConf.RecordDeleteAfter))
segments, err := recordstore.FindSegments(pathConf, pathName, nil, &end)
if err != nil {
return err
}
for _, seg := range segments {
if now.Sub(seg.Start) > time.Duration(pathConf.RecordDeleteAfter) {
c.Log(logger.Debug, "removing %s", seg.Fpath)
os.Remove(seg.Fpath)
}
c.Log(logger.Debug, "removing %s", seg.Fpath)
os.Remove(seg.Fpath)
}
return nil

View File

@ -89,7 +89,7 @@ func regexpPathFindPathsWithSegments(pathConf *conf.Path) map[string]struct{} {
return ret
}
// FindAllPathsWithSegments returns all paths that do have segments.
// FindAllPathsWithSegments returns all paths that have at least one segment.
func FindAllPathsWithSegments(pathConfs map[string]*conf.Path) []string {
pathNames := make(map[string]struct{})
@ -117,9 +117,12 @@ func FindAllPathsWithSegments(pathConfs map[string]*conf.Path) []string {
}
// FindSegments returns all segments of a path.
// Segments can be filtered by start date and end date.
func FindSegments(
pathConf *conf.Path,
pathName string,
start *time.Time,
end *time.Time,
) ([]*Segment, error) {
recordPath := PathAddExtension(
strings.ReplaceAll(pathConf.RecordPath, "%path", pathName),
@ -133,59 +136,6 @@ func FindSegments(
commonPath := CommonPath(recordPath)
var segments []*Segment
err := filepath.Walk(commonPath, func(fpath string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
var pa Path
ok := pa.Decode(recordPath, fpath)
if ok {
segments = append(segments, &Segment{
Fpath: fpath,
Start: pa.Start,
})
}
}
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)
})
return segments, nil
}
// FindSegmentsInTimespan returns all segments in a certain timestamp.
func FindSegmentsInTimespan(
pathConf *conf.Path,
pathName string,
start time.Time,
duration time.Duration,
) ([]*Segment, error) {
recordPath := 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 := CommonPath(recordPath)
end := start.Add(duration)
var segments []*Segment
err := filepath.Walk(commonPath, func(fpath string, info fs.FileInfo, err error) error {
if err != nil {
return err
@ -196,7 +146,7 @@ func FindSegmentsInTimespan(
ok := pa.Decode(recordPath, fpath)
// gather all segments that starts before the end of the playback
if ok && !end.Before(pa.Start) {
if ok && (end == nil || !end.Before(pa.Start)) {
segments = append(segments, &Segment{
Fpath: fpath,
Start: pa.Start,
@ -218,21 +168,23 @@ func FindSegmentsInTimespan(
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
if start != nil {
// 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 if 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
// otherwise, keep the last segment only and check if 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
}
}
}

View File

@ -45,79 +45,86 @@ func TestFindAllPathsWithSegments(t *testing.T) {
}
func TestFindSegments(t *testing.T) {
dir, err := os.MkdirTemp("", "mediamtx-recordstore")
require.NoError(t, err)
defer os.RemoveAll(dir)
t.Run("no filtering", func(t *testing.T) {
dir, err := os.MkdirTemp("", "mediamtx-recordstore")
require.NoError(t, err)
defer os.RemoveAll(dir)
err = os.Mkdir(filepath.Join(dir, "path1"), 0o755)
require.NoError(t, err)
err = os.Mkdir(filepath.Join(dir, "path1"), 0o755)
require.NoError(t, err)
err = os.Mkdir(filepath.Join(dir, "path2"), 0o755)
require.NoError(t, err)
err = os.Mkdir(filepath.Join(dir, "path2"), 0o755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "path1", "2015-05-19_22-15-25-000427.mp4"), []byte{1}, 0o644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "path1", "2015-05-19_22-15-25-000427.mp4"), []byte{1}, 0o644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "path1", "2016-05-19_22-15-25-000427.mp4"), []byte{1}, 0o644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "path1", "2016-05-19_22-15-25-000427.mp4"), []byte{1}, 0o644)
require.NoError(t, err)
segments, err := FindSegments(
&conf.Path{
Name: "~^.*$",
Regexp: regexp.MustCompile("^.*$"),
RecordPath: filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f"),
RecordFormat: conf.RecordFormatFMP4,
},
"path1",
)
require.NoError(t, err)
segments, err := FindSegments(
&conf.Path{
Name: "~^.*$",
Regexp: regexp.MustCompile("^.*$"),
RecordPath: filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f"),
RecordFormat: conf.RecordFormatFMP4,
},
"path1",
nil,
nil,
)
require.NoError(t, err)
require.Equal(t, []*Segment{
{
Fpath: filepath.Join(dir, "path1", "2015-05-19_22-15-25-000427.mp4"),
Start: time.Date(2015, 5, 19, 22, 15, 25, 427000, time.Local),
},
{
Fpath: filepath.Join(dir, "path1", "2016-05-19_22-15-25-000427.mp4"),
Start: time.Date(2016, 5, 19, 22, 15, 25, 427000, time.Local),
},
}, segments)
}
func TestFindSegmentsInTimespan(t *testing.T) {
dir, err := os.MkdirTemp("", "mediamtx-recordstore")
require.NoError(t, err)
defer os.RemoveAll(dir)
err = os.Mkdir(filepath.Join(dir, "path1"), 0o755)
require.NoError(t, err)
err = os.Mkdir(filepath.Join(dir, "path2"), 0o755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "path1", "2015-05-19_22-15-25-000427.mp4"), []byte{1}, 0o644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "path1", "2016-05-19_22-15-25-000427.mp4"), []byte{1}, 0o644)
require.NoError(t, err)
segments, err := FindSegmentsInTimespan(
&conf.Path{
Name: "~^.*$",
Regexp: regexp.MustCompile("^.*$"),
RecordPath: filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f"),
RecordFormat: conf.RecordFormatFMP4,
},
"path1",
time.Date(2015, 5, 19, 22, 18, 25, 427000, time.Local),
60*time.Minute,
)
require.NoError(t, err)
require.Equal(t, []*Segment{
{
Fpath: filepath.Join(dir, "path1", "2015-05-19_22-15-25-000427.mp4"),
Start: time.Date(2015, 5, 19, 22, 15, 25, 427000, time.Local),
},
}, segments)
require.Equal(t, []*Segment{
{
Fpath: filepath.Join(dir, "path1", "2015-05-19_22-15-25-000427.mp4"),
Start: time.Date(2015, 5, 19, 22, 15, 25, 427000, time.Local),
},
{
Fpath: filepath.Join(dir, "path1", "2016-05-19_22-15-25-000427.mp4"),
Start: time.Date(2016, 5, 19, 22, 15, 25, 427000, time.Local),
},
}, segments)
})
t.Run("filtering", func(t *testing.T) {
dir, err := os.MkdirTemp("", "mediamtx-recordstore")
require.NoError(t, err)
defer os.RemoveAll(dir)
err = os.Mkdir(filepath.Join(dir, "path1"), 0o755)
require.NoError(t, err)
err = os.Mkdir(filepath.Join(dir, "path2"), 0o755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "path1", "2015-05-19_22-15-25-000427.mp4"), []byte{1}, 0o644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "path1", "2016-05-19_22-15-25-000427.mp4"), []byte{1}, 0o644)
require.NoError(t, err)
start := time.Date(2015, 5, 19, 22, 18, 25, 427000, time.Local)
end := start.Add(60 * time.Minute)
segments, err := FindSegments(
&conf.Path{
Name: "~^.*$",
Regexp: regexp.MustCompile("^.*$"),
RecordPath: filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f"),
RecordFormat: conf.RecordFormatFMP4,
},
"path1",
&start,
&end,
)
require.NoError(t, err)
require.Equal(t, []*Segment{
{
Fpath: filepath.Join(dir, "path1", "2015-05-19_22-15-25-000427.mp4"),
Start: time.Date(2015, 5, 19, 22, 15, 25, 427000, time.Local),
},
}, segments)
})
}