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:
Aniol Pagès
2026-08-04 21:57:15 +02:00
committed by GitHub
co-authored by aler9
parent f36c41d90a
commit 98ab3009ea
44 changed files with 2957 additions and 54 deletions
+6 -6
View File
@@ -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
+161
View File
@@ -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 -6
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
# Publish a stream
# Publish streams
Live streams can be published to the server with the following protocols:
+1 -1
View File
@@ -1,4 +1,4 @@
# Read a stream
# Read streams
Live streams can be read from the server with the following protocols:
+1 -1
View File
@@ -1,4 +1,4 @@
# Record
# Record streams
Live streams be recorded to disk and played back with the following file containers and codecs:
+1 -1
View File
@@ -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:
+45 -2
View File
@@ -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 -1
View File
@@ -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:
+6 -1
View File
@@ -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
+2
View File
@@ -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)
+67
View File
@@ -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)
}
+104
View File
@@ -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)
}
+10
View File
@@ -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{
+10
View File
@@ -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",
+97
View File
@@ -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
+21
View File
@@ -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
+9
View File
@@ -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))
+20
View File
@@ -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
}
+40
View File
@@ -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
View File
@@ -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)
}
+1
View File
@@ -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,
+358
View File
@@ -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
View File
@@ -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)
}
+91 -12
View File
@@ -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")
}
}
+49
View File
@@ -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"`
}
+4
View File
@@ -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.
+11
View File
@@ -0,0 +1,11 @@
package forward
import (
"context"
)
// Dest is a protocol-specific forward destination.
type Dest interface {
Run(context.Context) error
OutboundBytes() uint64
}
+258
View File
@@ -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,
}
}
+43
View File
@@ -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)
})
}
}
+154
View File
@@ -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}
}
+171
View File
@@ -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)
})
}
}
+162
View File
@@ -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
}
}
+157
View File
@@ -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")
}
}
+153
View File
@@ -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
}
}
+210
View File
@@ -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
}
+135
View File
@@ -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
}
}
+159
View File
@@ -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")
}
}
+56
View File
@@ -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
+74
View File
@@ -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 -8
View File
@@ -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
+4 -4
View File
@@ -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)
+2 -1
View File
@@ -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
View File
@@ -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