support forwarding streams natively (#5558)
It is now possible to define forward destinations for each path configuration. For each destination, the server will create a client that will forward the stream to the intended destination. Supported protocols are RTSP, RTMP, SRT. API and metrics have also been improved to allow monitoring the new forwarding system. --------- Co-authored-by: aler9 <46489434+aler9@users.noreply.github.com>
This commit is contained in:
@@ -28,17 +28,17 @@ _MediaMTX_ is a ready-to-use and zero-dependency live media server and media pro
|
||||
|
||||
<h3>Features</h3>
|
||||
|
||||
- [Publish](https://mediamtx.org/docs/features/publish) live streams to the server with Media-over-QUIC, SRT, WebRTC, RTSP, RTMP, HLS, MPEG-TS, RTP, using FFmpeg, GStreamer, OBS Studio, Python , Golang, Unity, web browsers, Raspberry Pi Cameras and more.
|
||||
- [Read](https://mediamtx.org/docs/features/read) live streams from the server with Media-over-QUIC, SRT, WebRTC, RTSP, RTMP, HLS, using FFmpeg, GStreamer, VLC, OBS Studio, Python , Golang, Unity, web browsers and more.
|
||||
- [Publish streams](https://mediamtx.org/docs/features/publish) to the server with Media-over-QUIC, SRT, WebRTC, RTSP, RTMP, HLS, MPEG-TS, RTP, using FFmpeg, GStreamer, OBS Studio, Python , Golang, Unity, Web browsers, Raspberry Pi Cameras and more.
|
||||
- [Read streams](https://mediamtx.org/docs/features/read) from the server with Media-over-QUIC, SRT, WebRTC, RTSP, RTMP, HLS, using FFmpeg, GStreamer, VLC, OBS Studio, Python , Golang, Unity, Web browsers and more.
|
||||
- Streams are automatically converted from a protocol to another
|
||||
- Serve several streams at once in separate paths
|
||||
- Reload the configuration without disconnecting existing clients (hot reloading)
|
||||
- [Serve always-available streams](https://mediamtx.org/docs/features/always-available) even when the publisher is offline
|
||||
- [Record](https://mediamtx.org/docs/features/record) streams to disk in fMP4 or MPEG-TS format
|
||||
- [Playback](https://mediamtx.org/docs/features/playback) recorded streams
|
||||
- [Record streams](https://mediamtx.org/docs/features/record) to disk in fMP4 or MPEG-TS format
|
||||
- [Playback recorded streams](https://mediamtx.org/docs/features/playback) from disk
|
||||
- [Authenticate](https://mediamtx.org/docs/features/authentication) users with internal, HTTP or JWT authentication
|
||||
- [Forward](https://mediamtx.org/docs/features/forward) streams to other servers
|
||||
- [Proxy](https://mediamtx.org/docs/features/proxy) requests to other servers
|
||||
- [Forward streams](https://mediamtx.org/docs/features/forward) to other servers
|
||||
- [Proxy requests](https://mediamtx.org/docs/features/proxy) to other servers
|
||||
- [Control](https://mediamtx.org/docs/features/control-api) the server through the Control API
|
||||
- [Extract metrics](https://mediamtx.org/docs/features/metrics) from the server in a Prometheus-compatible format
|
||||
- [Monitor performance](https://mediamtx.org/docs/features/performance) to investigate CPU and RAM consumption
|
||||
|
||||
@@ -141,6 +141,22 @@ components:
|
||||
- webRTCSource
|
||||
- moqSession
|
||||
|
||||
ForwardDestProtocol:
|
||||
type: string
|
||||
enum:
|
||||
- rtmp
|
||||
- rtmps
|
||||
- rtsp
|
||||
- rtsps
|
||||
- srt
|
||||
|
||||
ForwardDestState:
|
||||
type: string
|
||||
enum:
|
||||
- idle
|
||||
- forwarding
|
||||
- error
|
||||
|
||||
PathTrackCodec:
|
||||
type: string
|
||||
enum:
|
||||
@@ -915,6 +931,11 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
deprecated: true
|
||||
forward:
|
||||
type: array
|
||||
description: Forward
|
||||
items:
|
||||
$ref: "#/components/schemas/PathConfForwardDest"
|
||||
maxReaders:
|
||||
type: integer
|
||||
format: int64
|
||||
@@ -1235,6 +1256,12 @@ components:
|
||||
type: integer
|
||||
format: int64
|
||||
|
||||
PathConfForwardDest:
|
||||
type: object
|
||||
properties:
|
||||
dest:
|
||||
type: string
|
||||
|
||||
PathList:
|
||||
type: object
|
||||
properties:
|
||||
@@ -1382,6 +1409,43 @@ components:
|
||||
type: integer
|
||||
format: int64
|
||||
|
||||
ForwardDest:
|
||||
type: object
|
||||
properties:
|
||||
conf:
|
||||
$ref: "#/components/schemas/PathConfForwardDest"
|
||||
created:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
lastError:
|
||||
type: string
|
||||
outboundBytes:
|
||||
type: integer
|
||||
format: uint64
|
||||
pos:
|
||||
type: integer
|
||||
format: int64
|
||||
protocol:
|
||||
$ref: "#/components/schemas/ForwardDestProtocol"
|
||||
state:
|
||||
$ref: "#/components/schemas/ForwardDestState"
|
||||
|
||||
ForwardDestList:
|
||||
type: object
|
||||
properties:
|
||||
itemCount:
|
||||
type: integer
|
||||
format: int64
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ForwardDest"
|
||||
pageCount:
|
||||
type: integer
|
||||
format: int64
|
||||
|
||||
Recording:
|
||||
type: object
|
||||
properties:
|
||||
@@ -2667,6 +2731,103 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/v3/paths/forward/list:
|
||||
get:
|
||||
operationId: pathsForwardList
|
||||
tags: [Paths]
|
||||
summary: returns all forward destinations of a path.
|
||||
description: ""
|
||||
parameters:
|
||||
- name: path
|
||||
in: query
|
||||
required: true
|
||||
description: name of the path.
|
||||
schema:
|
||||
type: string
|
||||
- name: page
|
||||
in: query
|
||||
description: page number.
|
||||
schema:
|
||||
type: integer
|
||||
default: 0
|
||||
- name: itemsPerPage
|
||||
in: query
|
||||
description: items per page.
|
||||
schema:
|
||||
type: integer
|
||||
default: 100
|
||||
responses:
|
||||
"200":
|
||||
description: the request was successful.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ForwardDestList"
|
||||
"400":
|
||||
description: invalid request.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"404":
|
||||
description: path not found.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"500":
|
||||
description: server error.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/v3/paths/forward/get:
|
||||
get:
|
||||
operationId: pathsForwardGet
|
||||
tags: [Paths]
|
||||
summary: returns a forward destination.
|
||||
description: ""
|
||||
parameters:
|
||||
- name: id
|
||||
in: query
|
||||
required: true
|
||||
description: ID of the forward destination.
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- name: path
|
||||
in: query
|
||||
required: true
|
||||
description: name of the path.
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: the request was successful.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ForwardDest"
|
||||
"400":
|
||||
description: invalid request.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"404":
|
||||
description: path or forward destination not found.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"500":
|
||||
description: server error.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/v3/rtspconns/list:
|
||||
get:
|
||||
operationId: rtspConnsList
|
||||
|
||||
@@ -6,17 +6,17 @@ _MediaMTX_ is a ready-to-use and zero-dependency live media server and media pro
|
||||
|
||||
Main features:
|
||||
|
||||
- [Publish](../2-features/03-publish.md) live streams to the server with Media-over-QUIC, SRT, WebRTC, RTSP, RTMP, HLS, MPEG-TS, RTP, using FFmpeg, GStreamer, OBS Studio, Python , Golang, Unity, Web browsers, Raspberry Pi Cameras and more.
|
||||
- [Read](../2-features/04-read.md) live streams from the server with Media-over-QUIC, SRT, WebRTC, RTSP, RTMP, HLS, using FFmpeg, GStreamer, VLC, OBS Studio, Python , Golang, Unity, Web browsers and more.
|
||||
- [Publish streams](../2-features/03-publish.md) to the server with Media-over-QUIC, SRT, WebRTC, RTSP, RTMP, HLS, MPEG-TS, RTP, using FFmpeg, GStreamer, OBS Studio, Python , Golang, Unity, Web browsers, Raspberry Pi Cameras and more.
|
||||
- [Read streams](../2-features/04-read.md) from the server with Media-over-QUIC, SRT, WebRTC, RTSP, RTMP, HLS, using FFmpeg, GStreamer, VLC, OBS Studio, Python , Golang, Unity, Web browsers and more.
|
||||
- Streams are automatically converted from a protocol to another
|
||||
- Serve several streams at once in separate paths
|
||||
- Reload the configuration without disconnecting existing clients (hot reloading)
|
||||
- [Serve always-available streams](../2-features/08-always-available.md) even when the publisher is offline
|
||||
- [Record](../2-features/09-record.md) streams to disk in fMP4 or MPEG-TS format
|
||||
- [Playback](../2-features/10-playback.md) recorded streams
|
||||
- [Record streams](../2-features/09-record.md) to disk in fMP4 or MPEG-TS format
|
||||
- [Playback recorded streams](../2-features/10-playback.md) from disk
|
||||
- [Authenticate](../2-features/06-authentication.md) users with internal, HTTP or JWT authentication
|
||||
- [Forward](../2-features/11-forward.md) streams to other servers
|
||||
- [Proxy](../2-features/12-proxy.md) requests to other servers
|
||||
- [Forward streams](../2-features/11-forward.md) to other servers
|
||||
- [Proxy requests](../2-features/12-proxy.md) to other servers
|
||||
- [Control](../2-features/22-control-api.md) the server through the Control API
|
||||
- [Extract metrics](../2-features/23-metrics.md) from the server in a Prometheus-compatible format
|
||||
- [Monitor performance](../2-features/24-performance.md) to investigate CPU and RAM consumption
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Publish a stream
|
||||
# Publish streams
|
||||
|
||||
Live streams can be published to the server with the following protocols:
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Read a stream
|
||||
# Read streams
|
||||
|
||||
Live streams can be read from the server with the following protocols:
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Record
|
||||
# Record streams
|
||||
|
||||
Live streams be recorded to disk and played back with the following file containers and codecs:
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Playback
|
||||
# Playback recorded streams
|
||||
|
||||
Existing recordings can be played back to users through a dedicated HTTP server, that can be enabled inside the configuration:
|
||||
|
||||
|
||||
@@ -1,6 +1,49 @@
|
||||
# Forward
|
||||
# Forward streams
|
||||
|
||||
To forward incoming streams to another server, use _FFmpeg_ inside the `runOnAvailable` parameter:
|
||||
Incoming streams can be natively forwarded to other servers with the following protocols:
|
||||
|
||||
- [RTSP](#rtsp)
|
||||
- [RTMP](#rtmp)
|
||||
- [SRT](#srt)
|
||||
|
||||
It is also possible to use [FFmpeg](#ffmpeg) to perform the forwarding.
|
||||
|
||||
## RTSP
|
||||
|
||||
Add the target URL inside `dest` of a `forward` entry:
|
||||
|
||||
```yml
|
||||
paths:
|
||||
mypath:
|
||||
forward:
|
||||
- dest: rtsp://user:pass@host:port/path
|
||||
```
|
||||
|
||||
## RTMP
|
||||
|
||||
Add the target URL inside `dest` of a `forward` entry:
|
||||
|
||||
```yml
|
||||
paths:
|
||||
mypath:
|
||||
forward:
|
||||
- dest: rtmp://user:pass@host:port/path#streamKey
|
||||
```
|
||||
|
||||
## SRT
|
||||
|
||||
Add the target URL inside `dest` of a `forward` entry:
|
||||
|
||||
```yml
|
||||
paths:
|
||||
mypath:
|
||||
forward:
|
||||
- dest: srt://host:port?streamid=streamid
|
||||
```
|
||||
|
||||
## FFmpeg
|
||||
|
||||
When the destination requires transcoding, filtering or a protocol that is not supported by `forward`, use _FFmpeg_ inside the `runOnAvailable` parameter instead:
|
||||
|
||||
```yml
|
||||
pathDefaults:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Proxy
|
||||
# Proxy requests
|
||||
|
||||
The server allows to proxy incoming requests to other servers or cameras. This is useful to expose servers or cameras behind a NAT. Edit `mediamtx.yml` and replace everything inside section `paths` with the following content:
|
||||
|
||||
|
||||
@@ -154,13 +154,17 @@ webrtc_sessions_outbound_frames_discarded{id="[id]",path="[path]",remoteAddr="[r
|
||||
moq_sessions{id="[id]",path="[path]",remoteAddr="[remoteAddr]",state="[state]"} 1
|
||||
moq_sessions_inbound_bytes{id="[id]",path="[path]",remoteAddr="[remoteAddr]",state="[state]"} 1234
|
||||
moq_sessions_outbound_bytes{id="[id]",path="[path]",remoteAddr="[remoteAddr]",state="[state]"} 187
|
||||
|
||||
# Forward destinations
|
||||
forward_dests{id="[id]",path="[path]",protocol="[protocol]",state="[state]"} 1
|
||||
forward_dests_outbound_bytes{id="[id]",path="[path]",protocol="[protocol]",state="[state]"} 1234
|
||||
```
|
||||
|
||||
Bitrates are not provided directly as metrics because they can be computed from received and sent bytes by any metrics analyzer (i.e. Grafana).
|
||||
|
||||
Metrics can be filtered by using HTTP query parameters:
|
||||
|
||||
- `type=[TYPE]`: show metrics of a certain type only. TYPE can be `paths`, `hls_sessions`, `hls_muxers`, `rtsp_conns`, `rtsp_sessions`, `rtsps_conns`, `rtsps_sessions`, `rtmp_conns`, `rtmps_conns`, `srt_conns`, `webrtc_sessions`, `moq_sessions`.
|
||||
- `type=[TYPE]`: show metrics of a certain type only. TYPE can be `paths`, `forward_dests`, `hls_sessions`, `hls_muxers`, `rtsp_conns`, `rtsp_sessions`, `rtsps_conns`, `rtsps_sessions`, `rtmp_conns`, `rtmps_conns`, `srt_conns`, `webrtc_sessions`, `moq_sessions`.
|
||||
- `path=[PATH]`: show metrics belonging to a specific path only
|
||||
- `hls_muxer=[PATH]`: show metrics belonging to a specific HLS muxer only
|
||||
- `hls_session=[ID]`: show metrics belonging to a specific HLS session only
|
||||
@@ -172,4 +176,5 @@ Metrics can be filtered by using HTTP query parameters:
|
||||
- `rtmps_conn=[ID]` show metrics belonging to a specific RTMPS connection only
|
||||
- `srt_conn=[ID]` show metrics belonging to a specific SRT connection only
|
||||
- `webrtc_session=[ID]` show metrics belonging to a specific WebRTC session only
|
||||
- `forward_dest=[ID]` show metrics belonging to a specific forward destination only
|
||||
- `moq_session=[ID]` show metrics belonging to a specific MoQ session only
|
||||
|
||||
@@ -116,6 +116,8 @@ func (a *API) Initialize() error {
|
||||
|
||||
group.GET("/paths/list", a.onPathsList)
|
||||
group.GET("/paths/get/*name", a.onPathsGet)
|
||||
group.GET("/paths/forward/list", a.onForwardList)
|
||||
group.GET("/paths/forward/get", a.onForwardGet)
|
||||
|
||||
if !interfaceIsEmpty(a.HLSServer) {
|
||||
group.GET("/hlsmuxers/list", a.onHLSMuxersList)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package api //nolint:revive
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/bluenviron/mediamtx/internal/conf"
|
||||
"github.com/bluenviron/mediamtx/internal/forward"
|
||||
)
|
||||
|
||||
func (a *API) onForwardList(ctx *gin.Context) {
|
||||
pathName := ctx.Query("path")
|
||||
if pathName == "" {
|
||||
a.writeError(ctx, http.StatusBadRequest, fmt.Errorf("invalid path"))
|
||||
return
|
||||
}
|
||||
|
||||
data, err := a.PathManager.APIForwardDestList(pathName)
|
||||
if err != nil {
|
||||
if errors.Is(err, conf.ErrPathNotFound) {
|
||||
a.writeError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
a.writeError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
data.ItemCount = len(data.Items)
|
||||
pageCount, err := paginate(&data.Items, ctx.Query("itemsPerPage"), ctx.Query("page"))
|
||||
if err != nil {
|
||||
a.writeError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
data.PageCount = pageCount
|
||||
|
||||
ctx.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (a *API) onForwardGet(ctx *gin.Context) {
|
||||
id, err := uuid.Parse(ctx.Query("id"))
|
||||
if err != nil {
|
||||
a.writeError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
pathName := ctx.Query("path")
|
||||
if pathName == "" {
|
||||
a.writeError(ctx, http.StatusBadRequest, fmt.Errorf("invalid path"))
|
||||
return
|
||||
}
|
||||
|
||||
data, err := a.PathManager.APIForwardDestGet(pathName, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, conf.ErrPathNotFound) || errors.Is(err, forward.ErrDestNotFound) {
|
||||
a.writeError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
a.writeError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, data)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package api //nolint:revive
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/bluenviron/mediamtx/internal/conf"
|
||||
"github.com/bluenviron/mediamtx/internal/defs"
|
||||
"github.com/bluenviron/mediamtx/internal/forward"
|
||||
"github.com/bluenviron/mediamtx/internal/test"
|
||||
)
|
||||
|
||||
type testForwardPathManager struct {
|
||||
items map[uuid.UUID]*defs.APIForwardDest
|
||||
}
|
||||
|
||||
func (*testForwardPathManager) APIPathsList() (*defs.APIPathList, error) {
|
||||
return &defs.APIPathList{}, nil
|
||||
}
|
||||
|
||||
func (*testForwardPathManager) APIPathsGet(string) (*defs.APIPath, error) {
|
||||
return &defs.APIPath{}, nil
|
||||
}
|
||||
|
||||
func (m *testForwardPathManager) APIForwardDestList(path string) (*defs.APIForwardDestList, error) {
|
||||
if path != "my/nested/stream" {
|
||||
return nil, conf.ErrPathNotFound
|
||||
}
|
||||
|
||||
items := make([]defs.APIForwardDest, 0, len(m.items))
|
||||
for _, item := range m.items {
|
||||
items = append(items, *item)
|
||||
}
|
||||
|
||||
return &defs.APIForwardDestList{Items: items}, nil
|
||||
}
|
||||
|
||||
func (m *testForwardPathManager) APIForwardDestGet(path string, id uuid.UUID) (*defs.APIForwardDest, error) {
|
||||
if path != "my/nested/stream" {
|
||||
return nil, conf.ErrPathNotFound
|
||||
}
|
||||
|
||||
item, ok := m.items[id]
|
||||
if !ok {
|
||||
return nil, forward.ErrDestNotFound
|
||||
}
|
||||
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func TestForward(t *testing.T) {
|
||||
id := uuid.New()
|
||||
pathManager := &testForwardPathManager{
|
||||
items: map[uuid.UUID]*defs.APIForwardDest{
|
||||
id: {
|
||||
ID: id,
|
||||
Pos: 1,
|
||||
Created: time.Date(2026, 6, 18, 9, 0, 0, 0, time.UTC),
|
||||
Conf: conf.ForwardDest{Dest: "rtmp://localhost/live/stream"},
|
||||
Protocol: defs.APIForwardDestProtocolRTMP,
|
||||
State: defs.APIForwardDestStateError,
|
||||
LastError: "connection refused",
|
||||
OutboundBytes: 123,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
api := API{
|
||||
Address: "localhost:9997",
|
||||
ReadTimeout: conf.Duration(10 * time.Second),
|
||||
WriteTimeout: conf.Duration(10 * time.Second),
|
||||
AuthManager: test.NilAuthManager,
|
||||
PathManager: pathManager,
|
||||
Parent: &testParent{},
|
||||
}
|
||||
err := api.Initialize()
|
||||
require.NoError(t, err)
|
||||
defer api.Close()
|
||||
|
||||
tr := &http.Transport{}
|
||||
defer tr.CloseIdleConnections()
|
||||
hc := &http.Client{Transport: tr}
|
||||
|
||||
var list defs.APIForwardDestList
|
||||
httpRequest(t, hc, http.MethodGet,
|
||||
"http://localhost:9997/v3/paths/forward/list?path=my%2Fnested%2Fstream", nil, &list)
|
||||
require.Equal(t, 1, list.ItemCount)
|
||||
require.Equal(t, 1, list.PageCount)
|
||||
require.Equal(t, id, list.Items[0].ID)
|
||||
require.Equal(t, 1, list.Items[0].Pos)
|
||||
|
||||
var item defs.APIForwardDest
|
||||
httpRequest(t, hc, http.MethodGet,
|
||||
"http://localhost:9997/v3/paths/forward/get?path=my%2Fnested%2Fstream&id="+id.String(), nil, &item)
|
||||
require.Equal(t, "rtmp://localhost/live/stream", item.Conf.Dest)
|
||||
require.Equal(t, defs.APIForwardDestProtocolRTMP, item.Protocol)
|
||||
require.Equal(t, defs.APIForwardDestStateError, item.State)
|
||||
require.Equal(t, "connection refused", item.LastError)
|
||||
require.Equal(t, uint64(123), item.OutboundBytes)
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/bluenviron/mediamtx/internal/conf"
|
||||
"github.com/bluenviron/mediamtx/internal/defs"
|
||||
"github.com/bluenviron/mediamtx/internal/formatlabel"
|
||||
@@ -32,6 +34,14 @@ func (m *testPathManager) APIPathsGet(name string) (*defs.APIPath, error) {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (*testPathManager) APIForwardDestList(string) (*defs.APIForwardDestList, error) {
|
||||
return &defs.APIForwardDestList{}, nil
|
||||
}
|
||||
|
||||
func (*testPathManager) APIForwardDestGet(string, uuid.UUID) (*defs.APIForwardDest, error) {
|
||||
return nil, conf.ErrPathNotFound
|
||||
}
|
||||
|
||||
func TestPathsList(t *testing.T) {
|
||||
now := time.Now()
|
||||
pathManager := &testPathManager{
|
||||
|
||||
@@ -67,6 +67,16 @@ var enums = []struct {
|
||||
internalName: "APIPathSourceType",
|
||||
File: filepath.Join("internal", "defs", "api_path.go"),
|
||||
},
|
||||
{
|
||||
externalName: "ForwardDestProtocol",
|
||||
internalName: "APIForwardDestProtocol",
|
||||
File: filepath.Join("internal", "defs", "api_forward.go"),
|
||||
},
|
||||
{
|
||||
externalName: "ForwardDestState",
|
||||
internalName: "APIForwardDestState",
|
||||
File: filepath.Join("internal", "defs", "api_forward.go"),
|
||||
},
|
||||
{
|
||||
externalName: "PathTrackCodec",
|
||||
internalName: "Label",
|
||||
|
||||
@@ -721,6 +721,103 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/v3/paths/forward/list:
|
||||
get:
|
||||
operationId: pathsForwardList
|
||||
tags: [Paths]
|
||||
summary: returns all forward destinations of a path.
|
||||
description: ""
|
||||
parameters:
|
||||
- name: path
|
||||
in: query
|
||||
required: true
|
||||
description: name of the path.
|
||||
schema:
|
||||
type: string
|
||||
- name: page
|
||||
in: query
|
||||
description: page number.
|
||||
schema:
|
||||
type: integer
|
||||
default: 0
|
||||
- name: itemsPerPage
|
||||
in: query
|
||||
description: items per page.
|
||||
schema:
|
||||
type: integer
|
||||
default: 100
|
||||
responses:
|
||||
"200":
|
||||
description: the request was successful.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ForwardDestList"
|
||||
"400":
|
||||
description: invalid request.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"404":
|
||||
description: path not found.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"500":
|
||||
description: server error.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/v3/paths/forward/get:
|
||||
get:
|
||||
operationId: pathsForwardGet
|
||||
tags: [Paths]
|
||||
summary: returns a forward destination.
|
||||
description: ""
|
||||
parameters:
|
||||
- name: id
|
||||
in: query
|
||||
required: true
|
||||
description: ID of the forward destination.
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- name: path
|
||||
in: query
|
||||
required: true
|
||||
description: name of the path.
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: the request was successful.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ForwardDest"
|
||||
"400":
|
||||
description: invalid request.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"404":
|
||||
description: path or forward destination not found.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"500":
|
||||
description: server error.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/v3/rtspconns/list:
|
||||
get:
|
||||
operationId: rtspConnsList
|
||||
|
||||
@@ -85,6 +85,10 @@ var structs = []struct {
|
||||
externalName: "PathConfList",
|
||||
typ: reflect.TypeOf(defs.APIPathConfList{}),
|
||||
},
|
||||
{
|
||||
externalName: "PathConfForwardDest",
|
||||
typ: reflect.TypeOf(conf.ForwardDest{}),
|
||||
},
|
||||
{
|
||||
externalName: "PathList",
|
||||
typ: reflect.TypeOf(defs.APIPathList{}),
|
||||
@@ -137,6 +141,14 @@ var structs = []struct {
|
||||
externalName: "PathTrackCodecPropsVP9",
|
||||
typ: reflect.TypeOf(defs.APIPathTrackCodecPropsVP9{}),
|
||||
},
|
||||
{
|
||||
externalName: "ForwardDest",
|
||||
typ: reflect.TypeOf(defs.APIForwardDest{}),
|
||||
},
|
||||
{
|
||||
externalName: "ForwardDestList",
|
||||
typ: reflect.TypeOf(defs.APIForwardDestList{}),
|
||||
},
|
||||
{
|
||||
externalName: "Recording",
|
||||
typ: reflect.TypeOf(defs.APIRecording{}),
|
||||
@@ -292,6 +304,9 @@ func schemaName(rt reflect.Type) string {
|
||||
if rt == reflect.TypeOf(conf.Path{}) {
|
||||
return "PathConf"
|
||||
}
|
||||
if rt == reflect.TypeOf(conf.ForwardDest{}) {
|
||||
return "PathConfForwardDest"
|
||||
}
|
||||
|
||||
if rt == reflect.TypeOf(defs.APIPathTrackCodec("")) {
|
||||
return "PathTrackCodec"
|
||||
@@ -351,6 +366,12 @@ func isStructEnum(rt reflect.Type) bool {
|
||||
case reflect.TypeOf(defs.APIPathReaderType("")):
|
||||
return true
|
||||
|
||||
case reflect.TypeOf(defs.APIForwardDestProtocol("")):
|
||||
return true
|
||||
|
||||
case reflect.TypeOf(defs.APIForwardDestState("")):
|
||||
return true
|
||||
|
||||
case reflect.TypeOf(defs.APIPathTrackCodec("")):
|
||||
return true
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ func TestConfFromFile(t *testing.T) {
|
||||
SourceOnDemandStartTimeout: 10 * Duration(time.Second),
|
||||
SourceOnDemandCloseAfter: 10 * Duration(time.Second),
|
||||
OverridePublisher: true,
|
||||
Forward: Forward{},
|
||||
AlwaysAvailableTracks: []AlwaysAvailableTrack{},
|
||||
RecordPath: "./recordings/%path/%Y-%m-%d_%H-%M-%S-%f",
|
||||
RecordFormat: RecordFormatFMP4,
|
||||
@@ -843,6 +844,14 @@ func TestConfErrors(t *testing.T) {
|
||||
" source: rtsp://user@localhost/stream\n",
|
||||
"username and password must be both provided",
|
||||
},
|
||||
{
|
||||
"invalid forward destination",
|
||||
"paths:\n" +
|
||||
" mypath:\n" +
|
||||
" forward:\n" +
|
||||
" - dest: http://localhost/stream\n",
|
||||
"invalid 'forward': entry 0: unsupported scheme 'http', supported ones are rtmp, rtmps, rtsp, rtsps and srt",
|
||||
},
|
||||
} {
|
||||
t.Run(ca.name, func(t *testing.T) {
|
||||
tmpf := createTempFile(t, []byte(ca.conf))
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package conf
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Forward is a list of ForwardDest.
|
||||
type Forward []ForwardDest
|
||||
|
||||
// Validate validates the configuration.
|
||||
func (p Forward) Validate() error {
|
||||
for i, entry := range p {
|
||||
err := entry.Validate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("entry %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package conf
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ForwardDest is a destination to which a path is forwarded.
|
||||
type ForwardDest struct {
|
||||
Dest string `json:"dest"`
|
||||
}
|
||||
|
||||
func validateForwardDest(dest string) (*url.URL, error) {
|
||||
replaced := strings.ReplaceAll(dest, "$MTX_PATH", "path")
|
||||
|
||||
return validateURL(replaced)
|
||||
}
|
||||
|
||||
// Validate validates the configuration.
|
||||
func (p *ForwardDest) Validate() error {
|
||||
if p.Dest == "" {
|
||||
return fmt.Errorf("destination is empty")
|
||||
}
|
||||
|
||||
u, err := validateForwardDest(p.Dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch u.Scheme {
|
||||
case "rtmp", "rtmps", "rtsp", "rtsps", "srt":
|
||||
default:
|
||||
return fmt.Errorf(
|
||||
"unsupported scheme '%s', supported ones are rtmp, rtmps, rtsp, rtsps and srt",
|
||||
u.Scheme)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+10
-2
@@ -226,6 +226,9 @@ type Path struct {
|
||||
AlwaysAvailableTracks []AlwaysAvailableTrack `json:"alwaysAvailableTracks"`
|
||||
AlwaysAvailableFile string `json:"alwaysAvailableFile"`
|
||||
|
||||
// Forward
|
||||
Forward Forward `json:"forward"`
|
||||
|
||||
// Record
|
||||
Record bool `json:"record"`
|
||||
Playback *bool `json:"playback,omitempty" deprecated:"true"`
|
||||
@@ -785,9 +788,14 @@ func (pconf *Path) validate(
|
||||
}
|
||||
}
|
||||
|
||||
err := pconf.Forward.Validate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid 'forward': %w", err)
|
||||
}
|
||||
|
||||
if pconf.Fallback != nil {
|
||||
l.Log(logger.Warn, "the 'fallback' feature is deprecated, use 'alwaysAvailable' instead")
|
||||
err := checkRedirect(*pconf.Fallback)
|
||||
err = checkRedirect(*pconf.Fallback)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -813,7 +821,7 @@ func (pconf *Path) validate(
|
||||
return fmt.Errorf("'alwaysAvailableFile' and 'alwaysAvailableTracks' cannot be used together")
|
||||
}
|
||||
|
||||
err := checkAlwaysAvailableFile(pconf.AlwaysAvailableFile)
|
||||
err = checkAlwaysAvailableFile(pconf.AlwaysAvailableFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid 'alwaysAvailableFile': %w", err)
|
||||
}
|
||||
|
||||
@@ -454,6 +454,7 @@ func (p *Core) createResources(initial bool) error {
|
||||
writeTimeout: p.conf.WriteTimeout,
|
||||
writeQueueSize: p.conf.WriteQueueSize,
|
||||
udpReadBufferSize: p.conf.UDPReadBufferSize,
|
||||
udpMaxPayloadSize: p.conf.UDPMaxPayloadSize,
|
||||
rtpMaxPayloadSize: rtpMaxPayloadSize,
|
||||
pathConfs: p.conf.Paths,
|
||||
authManager: p.authManager,
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bluenviron/gortmplib"
|
||||
rtmpcodecs "github.com/bluenviron/gortmplib/pkg/codecs"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/bluenviron/mediamtx/internal/defs"
|
||||
"github.com/bluenviron/mediamtx/internal/test"
|
||||
)
|
||||
|
||||
func startRTMPForwardServer(t *testing.T) (string, <-chan [][]byte, <-chan error) {
|
||||
ready := &atomic.Bool{}
|
||||
ready.Store(true)
|
||||
u, received, _, serverErr := startRTMPForwardServerControlled(t, ready)
|
||||
return u, received, serverErr
|
||||
}
|
||||
|
||||
func startRTMPForwardServerControlled(
|
||||
t *testing.T,
|
||||
ready *atomic.Bool,
|
||||
) (string, <-chan [][]byte, <-chan struct{}, <-chan error) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
done := make(chan struct{})
|
||||
t.Cleanup(func() {
|
||||
close(done)
|
||||
ln.Close()
|
||||
})
|
||||
|
||||
received := make(chan [][]byte, 16)
|
||||
connOpened := make(chan struct{}, 16)
|
||||
serverErr := make(chan error, 16)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
nconn, acceptErr := ln.Accept()
|
||||
if acceptErr != nil {
|
||||
select {
|
||||
case <-done:
|
||||
default:
|
||||
serverErr <- acceptErr
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !ready.Load() {
|
||||
nconn.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
select {
|
||||
case connOpened <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
|
||||
go handleRTMPForwardConn(nconn, received, serverErr)
|
||||
}
|
||||
}()
|
||||
|
||||
return "rtmp://" + ln.Addr().String() + "/dest", received, connOpened, serverErr
|
||||
}
|
||||
|
||||
func handleRTMPForwardConn(nconn net.Conn, received chan<- [][]byte, serverErr chan<- error) {
|
||||
defer nconn.Close()
|
||||
|
||||
deadlineErr := nconn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
if deadlineErr != nil {
|
||||
serverErr <- deadlineErr
|
||||
return
|
||||
}
|
||||
|
||||
conn := &gortmplib.ServerConn{RW: nconn}
|
||||
initErr := conn.Initialize()
|
||||
if initErr != nil {
|
||||
serverErr <- initErr
|
||||
return
|
||||
}
|
||||
|
||||
acceptConnErr := conn.Accept()
|
||||
if acceptConnErr != nil {
|
||||
serverErr <- acceptConnErr
|
||||
return
|
||||
}
|
||||
|
||||
if !conn.Publish {
|
||||
serverErr <- fmt.Errorf("connection is not publishing")
|
||||
return
|
||||
}
|
||||
if conn.URL.Path != "/dest" {
|
||||
serverErr <- fmt.Errorf("unexpected path: %s", conn.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
r := &gortmplib.Reader{Conn: conn}
|
||||
err := r.Initialize()
|
||||
if err != nil {
|
||||
serverErr <- err
|
||||
return
|
||||
}
|
||||
|
||||
tracks := r.Tracks()
|
||||
if len(tracks) != 1 {
|
||||
serverErr <- fmt.Errorf("unexpected track count: %d", len(tracks))
|
||||
return
|
||||
}
|
||||
if _, ok := tracks[0].Codec.(*rtmpcodecs.H264); !ok {
|
||||
serverErr <- fmt.Errorf("unexpected codec: %T", tracks[0].Codec)
|
||||
return
|
||||
}
|
||||
|
||||
r.OnDataH264(tracks[0], func(_ time.Duration, _ time.Duration, au [][]byte) {
|
||||
for _, nalu := range au {
|
||||
if bytes.Equal(nalu, []byte{5, 2, 3, 4}) {
|
||||
select {
|
||||
case received <- au:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for {
|
||||
err = r.Read()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startRTMPPublisher(
|
||||
t *testing.T,
|
||||
path string,
|
||||
) (*gortmplib.Client, *gortmplib.Writer, *gortmplib.Track) {
|
||||
u, err := url.Parse("rtmp://127.0.0.1:1935/" + path)
|
||||
require.NoError(t, err)
|
||||
|
||||
source := &gortmplib.Client{
|
||||
URL: u,
|
||||
Publish: true,
|
||||
}
|
||||
err = source.Initialize(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
track := &gortmplib.Track{
|
||||
Codec: &rtmpcodecs.H264{
|
||||
SPS: test.FormatH264.SPS,
|
||||
PPS: test.FormatH264.PPS,
|
||||
},
|
||||
}
|
||||
|
||||
w := &gortmplib.Writer{
|
||||
Conn: source,
|
||||
Tracks: []*gortmplib.Track{track},
|
||||
}
|
||||
err = w.Initialize()
|
||||
require.NoError(t, err)
|
||||
|
||||
return source, w, track
|
||||
}
|
||||
|
||||
func waitRTMPForwardFrame(
|
||||
t *testing.T,
|
||||
w *gortmplib.Writer,
|
||||
track *gortmplib.Track,
|
||||
received <-chan [][]byte,
|
||||
serverErr <-chan error,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
timer := time.NewTimer(10 * time.Second)
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case au := <-received:
|
||||
require.Contains(t, au, []byte{5, 2, 3, 4})
|
||||
return
|
||||
|
||||
case err := <-serverErr:
|
||||
require.NoError(t, err)
|
||||
|
||||
case <-ticker.C:
|
||||
err := w.WriteH264(track, 2*time.Second, 2*time.Second, [][]byte{{5, 2, 3, 4}})
|
||||
require.NoError(t, err)
|
||||
|
||||
case <-timer.C:
|
||||
t.Fatal("timed out waiting for RTMP forwarded frame")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathForwardRTMP(t *testing.T) {
|
||||
dest, received, serverErr := startRTMPForwardServer(t)
|
||||
|
||||
p, ok := newInstance(t, "api: yes\n"+
|
||||
"paths:\n"+
|
||||
" source:\n"+
|
||||
" forward:\n"+
|
||||
" - dest: "+dest+"\n")
|
||||
require.Equal(t, true, ok)
|
||||
defer p.Close()
|
||||
|
||||
source, w, track := startRTMPPublisher(t, "source")
|
||||
defer source.Close()
|
||||
|
||||
tr := &http.Transport{}
|
||||
defer tr.CloseIdleConnections()
|
||||
hc := &http.Client{Transport: tr}
|
||||
|
||||
err := w.WriteH264(track, 2*time.Second, 2*time.Second, [][]byte{{5, 2, 3, 4}})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
var path struct {
|
||||
Ready bool `json:"ready"`
|
||||
}
|
||||
httpRequest(t, hc, http.MethodGet, "http://localhost:9997/v3/paths/get/source", nil, &path)
|
||||
return path.Ready
|
||||
}, 5*time.Second, 100*time.Millisecond)
|
||||
|
||||
var list defs.APIForwardDestList
|
||||
httpRequest(t, hc, http.MethodGet,
|
||||
"http://localhost:9997/v3/paths/forward/list?path=source", nil, &list)
|
||||
require.Len(t, list.Items, 1)
|
||||
added := list.Items[0]
|
||||
require.Equal(t, dest, added.Conf.Dest)
|
||||
require.Equal(t, defs.APIForwardDestProtocolRTMP, added.Protocol)
|
||||
require.Equal(t, 1, added.Pos)
|
||||
|
||||
waitRTMPForwardFrame(t, w, track, received, serverErr)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
var item defs.APIForwardDest
|
||||
httpRequest(t, hc, http.MethodGet,
|
||||
"http://localhost:9997/v3/paths/forward/get?path=source&id="+added.ID.String(), nil, &item)
|
||||
return item.State == defs.APIForwardDestStateForwarding &&
|
||||
item.Protocol == defs.APIForwardDestProtocolRTMP &&
|
||||
item.OutboundBytes > 0
|
||||
}, 5*time.Second, 100*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestPathForwardRTMPReconnectsAfterSourceUnavailable(t *testing.T) {
|
||||
dest, received, serverErr := startRTMPForwardServer(t)
|
||||
|
||||
p, ok := newInstance(t, "api: yes\n"+
|
||||
"paths:\n"+
|
||||
" source:\n"+
|
||||
" forward:\n"+
|
||||
" - dest: "+dest+"\n")
|
||||
require.Equal(t, true, ok)
|
||||
defer p.Close()
|
||||
|
||||
tr := &http.Transport{}
|
||||
defer tr.CloseIdleConnections()
|
||||
hc := &http.Client{Transport: tr}
|
||||
|
||||
var id uuid.UUID
|
||||
require.Eventually(t, func() bool {
|
||||
var list defs.APIForwardDestList
|
||||
httpRequest(t, hc, http.MethodGet,
|
||||
"http://localhost:9997/v3/paths/forward/list?path=source", nil, &list)
|
||||
if list.ItemCount != 1 || list.Items[0].State != defs.APIForwardDestStateIdle {
|
||||
return false
|
||||
}
|
||||
id = list.Items[0].ID
|
||||
return true
|
||||
}, 7*time.Second, 100*time.Millisecond)
|
||||
|
||||
source, w, track := startRTMPPublisher(t, "source")
|
||||
waitRTMPForwardFrame(t, w, track, received, serverErr)
|
||||
source.Close()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
var list defs.APIForwardDestList
|
||||
httpRequest(t, hc, http.MethodGet,
|
||||
"http://localhost:9997/v3/paths/forward/list?path=source", nil, &list)
|
||||
return list.ItemCount == 1 && list.Items[0].ID == id
|
||||
}, 5*time.Second, 100*time.Millisecond)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-received:
|
||||
default:
|
||||
goto drained
|
||||
}
|
||||
}
|
||||
|
||||
drained:
|
||||
source, w, track = startRTMPPublisher(t, "source")
|
||||
defer source.Close()
|
||||
|
||||
waitRTMPForwardFrame(t, w, track, received, serverErr)
|
||||
|
||||
var item defs.APIForwardDest
|
||||
httpRequest(t, hc, http.MethodGet,
|
||||
"http://localhost:9997/v3/paths/forward/get?path=source&id="+id.String(), nil, &item)
|
||||
require.Equal(t, id, item.ID)
|
||||
require.Equal(t, defs.APIForwardDestStateForwarding, item.State)
|
||||
require.Greater(t, item.OutboundBytes, uint64(0))
|
||||
}
|
||||
|
||||
func TestPathForwardRTMPReconnectsAfterDestinationUnavailable(t *testing.T) {
|
||||
ready := &atomic.Bool{}
|
||||
dest, received, _, serverErr := startRTMPForwardServerControlled(t, ready)
|
||||
|
||||
p, ok := newInstance(t, "api: yes\n"+
|
||||
"paths:\n"+
|
||||
" source:\n"+
|
||||
" forward:\n"+
|
||||
" - dest: "+dest+"\n")
|
||||
require.Equal(t, true, ok)
|
||||
defer p.Close()
|
||||
|
||||
source, w, track := startRTMPPublisher(t, "source")
|
||||
defer source.Close()
|
||||
|
||||
tr := &http.Transport{}
|
||||
defer tr.CloseIdleConnections()
|
||||
hc := &http.Client{Transport: tr}
|
||||
|
||||
var id uuid.UUID
|
||||
require.Eventually(t, func() bool {
|
||||
var list defs.APIForwardDestList
|
||||
httpRequest(t, hc, http.MethodGet,
|
||||
"http://localhost:9997/v3/paths/forward/list?path=source", nil, &list)
|
||||
if list.ItemCount != 1 || list.Items[0].State != defs.APIForwardDestStateIdle {
|
||||
return false
|
||||
}
|
||||
id = list.Items[0].ID
|
||||
return true
|
||||
}, 7*time.Second, 100*time.Millisecond)
|
||||
|
||||
ready.Store(true)
|
||||
waitRTMPForwardFrame(t, w, track, received, serverErr)
|
||||
|
||||
var item defs.APIForwardDest
|
||||
httpRequest(t, hc, http.MethodGet,
|
||||
"http://localhost:9997/v3/paths/forward/get?path=source&id="+id.String(), nil, &item)
|
||||
require.Equal(t, id, item.ID)
|
||||
require.Equal(t, defs.APIForwardDestStateForwarding, item.State)
|
||||
require.Equal(t, defs.APIForwardDestProtocolRTMP, item.Protocol)
|
||||
require.Greater(t, item.OutboundBytes, uint64(0))
|
||||
}
|
||||
+58
-4
@@ -11,11 +11,13 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/description"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/bluenviron/mediamtx/internal/conf"
|
||||
"github.com/bluenviron/mediamtx/internal/defs"
|
||||
"github.com/bluenviron/mediamtx/internal/externalcmd"
|
||||
"github.com/bluenviron/mediamtx/internal/formatlabel"
|
||||
"github.com/bluenviron/mediamtx/internal/forward"
|
||||
"github.com/bluenviron/mediamtx/internal/hooks"
|
||||
"github.com/bluenviron/mediamtx/internal/logger"
|
||||
"github.com/bluenviron/mediamtx/internal/recorder"
|
||||
@@ -67,6 +69,27 @@ type pathAPIPathsGetReq struct {
|
||||
res chan pathAPIPathsGetRes
|
||||
}
|
||||
|
||||
type pathAPIForwardDestListRes struct {
|
||||
path *path
|
||||
err error
|
||||
}
|
||||
|
||||
type pathAPIForwardDestListReq struct {
|
||||
name string
|
||||
res chan pathAPIForwardDestListRes
|
||||
}
|
||||
|
||||
type pathAPIForwardDestGetRes struct {
|
||||
path *path
|
||||
err error
|
||||
}
|
||||
|
||||
type pathAPIForwardDestGetReq struct {
|
||||
name string
|
||||
id uuid.UUID
|
||||
res chan pathAPIForwardDestGetRes
|
||||
}
|
||||
|
||||
type path struct {
|
||||
parentCtx context.Context
|
||||
logLevel conf.LogLevel
|
||||
@@ -76,6 +99,7 @@ type path struct {
|
||||
writeTimeout conf.Duration
|
||||
writeQueueSize int
|
||||
udpReadBufferSize uint
|
||||
udpMaxPayloadSize int
|
||||
rtpMaxPayloadSize int
|
||||
conf *conf.Path
|
||||
name string
|
||||
@@ -95,6 +119,7 @@ type path struct {
|
||||
source defs.Source
|
||||
stream *stream.Stream
|
||||
recorder *recorder.Recorder
|
||||
forwardManager *forward.Manager
|
||||
availableTime time.Time
|
||||
onlineTime time.Time
|
||||
onUnDemandHook func(string)
|
||||
@@ -147,6 +172,17 @@ func (pa *path) initialize() {
|
||||
pa.chAPIPathsGet = make(chan pathAPIPathsGetReq)
|
||||
pa.done = make(chan struct{})
|
||||
|
||||
pa.forwardManager = &forward.Manager{
|
||||
ReadTimeout: pa.readTimeout,
|
||||
WriteTimeout: pa.writeTimeout,
|
||||
UDPMaxPayloadSize: pa.udpMaxPayloadSize,
|
||||
PathName: pa.name,
|
||||
Matches: pa.matches,
|
||||
Forward: pa.conf.Forward,
|
||||
Parent: pa,
|
||||
}
|
||||
pa.forwardManager.Initialize()
|
||||
|
||||
pa.Log(logger.Debug, "created")
|
||||
|
||||
pa.wg.Add(1)
|
||||
@@ -339,6 +375,10 @@ func (pa *path) runInner() error {
|
||||
case req := <-pa.chRemoveReader:
|
||||
pa.doRemoveReader(req)
|
||||
|
||||
if pa.shouldClose() {
|
||||
pa.parent.closePathIfIdle(pa)
|
||||
}
|
||||
|
||||
case req := <-pa.chAPIPathsGet:
|
||||
pa.doAPIPathsGet(req)
|
||||
|
||||
@@ -398,6 +438,8 @@ func (pa *path) doReloadConf(newConf *conf.Path) {
|
||||
pa.source.(*staticsources.Handler).ReloadConf(newConf)
|
||||
}
|
||||
|
||||
pa.forwardManager.ReloadConf(newConf.Forward)
|
||||
|
||||
if pa.recorder != nil &&
|
||||
(newConf.Record != oldConf.Record ||
|
||||
newConf.RecordPath != oldConf.RecordPath ||
|
||||
@@ -872,10 +914,6 @@ func (pa *path) setAvailable(
|
||||
|
||||
pa.availableTime = time.Now()
|
||||
|
||||
if pa.conf.Record {
|
||||
pa.startRecording()
|
||||
}
|
||||
|
||||
var sourceDesc *defs.APIPathSource
|
||||
if source != nil {
|
||||
sourceDesc = source.APISourceDescribe()
|
||||
@@ -894,6 +932,12 @@ func (pa *path) setAvailable(
|
||||
pa.setOnline(sourceDesc, publisherQuery)
|
||||
}
|
||||
|
||||
pa.forwardManager.Start(pa.stream)
|
||||
|
||||
if pa.conf.Record {
|
||||
pa.startRecording()
|
||||
}
|
||||
|
||||
if pa.conf.AlwaysAvailable {
|
||||
pa.Log(logger.Info, "stream is available, %s", defs.MediasInfo(pa.stream.OrigDesc.Medias))
|
||||
} else {
|
||||
@@ -935,6 +979,8 @@ func (pa *path) setNotAvailable() {
|
||||
pa.recorder = nil
|
||||
}
|
||||
|
||||
pa.forwardManager.Stop()
|
||||
|
||||
if pa.stream != nil {
|
||||
pa.stream.Close()
|
||||
pa.stream = nil
|
||||
@@ -1144,3 +1190,11 @@ func (pa *path) APIPathsGet(req pathAPIPathsGetReq) (*defs.APIPath, error) {
|
||||
return nil, fmt.Errorf("terminated")
|
||||
}
|
||||
}
|
||||
|
||||
func (pa *path) APIForwardDestList() *defs.APIForwardDestList {
|
||||
return pa.forwardManager.APIList()
|
||||
}
|
||||
|
||||
func (pa *path) APIForwardDestGet(destID uuid.UUID) (*defs.APIForwardDest, error) {
|
||||
return pa.forwardManager.APIGet(destID)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/bluenviron/mediamtx/internal/logger"
|
||||
"github.com/bluenviron/mediamtx/internal/metrics"
|
||||
"github.com/bluenviron/mediamtx/internal/servers/hls"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func pathConfCanBeUpdated(oldPathConf *conf.Path, newPathConf *conf.Path) bool {
|
||||
@@ -22,6 +23,7 @@ func pathConfCanBeUpdated(oldPathConf *conf.Path, newPathConf *conf.Path) bool {
|
||||
|
||||
clone.Name = newPathConf.Name
|
||||
clone.Regexp = newPathConf.Regexp
|
||||
clone.Forward = newPathConf.Forward
|
||||
|
||||
clone.Record = newPathConf.Record
|
||||
clone.RecordPath = newPathConf.RecordPath
|
||||
@@ -78,6 +80,7 @@ type pathManager struct {
|
||||
writeTimeout conf.Duration
|
||||
writeQueueSize int
|
||||
udpReadBufferSize uint
|
||||
udpMaxPayloadSize int
|
||||
rtpMaxPayloadSize int
|
||||
pathConfs map[string]*conf.Path
|
||||
authManager pathManagerAuthManager
|
||||
@@ -92,18 +95,20 @@ type pathManager struct {
|
||||
paths map[string]*path
|
||||
|
||||
// in
|
||||
chReloadConf chan map[string]*conf.Path
|
||||
chSetHLSServer chan pathSetHLSServerReq
|
||||
chRemovePath chan *path
|
||||
chClosePathIfIdle chan *path
|
||||
chSetPathReady chan *path
|
||||
chSetPathNotReady chan *path
|
||||
chFindPathConf chan defs.PathFindPathConfReq
|
||||
chDescribe chan defs.PathDescribeReq
|
||||
chAddReader chan defs.PathAddReaderReq
|
||||
chAddPublisher chan defs.PathAddPublisherReq
|
||||
chAPIPathsList chan pathAPIPathsListReq
|
||||
chAPIPathsGet chan pathAPIPathsGetReq
|
||||
chReloadConf chan map[string]*conf.Path
|
||||
chSetHLSServer chan pathSetHLSServerReq
|
||||
chRemovePath chan *path
|
||||
chClosePathIfIdle chan *path
|
||||
chSetPathReady chan *path
|
||||
chSetPathNotReady chan *path
|
||||
chFindPathConf chan defs.PathFindPathConfReq
|
||||
chDescribe chan defs.PathDescribeReq
|
||||
chAddReader chan defs.PathAddReaderReq
|
||||
chAddPublisher chan defs.PathAddPublisherReq
|
||||
chAPIPathsList chan pathAPIPathsListReq
|
||||
chAPIPathsGet chan pathAPIPathsGetReq
|
||||
chAPIForwardDestList chan pathAPIForwardDestListReq
|
||||
chAPIForwardDestGet chan pathAPIForwardDestGetReq
|
||||
}
|
||||
|
||||
func (pm *pathManager) initialize() {
|
||||
@@ -124,6 +129,8 @@ func (pm *pathManager) initialize() {
|
||||
pm.chAddPublisher = make(chan defs.PathAddPublisherReq)
|
||||
pm.chAPIPathsList = make(chan pathAPIPathsListReq)
|
||||
pm.chAPIPathsGet = make(chan pathAPIPathsGetReq)
|
||||
pm.chAPIForwardDestList = make(chan pathAPIForwardDestListReq)
|
||||
pm.chAPIForwardDestGet = make(chan pathAPIForwardDestGetReq)
|
||||
|
||||
for _, pathConf := range pm.pathConfs {
|
||||
if pathConf.Regexp == nil {
|
||||
@@ -204,6 +211,12 @@ outer:
|
||||
case req := <-pm.chAPIPathsGet:
|
||||
pm.doAPIPathsGet(req)
|
||||
|
||||
case req := <-pm.chAPIForwardDestList:
|
||||
pm.doAPIForwardDestList(req)
|
||||
|
||||
case req := <-pm.chAPIForwardDestGet:
|
||||
pm.doAPIForwardDestGet(req)
|
||||
|
||||
case <-pm.ctx.Done():
|
||||
break outer
|
||||
}
|
||||
@@ -454,6 +467,26 @@ func (pm *pathManager) doAPIPathsGet(req pathAPIPathsGetReq) {
|
||||
req.res <- pathAPIPathsGetRes{path: pa}
|
||||
}
|
||||
|
||||
func (pm *pathManager) doAPIForwardDestList(req pathAPIForwardDestListReq) {
|
||||
pa, ok := pm.paths[req.name]
|
||||
if !ok {
|
||||
req.res <- pathAPIForwardDestListRes{err: conf.ErrPathNotFound}
|
||||
return
|
||||
}
|
||||
|
||||
req.res <- pathAPIForwardDestListRes{path: pa}
|
||||
}
|
||||
|
||||
func (pm *pathManager) doAPIForwardDestGet(req pathAPIForwardDestGetReq) {
|
||||
pa, ok := pm.paths[req.name]
|
||||
if !ok {
|
||||
req.res <- pathAPIForwardDestGetRes{err: conf.ErrPathNotFound}
|
||||
return
|
||||
}
|
||||
|
||||
req.res <- pathAPIForwardDestGetRes{path: pa}
|
||||
}
|
||||
|
||||
func (pm *pathManager) createPath(
|
||||
pathConf *conf.Path,
|
||||
name string,
|
||||
@@ -468,6 +501,7 @@ func (pm *pathManager) createPath(
|
||||
writeTimeout: pm.writeTimeout,
|
||||
writeQueueSize: pm.writeQueueSize,
|
||||
udpReadBufferSize: pm.udpReadBufferSize,
|
||||
udpMaxPayloadSize: pm.udpMaxPayloadSize,
|
||||
rtpMaxPayloadSize: pm.rtpMaxPayloadSize,
|
||||
conf: pathConf,
|
||||
name: name,
|
||||
@@ -696,3 +730,48 @@ func (pm *pathManager) APIPathsGet(name string) (*defs.APIPath, error) {
|
||||
return nil, fmt.Errorf("terminated")
|
||||
}
|
||||
}
|
||||
|
||||
// APIForwardDestList implements defs.APIPathManager.
|
||||
func (pm *pathManager) APIForwardDestList(name string) (*defs.APIForwardDestList, error) {
|
||||
req := pathAPIForwardDestListReq{
|
||||
name: name,
|
||||
res: make(chan pathAPIForwardDestListRes),
|
||||
}
|
||||
|
||||
select {
|
||||
case pm.chAPIForwardDestList <- req:
|
||||
res := <-req.res
|
||||
if res.err != nil {
|
||||
return nil, res.err
|
||||
}
|
||||
|
||||
data := res.path.APIForwardDestList()
|
||||
return data, nil
|
||||
|
||||
case <-pm.ctx.Done():
|
||||
return nil, fmt.Errorf("terminated")
|
||||
}
|
||||
}
|
||||
|
||||
// APIForwardDestGet implements defs.APIPathManager.
|
||||
func (pm *pathManager) APIForwardDestGet(name string, id uuid.UUID) (*defs.APIForwardDest, error) {
|
||||
req := pathAPIForwardDestGetReq{
|
||||
name: name,
|
||||
id: id,
|
||||
res: make(chan pathAPIForwardDestGetRes),
|
||||
}
|
||||
|
||||
select {
|
||||
case pm.chAPIForwardDestGet <- req:
|
||||
res := <-req.res
|
||||
if res.err != nil {
|
||||
return nil, res.err
|
||||
}
|
||||
|
||||
data, err := res.path.APIForwardDestGet(req.id)
|
||||
return data, err
|
||||
|
||||
case <-pm.ctx.Done():
|
||||
return nil, fmt.Errorf("terminated")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package defs
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/bluenviron/mediamtx/internal/conf"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// APIForwardDestState is the state of a forward destination.
|
||||
type APIForwardDestState string
|
||||
|
||||
// forward states.
|
||||
const (
|
||||
APIForwardDestStateIdle APIForwardDestState = "idle"
|
||||
APIForwardDestStateForwarding APIForwardDestState = "forwarding"
|
||||
APIForwardDestStateError APIForwardDestState = "error"
|
||||
)
|
||||
|
||||
// APIForwardDestProtocol is the protocol used by a forward destination.
|
||||
type APIForwardDestProtocol string
|
||||
|
||||
// forward protocols.
|
||||
const (
|
||||
APIForwardDestProtocolRTMP APIForwardDestProtocol = "rtmp"
|
||||
APIForwardDestProtocolRTMPS APIForwardDestProtocol = "rtmps"
|
||||
APIForwardDestProtocolRTSP APIForwardDestProtocol = "rtsp"
|
||||
APIForwardDestProtocolRTSPS APIForwardDestProtocol = "rtsps"
|
||||
APIForwardDestProtocolSRT APIForwardDestProtocol = "srt"
|
||||
)
|
||||
|
||||
// APIForwardDest is a forward destination.
|
||||
type APIForwardDest struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Pos int `json:"pos"`
|
||||
Created time.Time `json:"created"`
|
||||
Conf conf.ForwardDest `json:"conf"`
|
||||
Protocol APIForwardDestProtocol `json:"protocol"`
|
||||
State APIForwardDestState `json:"state"`
|
||||
LastError string `json:"lastError"`
|
||||
OutboundBytes uint64 `json:"outboundBytes"`
|
||||
}
|
||||
|
||||
// APIForwardDestList is a list of forward destinations.
|
||||
type APIForwardDestList struct {
|
||||
ItemCount int `json:"itemCount"`
|
||||
PageCount int `json:"pageCount"`
|
||||
Items []APIForwardDest `json:"items"`
|
||||
}
|
||||
@@ -2,12 +2,16 @@ package defs
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// APIPathManager contains methods used by the API and Metrics server.
|
||||
type APIPathManager interface {
|
||||
APIPathsList() (*APIPathList, error)
|
||||
APIPathsGet(string) (*APIPath, error)
|
||||
APIForwardDestList(string) (*APIForwardDestList, error)
|
||||
APIForwardDestGet(string, uuid.UUID) (*APIForwardDest, error)
|
||||
}
|
||||
|
||||
// APIPathSourceType is the type of a path source.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package forward
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// Dest is a protocol-specific forward destination.
|
||||
type Dest interface {
|
||||
Run(context.Context) error
|
||||
OutboundBytes() uint64
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package forward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/bluenviron/mediamtx/internal/conf"
|
||||
"github.com/bluenviron/mediamtx/internal/defs"
|
||||
forwardrtmp "github.com/bluenviron/mediamtx/internal/forward/rtmp"
|
||||
forwardrtsp "github.com/bluenviron/mediamtx/internal/forward/rtsp"
|
||||
forwardsrt "github.com/bluenviron/mediamtx/internal/forward/srt"
|
||||
"github.com/bluenviron/mediamtx/internal/logger"
|
||||
"github.com/bluenviron/mediamtx/internal/stream"
|
||||
)
|
||||
|
||||
const retryPause = 5 * time.Second
|
||||
|
||||
var errTerminated = errors.New("terminated")
|
||||
|
||||
func sanitizeDestURL(dest string) string {
|
||||
u, err := url.Parse(dest)
|
||||
if err != nil {
|
||||
return dest
|
||||
}
|
||||
u.User = nil
|
||||
u.Fragment = ""
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func resolveDest(dest string, pathName string, matches []string) string {
|
||||
out := strings.ReplaceAll(dest, "$MTX_PATH", pathName)
|
||||
|
||||
for i := len(matches) - 1; i >= 1; i-- {
|
||||
out = strings.ReplaceAll(out, "$G"+strconv.FormatInt(int64(i), 10), matches[i])
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// DestHandler manages a forward destination.
|
||||
type DestHandler struct {
|
||||
Pos int
|
||||
Conf conf.ForwardDest
|
||||
ReadTimeout conf.Duration
|
||||
WriteTimeout conf.Duration
|
||||
UDPMaxPayloadSize int
|
||||
PathName string
|
||||
Matches []string
|
||||
Parent logger.Writer
|
||||
|
||||
ctx context.Context
|
||||
ctxCancel func()
|
||||
|
||||
uuid uuid.UUID
|
||||
created time.Time
|
||||
protocol defs.APIForwardDestProtocol
|
||||
mutex sync.RWMutex
|
||||
state defs.APIForwardDestState
|
||||
lastError string
|
||||
outboundBytes uint64
|
||||
activeDest Dest
|
||||
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (h *DestHandler) initialize() {
|
||||
h.uuid = uuid.New()
|
||||
h.created = time.Now()
|
||||
h.protocol = destProtocol(h.Conf.Dest)
|
||||
h.state = defs.APIForwardDestStateIdle
|
||||
}
|
||||
|
||||
func (h *DestHandler) start(strm *stream.Stream) {
|
||||
h.Log(logger.Debug, "starting")
|
||||
h.ctx, h.ctxCancel = context.WithCancel(context.Background())
|
||||
h.done = make(chan struct{})
|
||||
go h.run(strm)
|
||||
}
|
||||
|
||||
func (h *DestHandler) stop() {
|
||||
h.Log(logger.Debug, "stopping")
|
||||
h.ctxCancel()
|
||||
<-h.done
|
||||
}
|
||||
|
||||
// ID returns the ID.
|
||||
func (h *DestHandler) ID() uuid.UUID {
|
||||
return h.uuid
|
||||
}
|
||||
|
||||
// Log implements logger.Writer.
|
||||
func (h *DestHandler) Log(level logger.Level, format string, args ...any) {
|
||||
id := hex.EncodeToString(h.uuid[:4])
|
||||
h.Parent.Log(level, "[%s dest %d %s] "+format,
|
||||
append([]any{strings.ToUpper(string(h.protocol)), h.Pos, id}, args...)...)
|
||||
}
|
||||
|
||||
func (h *DestHandler) outboundBytesLocked() uint64 {
|
||||
outboundBytes := h.outboundBytes
|
||||
if h.activeDest != nil {
|
||||
outboundBytes += h.activeDest.OutboundBytes()
|
||||
}
|
||||
return outboundBytes
|
||||
}
|
||||
|
||||
func destProtocol(dest string) defs.APIForwardDestProtocol {
|
||||
switch {
|
||||
case strings.HasPrefix(dest, "rtmp://"):
|
||||
return defs.APIForwardDestProtocolRTMP
|
||||
|
||||
case strings.HasPrefix(dest, "rtmps://"):
|
||||
return defs.APIForwardDestProtocolRTMPS
|
||||
|
||||
case strings.HasPrefix(dest, "rtsp://"):
|
||||
return defs.APIForwardDestProtocolRTSP
|
||||
|
||||
case strings.HasPrefix(dest, "rtsps://"):
|
||||
return defs.APIForwardDestProtocolRTSPS
|
||||
|
||||
case strings.HasPrefix(dest, "srt://"):
|
||||
return defs.APIForwardDestProtocolSRT
|
||||
|
||||
default:
|
||||
panic("should not happen")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DestHandler) run(strm *stream.Stream) {
|
||||
defer close(h.done)
|
||||
|
||||
defer func() {
|
||||
h.mutex.Lock()
|
||||
h.state = defs.APIForwardDestStateIdle
|
||||
h.mutex.Unlock()
|
||||
}()
|
||||
|
||||
for {
|
||||
h.mutex.Lock()
|
||||
h.state = defs.APIForwardDestStateForwarding
|
||||
h.lastError = ""
|
||||
h.mutex.Unlock()
|
||||
|
||||
err := h.runOnce(strm)
|
||||
if errors.Is(err, errTerminated) {
|
||||
return
|
||||
}
|
||||
|
||||
h.mutex.Lock()
|
||||
h.state = defs.APIForwardDestStateError
|
||||
h.lastError = err.Error()
|
||||
h.mutex.Unlock()
|
||||
|
||||
h.Log(logger.Error, err.Error())
|
||||
|
||||
timer := time.NewTimer(retryPause)
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-h.ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DestHandler) runOnce(strm *stream.Stream) error {
|
||||
resolvedDest := resolveDest(h.Conf.Dest, h.PathName, h.Matches)
|
||||
|
||||
var dest Dest
|
||||
|
||||
switch h.protocol {
|
||||
case defs.APIForwardDestProtocolRTMP, defs.APIForwardDestProtocolRTMPS:
|
||||
dest = &forwardrtmp.Dest{
|
||||
Stream: strm,
|
||||
Dest: resolvedDest,
|
||||
WriteTimeout: h.WriteTimeout,
|
||||
Parent: h,
|
||||
}
|
||||
|
||||
case defs.APIForwardDestProtocolRTSP, defs.APIForwardDestProtocolRTSPS:
|
||||
dest = &forwardrtsp.Dest{
|
||||
Stream: strm,
|
||||
Dest: resolvedDest,
|
||||
ReadTimeout: h.ReadTimeout,
|
||||
WriteTimeout: h.WriteTimeout,
|
||||
Parent: h,
|
||||
}
|
||||
|
||||
case defs.APIForwardDestProtocolSRT:
|
||||
dest = &forwardsrt.Dest{
|
||||
Stream: strm,
|
||||
Dest: resolvedDest,
|
||||
WriteTimeout: h.WriteTimeout,
|
||||
UDPMaxPayloadSize: h.UDPMaxPayloadSize,
|
||||
Parent: h,
|
||||
}
|
||||
|
||||
default:
|
||||
panic("should not happen")
|
||||
}
|
||||
|
||||
h.Log(logger.Info, "forwarding to '%s'", sanitizeDestURL(resolvedDest))
|
||||
|
||||
h.mutex.Lock()
|
||||
h.activeDest = dest
|
||||
h.mutex.Unlock()
|
||||
|
||||
defer func() {
|
||||
h.mutex.Lock()
|
||||
h.outboundBytes += h.activeDest.OutboundBytes()
|
||||
h.activeDest = nil
|
||||
h.mutex.Unlock()
|
||||
}()
|
||||
|
||||
destCtx, destCtxCancel := context.WithCancel(context.Background())
|
||||
|
||||
errChan := make(chan error)
|
||||
go func() {
|
||||
errChan <- dest.Run(destCtx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errChan:
|
||||
destCtxCancel()
|
||||
return err
|
||||
|
||||
case <-h.ctx.Done():
|
||||
destCtxCancel()
|
||||
<-errChan
|
||||
return errTerminated
|
||||
}
|
||||
}
|
||||
|
||||
// APIItem returns an API item.
|
||||
func (h *DestHandler) APIItem() defs.APIForwardDest {
|
||||
h.mutex.RLock()
|
||||
defer h.mutex.RUnlock()
|
||||
|
||||
outboundBytes := h.outboundBytesLocked()
|
||||
|
||||
return defs.APIForwardDest{
|
||||
ID: h.uuid,
|
||||
Pos: h.Pos,
|
||||
Created: h.created,
|
||||
Conf: h.Conf,
|
||||
Protocol: h.protocol,
|
||||
State: h.state,
|
||||
LastError: h.lastError,
|
||||
OutboundBytes: outboundBytes,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package forward
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestResolveDest(t *testing.T) {
|
||||
for _, ca := range []struct {
|
||||
name string
|
||||
dest string
|
||||
pathName string
|
||||
matches []string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "path substitution",
|
||||
dest: "rtmp://example.com/live/$MTX_PATH",
|
||||
pathName: "stream",
|
||||
expected: "rtmp://example.com/live/stream",
|
||||
},
|
||||
{
|
||||
name: "multi digit group substitution",
|
||||
dest: "rtmp://example.com/live/$G10",
|
||||
pathName: "stream",
|
||||
matches: []string{"full", "g1", "g2", "g3", "g4", "g5", "g6", "g7", "g8", "g9", "g10"},
|
||||
expected: "rtmp://example.com/live/g10",
|
||||
},
|
||||
{
|
||||
name: "combined substitutions",
|
||||
dest: "rtmp://$G1/live/$G10",
|
||||
pathName: "stream",
|
||||
matches: []string{"full", "host", "g2", "g3", "g4", "g5", "g6", "g7", "g8", "g9", "tail"},
|
||||
expected: "rtmp://host/live/tail",
|
||||
},
|
||||
} {
|
||||
t.Run(ca.name, func(t *testing.T) {
|
||||
result := resolveDest(ca.dest, ca.pathName, ca.matches)
|
||||
require.Equal(t, ca.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Package forward contains stream forwarding utilities.
|
||||
package forward
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/bluenviron/mediamtx/internal/conf"
|
||||
"github.com/bluenviron/mediamtx/internal/defs"
|
||||
"github.com/bluenviron/mediamtx/internal/logger"
|
||||
"github.com/bluenviron/mediamtx/internal/stream"
|
||||
)
|
||||
|
||||
// ErrDestNotFound is returned when a forward destination is not found.
|
||||
var ErrDestNotFound = errors.New("forward destination not found")
|
||||
|
||||
// ManagerParent is the parent interface.
|
||||
type ManagerParent interface {
|
||||
logger.Writer
|
||||
}
|
||||
|
||||
// Manager manages the forward destinations of a path.
|
||||
type Manager struct {
|
||||
ReadTimeout conf.Duration
|
||||
WriteTimeout conf.Duration
|
||||
UDPMaxPayloadSize int
|
||||
PathName string
|
||||
Matches []string
|
||||
Forward conf.Forward
|
||||
Parent ManagerParent
|
||||
|
||||
mutex sync.RWMutex
|
||||
destHandlers []*DestHandler
|
||||
started bool
|
||||
stream *stream.Stream
|
||||
}
|
||||
|
||||
// Initialize initializes Manager.
|
||||
func (m *Manager) Initialize() {
|
||||
m.destHandlers = make([]*DestHandler, 0, len(m.Forward))
|
||||
|
||||
for i, dest := range m.Forward {
|
||||
destHandler := m.createDestHandler(i+1, dest)
|
||||
m.destHandlers = append(m.destHandlers, destHandler)
|
||||
}
|
||||
}
|
||||
|
||||
// Log implements logger.Writer.
|
||||
func (m *Manager) Log(level logger.Level, format string, args ...any) {
|
||||
m.Parent.Log(level, format, args...)
|
||||
}
|
||||
|
||||
func (m *Manager) createDestHandler(pos int, conf conf.ForwardDest) *DestHandler {
|
||||
handler := &DestHandler{
|
||||
Pos: pos,
|
||||
Conf: conf,
|
||||
ReadTimeout: m.ReadTimeout,
|
||||
WriteTimeout: m.WriteTimeout,
|
||||
UDPMaxPayloadSize: m.UDPMaxPayloadSize,
|
||||
PathName: m.PathName,
|
||||
Matches: m.Matches,
|
||||
Parent: m,
|
||||
}
|
||||
handler.initialize()
|
||||
return handler
|
||||
}
|
||||
|
||||
// ReloadConf reloads statically-configured destinations.
|
||||
func (m *Manager) ReloadConf(forward conf.Forward) {
|
||||
m.mutex.Lock()
|
||||
|
||||
newHandlers := make([]*DestHandler, len(forward))
|
||||
toClose := make([]*DestHandler, 0)
|
||||
|
||||
for i, dest := range forward {
|
||||
if i < len(m.destHandlers) && m.destHandlers[i].Conf.Dest == dest.Dest {
|
||||
newHandlers[i] = m.destHandlers[i]
|
||||
} else {
|
||||
if i < len(m.destHandlers) {
|
||||
toClose = append(toClose, m.destHandlers[i])
|
||||
}
|
||||
|
||||
destHandler := m.createDestHandler(i+1, dest)
|
||||
if m.started {
|
||||
destHandler.start(m.stream)
|
||||
}
|
||||
|
||||
newHandlers[i] = destHandler
|
||||
}
|
||||
}
|
||||
|
||||
for i := len(forward); i < len(m.destHandlers); i++ {
|
||||
toClose = append(toClose, m.destHandlers[i])
|
||||
}
|
||||
|
||||
m.destHandlers = newHandlers
|
||||
|
||||
m.mutex.Unlock()
|
||||
|
||||
if m.started {
|
||||
for _, handler := range toClose {
|
||||
handler.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts all forward destinations.
|
||||
func (m *Manager) Start(strm *stream.Stream) {
|
||||
m.started = true
|
||||
m.stream = strm
|
||||
|
||||
for _, dest := range m.destHandlers {
|
||||
dest.start(strm)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop stops all forward destinations.
|
||||
func (m *Manager) Stop() {
|
||||
m.started = false
|
||||
|
||||
for _, dest := range m.destHandlers {
|
||||
dest.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// APIGet gets a forward destination.
|
||||
func (m *Manager) APIGet(id uuid.UUID) (*defs.APIForwardDest, error) {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
|
||||
for _, handler := range m.destHandlers {
|
||||
if handler.ID() == id {
|
||||
item := handler.APIItem()
|
||||
return &item, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, ErrDestNotFound
|
||||
}
|
||||
|
||||
// APIList lists all forward destinations.
|
||||
func (m *Manager) APIList() *defs.APIForwardDestList {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
|
||||
items := make([]defs.APIForwardDest, len(m.destHandlers))
|
||||
for i, handler := range m.destHandlers {
|
||||
items[i] = handler.APIItem()
|
||||
}
|
||||
|
||||
return &defs.APIForwardDestList{Items: items}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package forward_test
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/bluenviron/gortmplib"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/description"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/format"
|
||||
"github.com/bluenviron/mediamtx/internal/conf"
|
||||
"github.com/bluenviron/mediamtx/internal/defs"
|
||||
"github.com/bluenviron/mediamtx/internal/forward"
|
||||
"github.com/bluenviron/mediamtx/internal/stream"
|
||||
"github.com/bluenviron/mediamtx/internal/test"
|
||||
)
|
||||
|
||||
func TestManager(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer ln.Close()
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
conn, err2 := ln.Accept()
|
||||
require.NoError(t, err2)
|
||||
defer conn.Close()
|
||||
|
||||
sc := &gortmplib.ServerConn{
|
||||
RW: conn,
|
||||
}
|
||||
err2 = sc.Initialize()
|
||||
require.NoError(t, err2)
|
||||
|
||||
err2 = sc.Accept()
|
||||
require.NoError(t, err2)
|
||||
|
||||
require.Equal(t, true, sc.Publish)
|
||||
require.Equal(t, "/app/stream", sc.URL.Path)
|
||||
|
||||
close(done)
|
||||
}()
|
||||
|
||||
m := &forward.Manager{
|
||||
PathName: "test",
|
||||
Forward: conf.Forward{
|
||||
{Dest: "rtmp://" + ln.Addr().String() + "/app/stream"},
|
||||
},
|
||||
Parent: test.NilLogger,
|
||||
}
|
||||
m.Initialize()
|
||||
|
||||
desc := &description.Session{Medias: []*description.Media{{
|
||||
Type: description.MediaTypeVideo,
|
||||
Formats: []format.Format{test.FormatH264},
|
||||
}}}
|
||||
|
||||
strm := &stream.Stream{
|
||||
OrigDesc: desc,
|
||||
WriteQueueSize: 512,
|
||||
RTPMaxPayloadSize: 1450,
|
||||
Parent: test.NilLogger,
|
||||
}
|
||||
require.NoError(t, strm.Initialize())
|
||||
defer strm.Close()
|
||||
|
||||
m.Start(strm)
|
||||
defer m.Stop()
|
||||
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestManagerReloadConf(t *testing.T) {
|
||||
for _, ca := range []string{
|
||||
"idle",
|
||||
"running",
|
||||
} {
|
||||
t.Run(ca, func(t *testing.T) {
|
||||
m := &forward.Manager{
|
||||
PathName: "test",
|
||||
Forward: conf.Forward{
|
||||
{Dest: "rtmp://localhost:5788/app/stream"},
|
||||
{Dest: "rtsp://localhost:5789/stream"},
|
||||
},
|
||||
Parent: test.NilLogger,
|
||||
}
|
||||
m.Initialize()
|
||||
|
||||
if ca == "running" {
|
||||
desc := &description.Session{Medias: []*description.Media{{
|
||||
Type: description.MediaTypeVideo,
|
||||
Formats: []format.Format{test.FormatH264},
|
||||
}}}
|
||||
|
||||
strm := &stream.Stream{
|
||||
OrigDesc: desc,
|
||||
WriteQueueSize: 512,
|
||||
RTPMaxPayloadSize: 1450,
|
||||
Parent: test.NilLogger,
|
||||
}
|
||||
require.NoError(t, strm.Initialize())
|
||||
defer strm.Close()
|
||||
|
||||
m.Start(strm)
|
||||
defer m.Stop()
|
||||
}
|
||||
|
||||
list1 := m.APIList()
|
||||
require.Equal(t, &defs.APIForwardDestList{
|
||||
Items: []defs.APIForwardDest{
|
||||
{
|
||||
ID: list1.Items[0].ID,
|
||||
Pos: 1,
|
||||
Created: list1.Items[0].Created,
|
||||
Conf: conf.ForwardDest{Dest: "rtmp://localhost:5788/app/stream"},
|
||||
Protocol: "rtmp",
|
||||
State: list1.Items[0].State,
|
||||
},
|
||||
{
|
||||
ID: list1.Items[1].ID,
|
||||
Pos: 2,
|
||||
Created: list1.Items[1].Created,
|
||||
Conf: conf.ForwardDest{Dest: "rtsp://localhost:5789/stream"},
|
||||
Protocol: "rtsp",
|
||||
State: list1.Items[1].State,
|
||||
},
|
||||
},
|
||||
}, list1)
|
||||
|
||||
m.ReloadConf(conf.Forward{
|
||||
{Dest: "rtmp://localhost:5788/app/stream"}, // unchanged
|
||||
{Dest: "srt://localhost:5790?streamid=publish:test"},
|
||||
{Dest: "rtsp://localhost:5789/stream"},
|
||||
})
|
||||
|
||||
list2 := m.APIList()
|
||||
require.Equal(t, &defs.APIForwardDestList{
|
||||
Items: []defs.APIForwardDest{
|
||||
{
|
||||
ID: list1.Items[0].ID,
|
||||
Pos: 1,
|
||||
Created: list1.Items[0].Created,
|
||||
Conf: conf.ForwardDest{Dest: "rtmp://localhost:5788/app/stream"},
|
||||
Protocol: "rtmp",
|
||||
State: list2.Items[0].State,
|
||||
LastError: list2.Items[0].LastError,
|
||||
},
|
||||
{
|
||||
ID: list2.Items[1].ID,
|
||||
Pos: 2,
|
||||
Created: list2.Items[1].Created,
|
||||
Conf: conf.ForwardDest{Dest: "srt://localhost:5790?streamid=publish:test"},
|
||||
Protocol: "srt",
|
||||
State: list2.Items[1].State,
|
||||
LastError: list2.Items[1].LastError,
|
||||
},
|
||||
{
|
||||
ID: list2.Items[2].ID,
|
||||
Pos: 3,
|
||||
Created: list2.Items[2].Created,
|
||||
Conf: conf.ForwardDest{Dest: "rtsp://localhost:5789/stream"},
|
||||
Protocol: "rtsp",
|
||||
State: list2.Items[2].State,
|
||||
},
|
||||
},
|
||||
}, list2)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Package rtmp contains the RTMP forward destination.
|
||||
package rtmp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bluenviron/gortmplib"
|
||||
"github.com/bluenviron/gortmplib/pkg/amf0"
|
||||
"github.com/bluenviron/gortmplib/pkg/message"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/description"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/format"
|
||||
|
||||
"github.com/bluenviron/mediamtx/internal/conf"
|
||||
"github.com/bluenviron/mediamtx/internal/logger"
|
||||
rtmpprotocol "github.com/bluenviron/mediamtx/internal/protocols/rtmp"
|
||||
"github.com/bluenviron/mediamtx/internal/stream"
|
||||
)
|
||||
|
||||
func fourCCToString(c message.FourCC) string {
|
||||
return string([]byte{byte(c >> 24), byte(c >> 16), byte(c >> 8), byte(c)})
|
||||
}
|
||||
|
||||
func fourCCList(desc *description.Session) amf0.StrictArray {
|
||||
var videoCount int
|
||||
var audioCount int
|
||||
var enhanced bool
|
||||
|
||||
for _, media := range desc.Medias {
|
||||
for _, forma := range media.Formats {
|
||||
switch forma.(type) {
|
||||
case *format.AV1, *format.VP9, *format.H265, *format.Opus, *format.AC3, *format.Generic:
|
||||
enhanced = true
|
||||
|
||||
default:
|
||||
switch media.Type {
|
||||
case description.MediaTypeVideo:
|
||||
videoCount++
|
||||
case description.MediaTypeAudio:
|
||||
audioCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !enhanced && videoCount <= 1 && audioCount <= 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return amf0.StrictArray{
|
||||
fourCCToString(message.FourCCAV1),
|
||||
fourCCToString(message.FourCCVP9),
|
||||
fourCCToString(message.FourCCHEVC),
|
||||
fourCCToString(message.FourCCAVC),
|
||||
fourCCToString(message.FourCCOpus),
|
||||
fourCCToString(message.FourCCFLAC),
|
||||
fourCCToString(message.FourCCAC3),
|
||||
fourCCToString(message.FourCCMP4A),
|
||||
fourCCToString(message.FourCCMP3),
|
||||
}
|
||||
}
|
||||
|
||||
// Dest is a RTMP forward destination.
|
||||
type Dest struct {
|
||||
Stream *stream.Stream
|
||||
Dest string
|
||||
WriteTimeout conf.Duration
|
||||
Parent logger.Writer
|
||||
|
||||
mutex sync.RWMutex
|
||||
outboundBytesFunc func() uint64
|
||||
}
|
||||
|
||||
// Log implements logger.Writer.
|
||||
func (d *Dest) Log(level logger.Level, format string, args ...any) {
|
||||
d.Parent.Log(level, format, args...)
|
||||
}
|
||||
|
||||
// OutboundBytes returns the number of bytes sent by the destination.
|
||||
func (d *Dest) OutboundBytes() uint64 {
|
||||
d.mutex.RLock()
|
||||
defer d.mutex.RUnlock()
|
||||
|
||||
if d.outboundBytesFunc == nil {
|
||||
return 0
|
||||
}
|
||||
return d.outboundBytesFunc()
|
||||
}
|
||||
|
||||
// Run runs the destination.
|
||||
func (d *Dest) Run(ctx context.Context) error {
|
||||
u, err := url.Parse(d.Dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn := &gortmplib.Client{
|
||||
URL: u,
|
||||
Publish: true,
|
||||
}
|
||||
|
||||
err = conn.Initialize(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect RTMP destination: %w", err)
|
||||
}
|
||||
|
||||
terminate := make(chan struct{})
|
||||
|
||||
errChan := make(chan error, 1)
|
||||
go func() {
|
||||
errChan <- d.runInner(conn, terminate)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err = <-errChan:
|
||||
conn.Close()
|
||||
return err
|
||||
|
||||
case <-ctx.Done():
|
||||
close(terminate)
|
||||
conn.Close()
|
||||
<-errChan
|
||||
return fmt.Errorf("terminated")
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dest) runInner(conn *gortmplib.Client, terminate <-chan struct{}) error {
|
||||
d.mutex.Lock()
|
||||
d.outboundBytesFunc = conn.BytesSent
|
||||
d.mutex.Unlock()
|
||||
|
||||
r := &stream.Reader{Parent: d}
|
||||
outDesc := d.Stream.OutDescCopy()
|
||||
|
||||
err := rtmpprotocol.FromStream(
|
||||
d.Stream.OrigDesc,
|
||||
outDesc,
|
||||
r,
|
||||
conn,
|
||||
conn.NetConn(),
|
||||
time.Duration(d.WriteTimeout),
|
||||
fourCCList(outDesc))
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize RTMP destination writer: %w", err)
|
||||
}
|
||||
|
||||
conn.NetConn().SetReadDeadline(time.Time{})
|
||||
|
||||
d.Stream.AddReader(r)
|
||||
defer d.Stream.RemoveReader(r)
|
||||
|
||||
select {
|
||||
case err = <-r.Error():
|
||||
return err
|
||||
|
||||
case <-terminate:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package rtmp_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bluenviron/gortmplib"
|
||||
rtmpcodecs "github.com/bluenviron/gortmplib/pkg/codecs"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/description"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/format"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/bluenviron/mediamtx/internal/conf"
|
||||
"github.com/bluenviron/mediamtx/internal/forward/rtmp"
|
||||
"github.com/bluenviron/mediamtx/internal/stream"
|
||||
"github.com/bluenviron/mediamtx/internal/test"
|
||||
"github.com/bluenviron/mediamtx/internal/unit"
|
||||
)
|
||||
|
||||
func TestDest(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer ln.Close()
|
||||
|
||||
received := make(chan [][]byte, 8)
|
||||
serverErr := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
nconn, listenAcceptErr := ln.Accept()
|
||||
if listenAcceptErr != nil {
|
||||
serverErr <- listenAcceptErr
|
||||
return
|
||||
}
|
||||
defer nconn.Close()
|
||||
|
||||
conn := &gortmplib.ServerConn{RW: nconn}
|
||||
if initializeErr := conn.Initialize(); initializeErr != nil {
|
||||
serverErr <- initializeErr
|
||||
return
|
||||
}
|
||||
if publishAcceptErr := conn.Accept(); publishAcceptErr != nil {
|
||||
serverErr <- publishAcceptErr
|
||||
return
|
||||
}
|
||||
if !conn.Publish || conn.URL.Path != "/stream" {
|
||||
serverErr <- fmt.Errorf("unexpected publish target: %s", conn.URL)
|
||||
return
|
||||
}
|
||||
|
||||
reader := &gortmplib.Reader{Conn: conn}
|
||||
if initializeErr := reader.Initialize(); initializeErr != nil {
|
||||
serverErr <- initializeErr
|
||||
return
|
||||
}
|
||||
|
||||
tracks := reader.Tracks()
|
||||
if len(tracks) != 1 {
|
||||
serverErr <- fmt.Errorf("unexpected track count: %d", len(tracks))
|
||||
return
|
||||
}
|
||||
if _, ok := tracks[0].Codec.(*rtmpcodecs.H264); !ok {
|
||||
serverErr <- fmt.Errorf("unexpected codec: %T", tracks[0].Codec)
|
||||
return
|
||||
}
|
||||
|
||||
reader.OnDataH264(tracks[0], func(_ time.Duration, _ time.Duration, au [][]byte) {
|
||||
select {
|
||||
case received <- au:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
for {
|
||||
if readErr := reader.Read(); readErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
desc := &description.Session{Medias: []*description.Media{{
|
||||
Type: description.MediaTypeVideo,
|
||||
Formats: []format.Format{test.FormatH264},
|
||||
}}}
|
||||
strm := &stream.Stream{
|
||||
OrigDesc: desc,
|
||||
WriteQueueSize: 512,
|
||||
RTPMaxPayloadSize: 1450,
|
||||
Parent: test.NilLogger,
|
||||
}
|
||||
require.NoError(t, strm.Initialize())
|
||||
defer strm.Close()
|
||||
|
||||
subStream := &stream.SubStream{
|
||||
Stream: strm,
|
||||
UseRTPPackets: false,
|
||||
}
|
||||
require.NoError(t, subStream.Initialize())
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
dest := &rtmp.Dest{
|
||||
Stream: strm,
|
||||
Dest: "rtmp://" + ln.Addr().String() + "/stream",
|
||||
WriteTimeout: conf.Duration(10 * time.Second),
|
||||
Parent: test.NilLogger,
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- dest.Run(ctx)
|
||||
}()
|
||||
|
||||
strm.WaitForReaders()
|
||||
for i := range 2 {
|
||||
subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{
|
||||
PTS: int64(i) * 2 * 90000,
|
||||
Payload: unit.PayloadH264{{5, 2}},
|
||||
})
|
||||
}
|
||||
|
||||
timer := time.NewTimer(5 * time.Second)
|
||||
defer timer.Stop()
|
||||
|
||||
frameLoop:
|
||||
for {
|
||||
select {
|
||||
case au := <-received:
|
||||
for _, nalu := range au {
|
||||
if bytes.Equal(nalu, []byte{5, 2}) {
|
||||
break frameLoop
|
||||
}
|
||||
}
|
||||
case receivedErr := <-serverErr:
|
||||
require.NoError(t, receivedErr)
|
||||
case runErr := <-done:
|
||||
t.Fatalf("RTMP destination stopped before forwarding a frame: %v", runErr)
|
||||
case <-timer.C:
|
||||
t.Fatal("timed out waiting for RTMP frame")
|
||||
}
|
||||
}
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return dest.OutboundBytes() > 0
|
||||
}, 5*time.Second, 10*time.Millisecond)
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case runErr := <-done:
|
||||
require.Error(t, runErr)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for RTMP destination to stop")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Package rtsp contains the RTSP forward destination.
|
||||
package rtsp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bluenviron/gortsplib/v5"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/base"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/description"
|
||||
|
||||
"github.com/bluenviron/mediamtx/internal/conf"
|
||||
"github.com/bluenviron/mediamtx/internal/logger"
|
||||
"github.com/bluenviron/mediamtx/internal/stream"
|
||||
"github.com/bluenviron/mediamtx/internal/unit"
|
||||
)
|
||||
|
||||
// Dest is a RTSP forward destination.
|
||||
type Dest struct {
|
||||
Stream *stream.Stream
|
||||
Dest string
|
||||
ReadTimeout conf.Duration
|
||||
WriteTimeout conf.Duration
|
||||
Parent logger.Writer
|
||||
|
||||
mutex sync.RWMutex
|
||||
outboundBytesFunc func() uint64
|
||||
}
|
||||
|
||||
// Log implements logger.Writer.
|
||||
func (d *Dest) Log(level logger.Level, format string, args ...any) {
|
||||
d.Parent.Log(level, format, args...)
|
||||
}
|
||||
|
||||
// OutboundBytes returns the number of bytes sent by the destination.
|
||||
func (d *Dest) OutboundBytes() uint64 {
|
||||
d.mutex.RLock()
|
||||
defer d.mutex.RUnlock()
|
||||
|
||||
if d.outboundBytesFunc == nil {
|
||||
return 0
|
||||
}
|
||||
return d.outboundBytesFunc()
|
||||
}
|
||||
|
||||
// Run runs the destination.
|
||||
func (d *Dest) Run(ctx context.Context) error {
|
||||
desc := d.Stream.OutDescCopy()
|
||||
|
||||
u, err := base.ParseURL(d.Dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := &gortsplib.Client{
|
||||
Scheme: u.Scheme,
|
||||
Host: u.Host,
|
||||
ReadTimeout: time.Duration(d.ReadTimeout),
|
||||
WriteTimeout: time.Duration(d.WriteTimeout),
|
||||
OnRequest: func(req *base.Request) {
|
||||
d.Log(logger.Debug, "[c->s] %v", req)
|
||||
},
|
||||
OnResponse: func(res *base.Response) {
|
||||
d.Log(logger.Debug, "[s->c] %v", res)
|
||||
},
|
||||
OnTransportSwitch: func(err error) {
|
||||
d.Log(logger.Warn, err.Error())
|
||||
},
|
||||
}
|
||||
|
||||
err = client.Start()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
terminate := make(chan struct{})
|
||||
|
||||
errChan := make(chan error, 1)
|
||||
go func() {
|
||||
errChan <- d.runInner(client, desc, terminate)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err = <-errChan:
|
||||
client.Close()
|
||||
return err
|
||||
|
||||
case <-ctx.Done():
|
||||
close(terminate)
|
||||
client.Close()
|
||||
<-errChan
|
||||
return fmt.Errorf("terminated")
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dest) runInner(client *gortsplib.Client, desc *description.Session, terminate <-chan struct{}) error {
|
||||
u, err := base.ParseURL(d.Dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = client.Announce(u, desc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = client.SetupAll(u, desc.Medias)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = client.Record()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
d.mutex.Lock()
|
||||
d.outboundBytesFunc = func() uint64 {
|
||||
return client.Stats().Session.OutboundBytes
|
||||
}
|
||||
d.mutex.Unlock()
|
||||
|
||||
r := &stream.Reader{Parent: d}
|
||||
|
||||
for i, media := range d.Stream.OrigDesc.Medias {
|
||||
outMedia := desc.Medias[i]
|
||||
|
||||
for _, forma := range media.Formats {
|
||||
r.OnData(media, forma, func(u *unit.Unit) error {
|
||||
for _, pkt := range u.RTPPackets {
|
||||
writeErr := client.WritePacketRTPWithNTP(outMedia, pkt, u.NTP)
|
||||
if writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
d.Stream.AddReader(r)
|
||||
defer d.Stream.RemoveReader(r)
|
||||
|
||||
select {
|
||||
case err = <-r.Error():
|
||||
return err
|
||||
|
||||
case <-terminate:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package rtsp_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bluenviron/gortsplib/v5"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/base"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/description"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/format"
|
||||
"github.com/pion/rtp"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/bluenviron/mediamtx/internal/conf"
|
||||
"github.com/bluenviron/mediamtx/internal/forward/rtsp"
|
||||
"github.com/bluenviron/mediamtx/internal/stream"
|
||||
"github.com/bluenviron/mediamtx/internal/test"
|
||||
"github.com/bluenviron/mediamtx/internal/unit"
|
||||
)
|
||||
|
||||
type testServerHandler struct {
|
||||
announced chan *gortsplib.ServerHandlerOnAnnounceCtx
|
||||
received chan []byte
|
||||
}
|
||||
|
||||
func (h *testServerHandler) OnAnnounce(
|
||||
ctx *gortsplib.ServerHandlerOnAnnounceCtx,
|
||||
) (*base.Response, error) {
|
||||
h.announced <- ctx
|
||||
return &base.Response{StatusCode: base.StatusOK}, nil
|
||||
}
|
||||
|
||||
func (h *testServerHandler) OnSetup(
|
||||
*gortsplib.ServerHandlerOnSetupCtx,
|
||||
) (*base.Response, *gortsplib.ServerStream, error) {
|
||||
return &base.Response{StatusCode: base.StatusOK}, nil, nil
|
||||
}
|
||||
|
||||
func (h *testServerHandler) OnRecord(
|
||||
ctx *gortsplib.ServerHandlerOnRecordCtx,
|
||||
) (*base.Response, error) {
|
||||
ctx.Session.OnPacketRTPAny(func(_ *description.Media, _ format.Format, pkt *rtp.Packet) {
|
||||
h.received <- append([]byte(nil), pkt.Payload...)
|
||||
})
|
||||
return &base.Response{StatusCode: base.StatusOK}, nil
|
||||
}
|
||||
|
||||
func TestDest(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := &testServerHandler{
|
||||
announced: make(chan *gortsplib.ServerHandlerOnAnnounceCtx, 1),
|
||||
received: make(chan []byte, 1),
|
||||
}
|
||||
server := &gortsplib.Server{
|
||||
RTSPAddress: "unused",
|
||||
Handler: handler,
|
||||
Listen: func(string, string) (net.Listener, error) {
|
||||
return ln, nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, server.Start())
|
||||
defer server.Close()
|
||||
|
||||
desc := &description.Session{Medias: []*description.Media{{
|
||||
Type: description.MediaTypeVideo,
|
||||
Formats: []format.Format{test.FormatH264},
|
||||
}}}
|
||||
strm := &stream.Stream{
|
||||
OrigDesc: desc,
|
||||
WriteQueueSize: 512,
|
||||
RTPMaxPayloadSize: 1450,
|
||||
Parent: test.NilLogger,
|
||||
}
|
||||
require.NoError(t, strm.Initialize())
|
||||
defer strm.Close()
|
||||
|
||||
subStream := &stream.SubStream{
|
||||
Stream: strm,
|
||||
UseRTPPackets: true,
|
||||
}
|
||||
require.NoError(t, subStream.Initialize())
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
dest := &rtsp.Dest{
|
||||
Stream: strm,
|
||||
Dest: "rtsp://" + ln.Addr().String() + "/stream",
|
||||
ReadTimeout: conf.Duration(10 * time.Second),
|
||||
WriteTimeout: conf.Duration(10 * time.Second),
|
||||
Parent: test.NilLogger,
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- dest.Run(ctx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case announced := <-handler.announced:
|
||||
require.Equal(t, "/stream", announced.Path)
|
||||
require.Len(t, announced.Description.Medias, 1)
|
||||
_, ok := announced.Description.Medias[0].Formats[0].(*format.H264)
|
||||
require.True(t, ok)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for RTSP ANNOUNCE")
|
||||
}
|
||||
|
||||
strm.WaitForReaders()
|
||||
subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{
|
||||
NTP: time.Now(),
|
||||
PTS: 0,
|
||||
RTPPackets: []*rtp.Packet{{
|
||||
Header: rtp.Header{
|
||||
Version: 2,
|
||||
Marker: true,
|
||||
PayloadType: 96,
|
||||
SequenceNumber: 123,
|
||||
Timestamp: 456,
|
||||
SSRC: 789,
|
||||
},
|
||||
Payload: []byte{5, 1},
|
||||
}},
|
||||
})
|
||||
|
||||
select {
|
||||
case payload := <-handler.received:
|
||||
require.Equal(t, []byte{5, 1}, payload)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for RTP packet")
|
||||
}
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return dest.OutboundBytes() > 0
|
||||
}, 5*time.Second, 10*time.Millisecond)
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case runErr := <-done:
|
||||
require.Error(t, runErr)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for RTSP destination to stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDestCancelWhileStarting(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer ln.Close()
|
||||
|
||||
accepted := make(chan struct{}, 1)
|
||||
serverDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(serverDone)
|
||||
|
||||
nconn, acceptErr := ln.Accept()
|
||||
if acceptErr != nil {
|
||||
return
|
||||
}
|
||||
defer nconn.Close()
|
||||
|
||||
accepted <- struct{}{}
|
||||
<-time.After(5 * time.Second)
|
||||
}()
|
||||
|
||||
desc := &description.Session{Medias: []*description.Media{}}
|
||||
strm := &stream.Stream{
|
||||
OrigDesc: desc,
|
||||
WriteQueueSize: 512,
|
||||
RTPMaxPayloadSize: 1450,
|
||||
Parent: test.NilLogger,
|
||||
}
|
||||
require.NoError(t, strm.Initialize())
|
||||
defer strm.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
dest := &rtsp.Dest{
|
||||
Stream: strm,
|
||||
Dest: "rtsp://" + ln.Addr().String() + "/stream",
|
||||
ReadTimeout: conf.Duration(10 * time.Second),
|
||||
WriteTimeout: conf.Duration(10 * time.Second),
|
||||
Parent: test.NilLogger,
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- dest.Run(ctx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-accepted:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for RTSP connection")
|
||||
}
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case runErr := <-done:
|
||||
require.EqualError(t, runErr, "terminated")
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for RTSP destination to stop")
|
||||
}
|
||||
|
||||
<-serverDone
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Package srt contains the SRT forward destination.
|
||||
package srt
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
srtlib "github.com/datarhei/gosrt"
|
||||
|
||||
"github.com/bluenviron/mediamtx/internal/conf"
|
||||
"github.com/bluenviron/mediamtx/internal/logger"
|
||||
"github.com/bluenviron/mediamtx/internal/protocols/mpegts"
|
||||
"github.com/bluenviron/mediamtx/internal/stream"
|
||||
)
|
||||
|
||||
func maxPayloadSize(v int) int {
|
||||
return ((v - 16) / 188) * 188
|
||||
}
|
||||
|
||||
// Dest is a SRT forward destination.
|
||||
type Dest struct {
|
||||
Stream *stream.Stream
|
||||
Dest string
|
||||
WriteTimeout conf.Duration
|
||||
UDPMaxPayloadSize int
|
||||
Parent logger.Writer
|
||||
|
||||
mutex sync.RWMutex
|
||||
outboundBytesFunc func() uint64
|
||||
}
|
||||
|
||||
// Log implements logger.Writer.
|
||||
func (d *Dest) Log(level logger.Level, format string, args ...any) {
|
||||
d.Parent.Log(level, format, args...)
|
||||
}
|
||||
|
||||
// OutboundBytes returns the number of bytes sent by the destination.
|
||||
func (d *Dest) OutboundBytes() uint64 {
|
||||
d.mutex.RLock()
|
||||
defer d.mutex.RUnlock()
|
||||
|
||||
if d.outboundBytesFunc == nil {
|
||||
return 0
|
||||
}
|
||||
return d.outboundBytesFunc()
|
||||
}
|
||||
|
||||
// Run runs the destination.
|
||||
func (d *Dest) Run(ctx context.Context) error {
|
||||
srtConf := srtlib.DefaultConfig()
|
||||
address, err := srtConf.UnmarshalURL(d.Dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
udpMaxPayloadSize := d.UDPMaxPayloadSize
|
||||
if udpMaxPayloadSize == 0 {
|
||||
udpMaxPayloadSize = 1472
|
||||
}
|
||||
srtConf.PayloadSize = uint32(maxPayloadSize(udpMaxPayloadSize))
|
||||
|
||||
err = srtConf.Validate()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
terminate := make(chan struct{})
|
||||
|
||||
type runResult struct {
|
||||
err error
|
||||
}
|
||||
|
||||
errChan := make(chan runResult, 1)
|
||||
go func() {
|
||||
errChan <- runResult{err: d.runInner(ctx, address, srtConf, terminate)}
|
||||
}()
|
||||
|
||||
select {
|
||||
case res := <-errChan:
|
||||
return res.err
|
||||
|
||||
case <-ctx.Done():
|
||||
close(terminate)
|
||||
return fmt.Errorf("terminated")
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dest) runInner(ctx context.Context, address string, srtConf srtlib.Config, terminate <-chan struct{}) error {
|
||||
conn, err := srtlib.Dial("srt", address, srtConf)
|
||||
if err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
|
||||
d.mutex.Lock()
|
||||
d.outboundBytesFunc = func() uint64 {
|
||||
var stats srtlib.Statistics
|
||||
conn.Stats(&stats)
|
||||
return stats.Accumulated.ByteSent
|
||||
}
|
||||
d.mutex.Unlock()
|
||||
|
||||
r := &stream.Reader{Parent: d}
|
||||
bw := bufio.NewWriterSize(conn, int(srtConf.PayloadSize))
|
||||
|
||||
err = mpegts.FromStream(d.Stream.OrigDesc, r, bw, conn, time.Duration(d.WriteTimeout))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
d.Stream.AddReader(r)
|
||||
defer d.Stream.RemoveReader(r)
|
||||
|
||||
select {
|
||||
case readErr := <-r.Error():
|
||||
return readErr
|
||||
|
||||
case <-terminate:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package srt_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/description"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/format"
|
||||
"github.com/bluenviron/mediacommon/v2/pkg/formats/mpegts"
|
||||
tscodecs "github.com/bluenviron/mediacommon/v2/pkg/formats/mpegts/codecs"
|
||||
srtlib "github.com/datarhei/gosrt"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/bluenviron/mediamtx/internal/conf"
|
||||
"github.com/bluenviron/mediamtx/internal/forward/srt"
|
||||
"github.com/bluenviron/mediamtx/internal/stream"
|
||||
"github.com/bluenviron/mediamtx/internal/test"
|
||||
"github.com/bluenviron/mediamtx/internal/unit"
|
||||
)
|
||||
|
||||
func TestDest(t *testing.T) {
|
||||
ln, err := srtlib.Listen("srt", "127.0.0.1:0", srtlib.DefaultConfig())
|
||||
require.NoError(t, err)
|
||||
defer ln.Close()
|
||||
|
||||
desc := &description.Session{Medias: []*description.Media{{
|
||||
Type: description.MediaTypeVideo,
|
||||
Formats: []format.Format{test.FormatH264},
|
||||
}}}
|
||||
strm := &stream.Stream{
|
||||
OrigDesc: desc,
|
||||
WriteQueueSize: 512,
|
||||
RTPMaxPayloadSize: 1450,
|
||||
Parent: test.NilLogger,
|
||||
}
|
||||
require.NoError(t, strm.Initialize())
|
||||
defer strm.Close()
|
||||
|
||||
subStream := &stream.SubStream{
|
||||
Stream: strm,
|
||||
UseRTPPackets: false,
|
||||
}
|
||||
require.NoError(t, subStream.Initialize())
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
dest := &srt.Dest{
|
||||
Stream: strm,
|
||||
Dest: "srt://" + ln.Addr().String(),
|
||||
WriteTimeout: conf.Duration(10 * time.Second),
|
||||
UDPMaxPayloadSize: 1472,
|
||||
Parent: test.NilLogger,
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- dest.Run(ctx)
|
||||
}()
|
||||
|
||||
acceptTimer := time.AfterFunc(5*time.Second, ln.Close)
|
||||
req, err := ln.Accept2()
|
||||
if !acceptTimer.Stop() {
|
||||
t.Fatal("timed out waiting for SRT connection")
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
conn, err := req.Accept()
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
readTimer := time.AfterFunc(5*time.Second, func() {
|
||||
_ = conn.Close()
|
||||
})
|
||||
defer readTimer.Stop()
|
||||
|
||||
strm.WaitForReaders()
|
||||
subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{
|
||||
PTS: 0,
|
||||
Payload: unit.PayloadH264{{5, 1}},
|
||||
})
|
||||
|
||||
reader := &mpegts.Reader{R: conn}
|
||||
require.NoError(t, reader.Initialize())
|
||||
require.Equal(t, []*mpegts.Track{{
|
||||
PID: 256,
|
||||
Codec: &tscodecs.H264{},
|
||||
}}, reader.Tracks())
|
||||
|
||||
received := false
|
||||
reader.OnDataH264(reader.Tracks()[0], func(pts int64, dts int64, au [][]byte) error {
|
||||
require.Equal(t, int64(0), pts)
|
||||
require.Equal(t, int64(0), dts)
|
||||
require.Equal(t, [][]byte{
|
||||
test.FormatH264.SPS,
|
||||
test.FormatH264.PPS,
|
||||
{5, 1},
|
||||
}, au)
|
||||
received = true
|
||||
return nil
|
||||
})
|
||||
|
||||
subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{
|
||||
PTS: 90000,
|
||||
Payload: unit.PayloadH264{{5, 2}},
|
||||
})
|
||||
|
||||
for !received {
|
||||
require.NoError(t, reader.Read())
|
||||
}
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return dest.OutboundBytes() > 0
|
||||
}, 5*time.Second, 10*time.Millisecond)
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case runErr := <-done:
|
||||
require.Error(t, runErr)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for SRT destination to stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDestCancelWhileDialing(t *testing.T) {
|
||||
desc := &description.Session{Medias: []*description.Media{}}
|
||||
strm := &stream.Stream{
|
||||
OrigDesc: desc,
|
||||
WriteQueueSize: 512,
|
||||
RTPMaxPayloadSize: 1450,
|
||||
Parent: test.NilLogger,
|
||||
}
|
||||
require.NoError(t, strm.Initialize())
|
||||
defer strm.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
dest := &srt.Dest{
|
||||
Stream: strm,
|
||||
Dest: "srt://198.51.100.1:9000?conntimeo=10000",
|
||||
WriteTimeout: conf.Duration(10 * time.Second),
|
||||
UDPMaxPayloadSize: 1472,
|
||||
Parent: test.NilLogger,
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- dest.Run(ctx)
|
||||
}()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case runErr := <-done:
|
||||
require.EqualError(t, runErr, "terminated")
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for SRT destination to stop")
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,7 @@ type metricsType string
|
||||
|
||||
const (
|
||||
metricsTypePaths metricsType = "paths"
|
||||
metricsTypeForwardDests metricsType = "forward_dests"
|
||||
metricsTypeHLSSessions metricsType = "hls_sessions"
|
||||
metricsTypeHLSMuxers metricsType = "hls_muxers"
|
||||
metricsTypeRTSPConns metricsType = "rtsp_conns"
|
||||
@@ -230,6 +231,7 @@ func (m *Metrics) onMetrics(ctx *gin.Context) {
|
||||
|
||||
typ := metricsType(ctx.Query("type"))
|
||||
pathFilter := ctx.Query("path")
|
||||
forwardFilter := ctx.Query("forward_dest")
|
||||
hlsMuxerFilter := ctx.Query("hls_muxer")
|
||||
hlsSessionFilter := ctx.Query("hls_session")
|
||||
rtspConnFilter := ctx.Query("rtsp_conn")
|
||||
@@ -243,6 +245,7 @@ func (m *Metrics) onMetrics(ctx *gin.Context) {
|
||||
moqSessionFilter := ctx.Query("moq_session")
|
||||
|
||||
anyFilterActive := pathFilter != "" ||
|
||||
forwardFilter != "" ||
|
||||
hlsMuxerFilter != "" ||
|
||||
hlsSessionFilter != "" ||
|
||||
rtspConnFilter != "" ||
|
||||
@@ -343,6 +346,59 @@ func (m *Metrics) onMetrics(ctx *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if (typ == "" || typ == metricsTypeForwardDests) &&
|
||||
(!anyFilterActive || pathFilter != "" || forwardFilter != "") {
|
||||
data, err := pathManager.APIPathsList()
|
||||
if err == nil {
|
||||
type forwardWithPath struct {
|
||||
path string
|
||||
item defs.APIForwardDest
|
||||
}
|
||||
|
||||
var items []forwardWithPath
|
||||
for _, pa := range data.Items {
|
||||
if pathFilter != "" && pathFilter != pa.Name {
|
||||
continue
|
||||
}
|
||||
|
||||
forwards, forwardsErr := pathManager.APIForwardDestList(pa.Name)
|
||||
if forwardsErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, item := range forwards.Items {
|
||||
if forwardFilter == "" || forwardFilter == item.ID.String() {
|
||||
items = append(items, forwardWithPath{
|
||||
path: pa.Name,
|
||||
item: item,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(items) != 0 {
|
||||
out.WriteString("# Forward destinations\n")
|
||||
for _, i := range items {
|
||||
ta := tags(map[string]string{
|
||||
"id": i.item.ID.String(),
|
||||
"path": i.path,
|
||||
"protocol": string(i.item.Protocol),
|
||||
"state": string(i.item.State),
|
||||
})
|
||||
|
||||
metric(&out, "forward_dests", ta, 1)
|
||||
metric(&out, "forward_dests_outbound_bytes", ta, int64(i.item.OutboundBytes))
|
||||
}
|
||||
out.WriteString("\n")
|
||||
} else if typ == metricsTypeForwardDests && pathFilter == "" && forwardFilter == "" {
|
||||
out.WriteString("# Forward destinations\n")
|
||||
metric(&out, "forward_dests", "", 0)
|
||||
metric(&out, "forward_dests_outbound_bytes", "", 0)
|
||||
out.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !interfaceIsEmpty(hlsServer) {
|
||||
if (typ == "" || typ == metricsTypeHLSSessions) && (!anyFilterActive || hlsSessionFilter != "") {
|
||||
var data *defs.APIHLSSessionList
|
||||
|
||||
@@ -61,6 +61,34 @@ func (dummyPathManager) APIPathsGet(string) (*defs.APIPath, error) {
|
||||
panic("unused")
|
||||
}
|
||||
|
||||
func (dummyPathManager) APIForwardDestList(string) (*defs.APIForwardDestList, error) {
|
||||
return &defs.APIForwardDestList{}, nil
|
||||
}
|
||||
|
||||
func (dummyPathManager) APIForwardDestGet(string, uuid.UUID) (*defs.APIForwardDest, error) {
|
||||
panic("unused")
|
||||
}
|
||||
|
||||
type forwardPathManager struct {
|
||||
dummyPathManager
|
||||
}
|
||||
|
||||
func (forwardPathManager) APIForwardDestList(string) (*defs.APIForwardDestList, error) {
|
||||
return &defs.APIForwardDestList{
|
||||
ItemCount: 1,
|
||||
PageCount: 1,
|
||||
Items: []defs.APIForwardDest{{
|
||||
ID: uuid.MustParse("5b9a82ca-3cb8-46d1-a80b-6b716ccfcafe"),
|
||||
Pos: 1,
|
||||
Created: time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC),
|
||||
Conf: conf.ForwardDest{Dest: "rtmp://example.com/live/stream"},
|
||||
Protocol: defs.APIForwardDestProtocolRTMP,
|
||||
State: defs.APIForwardDestStateForwarding,
|
||||
OutboundBytes: 321,
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type dummyHLSServer struct{}
|
||||
|
||||
func (dummyHLSServer) APIMuxersList() (*defs.APIHLSMuxerList, error) {
|
||||
@@ -364,6 +392,14 @@ func (emptyPathManager) APIPathsGet(string) (*defs.APIPath, error) {
|
||||
panic("unused")
|
||||
}
|
||||
|
||||
func (emptyPathManager) APIForwardDestList(string) (*defs.APIForwardDestList, error) {
|
||||
return &defs.APIForwardDestList{}, nil
|
||||
}
|
||||
|
||||
func (emptyPathManager) APIForwardDestGet(string, uuid.UUID) (*defs.APIForwardDest, error) {
|
||||
panic("unused")
|
||||
}
|
||||
|
||||
type emptyHLSServer struct{}
|
||||
|
||||
func (emptyHLSServer) APIMuxersList() (*defs.APIHLSMuxerList, error) {
|
||||
@@ -1053,6 +1089,44 @@ func TestZeroMetricsFallback(t *testing.T) {
|
||||
string(byts))
|
||||
}
|
||||
|
||||
func TestForwardMetrics(t *testing.T) {
|
||||
m := Metrics{
|
||||
Address: "localhost:9998",
|
||||
AllowOrigins: []string{"*"},
|
||||
ReadTimeout: conf.Duration(10 * time.Second),
|
||||
WriteTimeout: conf.Duration(10 * time.Second),
|
||||
AuthManager: test.NilAuthManager,
|
||||
Parent: test.NilLogger,
|
||||
}
|
||||
err := m.Initialize()
|
||||
require.NoError(t, err)
|
||||
defer m.Close()
|
||||
|
||||
m.SetPathManager(&forwardPathManager{})
|
||||
|
||||
tr := &http.Transport{}
|
||||
defer tr.CloseIdleConnections()
|
||||
hc := &http.Client{Transport: tr}
|
||||
|
||||
res, err := hc.Get("http://localhost:9998/metrics?type=forward_dests")
|
||||
require.NoError(t, err)
|
||||
defer res.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusOK, res.StatusCode)
|
||||
|
||||
byts, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t,
|
||||
"# Forward destinations\n"+
|
||||
"forward_dests{id=\"5b9a82ca-3cb8-46d1-a80b-6b716ccfcafe\","+
|
||||
"path=\"mypath\",protocol=\"rtmp\",state=\"forwarding\"} 1\n"+
|
||||
"forward_dests_outbound_bytes{id=\"5b9a82ca-3cb8-46d1-a80b-6b716ccfcafe\",path=\"mypath\","+
|
||||
"protocol=\"rtmp\",state=\"forwarding\"} 321\n"+
|
||||
"\n",
|
||||
string(byts))
|
||||
}
|
||||
|
||||
func TestFilter(t *testing.T) {
|
||||
for _, ca := range []string{
|
||||
"path",
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/bluenviron/gortmplib"
|
||||
"github.com/bluenviron/gortmplib/pkg/amf0"
|
||||
"github.com/bluenviron/gortmplib/pkg/codecs"
|
||||
"github.com/bluenviron/gortmplib/pkg/message"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/description"
|
||||
@@ -46,14 +47,15 @@ func FromStream(
|
||||
origDesc *description.Session,
|
||||
outDesc *description.Session,
|
||||
r *stream.Reader,
|
||||
conn *gortmplib.ServerConn,
|
||||
conn gortmplib.Conn,
|
||||
nconn net.Conn,
|
||||
writeTimeout time.Duration,
|
||||
fourCcList amf0.StrictArray,
|
||||
) error {
|
||||
var tracks []*gortmplib.Track
|
||||
var w *gortmplib.Writer
|
||||
|
||||
isEnhanced := len(conn.FourCcList) != 0
|
||||
isEnhanced := len(fourCcList) != 0
|
||||
legacyVideoTrackCount := 0
|
||||
legacyAudioTrackCount := 0
|
||||
|
||||
@@ -61,7 +63,7 @@ func FromStream(
|
||||
for j, origFormat := range origMedia.Formats {
|
||||
switch origFormat := origFormat.(type) {
|
||||
case *format.AV1:
|
||||
if slices.Contains(conn.FourCcList, any(fourCCToString(message.FourCCAV1))) {
|
||||
if slices.Contains(fourCcList, any(fourCCToString(message.FourCCAV1))) {
|
||||
track := &gortmplib.Track{
|
||||
Codec: &codecs.AV1{},
|
||||
}
|
||||
@@ -84,7 +86,7 @@ func FromStream(
|
||||
}
|
||||
|
||||
case *format.VP9:
|
||||
if slices.Contains(conn.FourCcList, any(fourCCToString(message.FourCCVP9))) {
|
||||
if slices.Contains(fourCcList, any(fourCCToString(message.FourCCVP9))) {
|
||||
track := &gortmplib.Track{
|
||||
Codec: &codecs.VP9{},
|
||||
}
|
||||
@@ -107,7 +109,7 @@ func FromStream(
|
||||
}
|
||||
|
||||
case *format.H265:
|
||||
if slices.Contains(conn.FourCcList, any(fourCCToString(message.FourCCHEVC))) {
|
||||
if slices.Contains(fourCcList, any(fourCCToString(message.FourCCHEVC))) {
|
||||
outFormat := outDesc.Medias[i].Formats[j].(*format.H265)
|
||||
|
||||
track := &gortmplib.Track{
|
||||
@@ -216,7 +218,7 @@ func FromStream(
|
||||
}
|
||||
|
||||
case *format.Opus:
|
||||
if slices.Contains(conn.FourCcList, any(fourCCToString(message.FourCCOpus))) {
|
||||
if slices.Contains(fourCcList, any(fourCCToString(message.FourCCOpus))) {
|
||||
track := &gortmplib.Track{
|
||||
Codec: &codecs.Opus{
|
||||
IDHeader: &opus.IDHeader{
|
||||
@@ -369,7 +371,7 @@ func FromStream(
|
||||
}
|
||||
|
||||
case *format.AC3:
|
||||
if slices.Contains(conn.FourCcList, any(fourCCToString(message.FourCCAC3))) {
|
||||
if slices.Contains(fourCcList, any(fourCCToString(message.FourCCAC3))) {
|
||||
track := &gortmplib.Track{
|
||||
Codec: &codecs.AC3{
|
||||
SampleRate: origFormat.SampleRate,
|
||||
@@ -467,7 +469,7 @@ func FromStream(
|
||||
|
||||
case *format.Generic:
|
||||
if strings.HasPrefix(strings.ToLower(origFormat.RTPMap()), "flac/") &&
|
||||
slices.Contains(conn.FourCcList, any(fourCCToString(message.FourCCFLAC))) {
|
||||
slices.Contains(fourCcList, any(fourCCToString(message.FourCCFLAC))) {
|
||||
enc, err := hex.DecodeString(origFormat.FMT["streaminfo"])
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -673,7 +673,7 @@ func TestFromStream(t *testing.T) {
|
||||
|
||||
r := &stream.Reader{Parent: test.NilLogger}
|
||||
|
||||
err = FromStream(strm.OrigDesc, strm.OutDescCopy(), r, conn, nconn, 10*time.Second)
|
||||
err = FromStream(strm.OrigDesc, strm.OutDescCopy(), r, conn, nconn, 10*time.Second, conn.FourCcList)
|
||||
require.NoError(t, err)
|
||||
|
||||
strm.AddReader(r)
|
||||
@@ -819,7 +819,7 @@ func TestFromStreamLegacyClientMultipleTracks(t *testing.T) {
|
||||
|
||||
r := &stream.Reader{Parent: test.NilLogger}
|
||||
|
||||
err = FromStream(strm.OrigDesc, strm.OutDescCopy(), r, conn, nconn, 10*time.Second)
|
||||
err = FromStream(strm.OrigDesc, strm.OutDescCopy(), r, conn, nconn, 10*time.Second, conn.FourCcList)
|
||||
require.NoError(t, err)
|
||||
|
||||
strm.AddReader(r)
|
||||
@@ -857,7 +857,7 @@ func TestFromStreamNoSupportedCodecs(t *testing.T) {
|
||||
|
||||
conn := &gortmplib.ServerConn{}
|
||||
|
||||
err := FromStream(desc, desc, r, conn, nil, 0)
|
||||
err := FromStream(desc, desc, r, conn, nil, 0, nil)
|
||||
require.Equal(t, errNoSupportedCodecsFrom, err)
|
||||
}
|
||||
|
||||
@@ -913,7 +913,7 @@ func TestFromStreamSkipUnsupportedTracks(t *testing.T) {
|
||||
err = conn.Accept()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = FromStream(desc, desc, r, conn, nil, 0)
|
||||
err = FromStream(desc, desc, r, conn, nil, 0, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 1, n)
|
||||
|
||||
@@ -192,7 +192,8 @@ func (c *conn) runRead() error {
|
||||
r,
|
||||
c.rconn,
|
||||
c.nconn,
|
||||
time.Duration(c.writeTimeout))
|
||||
time.Duration(c.writeTimeout),
|
||||
c.rconn.FourCcList)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+18
-3
@@ -491,10 +491,9 @@ pathDefaults:
|
||||
# * wheps://host:port/path -> the stream is pulled from another WebRTC server / camera with HTTPS+WHEP
|
||||
# * redirect -> the stream is provided by another path or server
|
||||
# * rpiCamera -> the stream is provided by a Raspberry Pi Camera
|
||||
# The following variables can be used in the source string:
|
||||
# The following variables can be used:
|
||||
# * $MTX_QUERY: query parameters (passed by first reader)
|
||||
# * $G1, $G2, ...: regular expression groups, if path name is
|
||||
# a regular expression.
|
||||
# * $G1, $G2, ...: regular expression groups, if path name is a regular expression.
|
||||
source: publisher
|
||||
# If the source is a URL, and the source TLS certificate is self-signed
|
||||
# or invalid, you can provide the fingerprint of the certificate in order to
|
||||
@@ -535,6 +534,22 @@ pathDefaults:
|
||||
# An MP4 file can be used instead of the default offline segment.
|
||||
alwaysAvailableFile: ""
|
||||
|
||||
###############################################
|
||||
# Default path settings -> Forward
|
||||
|
||||
# Forward the stream of this path to one or more external servers.
|
||||
forward: []
|
||||
# forward destination. This can be:
|
||||
# * rtsp://user:pass@host:port/path -> the stream is forwarded to another RTSP server
|
||||
# * rtsps://user:pass@host:port/path -> the stream is forwarded to another RTSP server with RTSPS
|
||||
# * rtmp://user:pass@host:port/path#streamKey -> the stream is forwarded to another RTMP server
|
||||
# * rtmps://user:pass@host:port/path#streamKey -> the stream is forwarded to another RTMP server with RTMPS
|
||||
# * srt://host:port?streamid=streamid -> the stream is forwarded to another SRT server
|
||||
# The following variables can be used:
|
||||
# * $MTX_PATH: path name
|
||||
# * $G1, $G2, ...: regular expression groups, if path name is a regular expression.
|
||||
# - dest:
|
||||
|
||||
###############################################
|
||||
# Default path settings -> Record
|
||||
|
||||
|
||||
Reference in New Issue
Block a user