From 98ab3009ead69ab78f82cb9dc1dec091a3570e4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aniol=20Pag=C3=A8s?= <47257568+aniolpages@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:57:15 +0200 Subject: [PATCH] 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> --- README.md | 12 +- api/openapi.yaml | 161 +++++++++ docs/1-kickoff/1-introduction.md | 12 +- docs/2-features/03-publish.md | 2 +- docs/2-features/04-read.md | 2 +- docs/2-features/09-record.md | 2 +- docs/2-features/10-playback.md | 2 +- docs/2-features/11-forward.md | 47 ++- docs/2-features/12-proxy.md | 2 +- docs/2-features/23-metrics.md | 7 +- internal/api/api.go | 2 + internal/api/api_forward.go | 67 ++++ internal/api/api_forward_test.go | 104 ++++++ internal/api/api_paths_test.go | 10 + internal/apidocsgen/enums.go | 10 + internal/apidocsgen/openapi.template.yaml | 97 ++++++ internal/apidocsgen/structs.go | 21 ++ internal/conf/conf_test.go | 9 + internal/conf/forward.go | 20 ++ internal/conf/forward_dest.go | 40 +++ internal/conf/path.go | 12 +- internal/core/core.go | 1 + internal/core/forward_test.go | 358 ++++++++++++++++++++ internal/core/path.go | 62 +++- internal/core/path_manager.go | 103 +++++- internal/defs/api_forward.go | 49 +++ internal/defs/api_path.go | 4 + internal/forward/dest.go | 11 + internal/forward/dest_handler.go | 258 ++++++++++++++ internal/forward/dest_handler_test.go | 43 +++ internal/forward/manager.go | 154 +++++++++ internal/forward/manager_test.go | 171 ++++++++++ internal/forward/rtmp/dest.go | 162 +++++++++ internal/forward/rtmp/dest_test.go | 157 +++++++++ internal/forward/rtsp/dest.go | 153 +++++++++ internal/forward/rtsp/dest_test.go | 210 ++++++++++++ internal/forward/srt/dest.go | 135 ++++++++ internal/forward/srt/dest_test.go | 159 +++++++++ internal/metrics/metrics.go | 56 +++ internal/metrics/metrics_test.go | 74 ++++ internal/protocols/rtmp/from_stream.go | 18 +- internal/protocols/rtmp/from_stream_test.go | 8 +- internal/servers/rtmp/conn.go | 3 +- mediamtx.yml | 21 +- 44 files changed, 2957 insertions(+), 54 deletions(-) create mode 100644 internal/api/api_forward.go create mode 100644 internal/api/api_forward_test.go create mode 100644 internal/conf/forward.go create mode 100644 internal/conf/forward_dest.go create mode 100644 internal/core/forward_test.go create mode 100644 internal/defs/api_forward.go create mode 100644 internal/forward/dest.go create mode 100644 internal/forward/dest_handler.go create mode 100644 internal/forward/dest_handler_test.go create mode 100644 internal/forward/manager.go create mode 100644 internal/forward/manager_test.go create mode 100644 internal/forward/rtmp/dest.go create mode 100644 internal/forward/rtmp/dest_test.go create mode 100644 internal/forward/rtsp/dest.go create mode 100644 internal/forward/rtsp/dest_test.go create mode 100644 internal/forward/srt/dest.go create mode 100644 internal/forward/srt/dest_test.go diff --git a/README.md b/README.md index 14b98e09..9e692063 100644 --- a/README.md +++ b/README.md @@ -28,17 +28,17 @@ _MediaMTX_ is a ready-to-use and zero-dependency live media server and media pro

Features

-- [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 diff --git a/api/openapi.yaml b/api/openapi.yaml index 90cb47d9..cd04112a 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -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 diff --git a/docs/1-kickoff/1-introduction.md b/docs/1-kickoff/1-introduction.md index 9a23b31d..0eb23ec9 100644 --- a/docs/1-kickoff/1-introduction.md +++ b/docs/1-kickoff/1-introduction.md @@ -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 diff --git a/docs/2-features/03-publish.md b/docs/2-features/03-publish.md index 4ff73f13..ba5ed900 100644 --- a/docs/2-features/03-publish.md +++ b/docs/2-features/03-publish.md @@ -1,4 +1,4 @@ -# Publish a stream +# Publish streams Live streams can be published to the server with the following protocols: diff --git a/docs/2-features/04-read.md b/docs/2-features/04-read.md index 7f729289..28a0e1bb 100644 --- a/docs/2-features/04-read.md +++ b/docs/2-features/04-read.md @@ -1,4 +1,4 @@ -# Read a stream +# Read streams Live streams can be read from the server with the following protocols: diff --git a/docs/2-features/09-record.md b/docs/2-features/09-record.md index 6c256309..1af53be7 100644 --- a/docs/2-features/09-record.md +++ b/docs/2-features/09-record.md @@ -1,4 +1,4 @@ -# Record +# Record streams Live streams be recorded to disk and played back with the following file containers and codecs: diff --git a/docs/2-features/10-playback.md b/docs/2-features/10-playback.md index ef5c9aaf..55433157 100644 --- a/docs/2-features/10-playback.md +++ b/docs/2-features/10-playback.md @@ -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: diff --git a/docs/2-features/11-forward.md b/docs/2-features/11-forward.md index b0fe0bf1..87290cb3 100644 --- a/docs/2-features/11-forward.md +++ b/docs/2-features/11-forward.md @@ -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: diff --git a/docs/2-features/12-proxy.md b/docs/2-features/12-proxy.md index 04b5f28c..fdc1affc 100644 --- a/docs/2-features/12-proxy.md +++ b/docs/2-features/12-proxy.md @@ -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: diff --git a/docs/2-features/23-metrics.md b/docs/2-features/23-metrics.md index 91c473de..af4db4c6 100644 --- a/docs/2-features/23-metrics.md +++ b/docs/2-features/23-metrics.md @@ -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 diff --git a/internal/api/api.go b/internal/api/api.go index 7ada7380..76399fb4 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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) diff --git a/internal/api/api_forward.go b/internal/api/api_forward.go new file mode 100644 index 00000000..1cccab32 --- /dev/null +++ b/internal/api/api_forward.go @@ -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) +} diff --git a/internal/api/api_forward_test.go b/internal/api/api_forward_test.go new file mode 100644 index 00000000..2265be72 --- /dev/null +++ b/internal/api/api_forward_test.go @@ -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) +} diff --git a/internal/api/api_paths_test.go b/internal/api/api_paths_test.go index e27dc985..8f17328f 100644 --- a/internal/api/api_paths_test.go +++ b/internal/api/api_paths_test.go @@ -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{ diff --git a/internal/apidocsgen/enums.go b/internal/apidocsgen/enums.go index 8a8bef2a..d72f205c 100644 --- a/internal/apidocsgen/enums.go +++ b/internal/apidocsgen/enums.go @@ -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", diff --git a/internal/apidocsgen/openapi.template.yaml b/internal/apidocsgen/openapi.template.yaml index f8920500..518dff10 100644 --- a/internal/apidocsgen/openapi.template.yaml +++ b/internal/apidocsgen/openapi.template.yaml @@ -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 diff --git a/internal/apidocsgen/structs.go b/internal/apidocsgen/structs.go index bc42be8a..98a9fb93 100644 --- a/internal/apidocsgen/structs.go +++ b/internal/apidocsgen/structs.go @@ -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 diff --git a/internal/conf/conf_test.go b/internal/conf/conf_test.go index 3885c06b..fe028cc8 100644 --- a/internal/conf/conf_test.go +++ b/internal/conf/conf_test.go @@ -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)) diff --git a/internal/conf/forward.go b/internal/conf/forward.go new file mode 100644 index 00000000..57dc38dd --- /dev/null +++ b/internal/conf/forward.go @@ -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 +} diff --git a/internal/conf/forward_dest.go b/internal/conf/forward_dest.go new file mode 100644 index 00000000..1fd8d1ff --- /dev/null +++ b/internal/conf/forward_dest.go @@ -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 +} diff --git a/internal/conf/path.go b/internal/conf/path.go index 9b4763a6..055ace48 100644 --- a/internal/conf/path.go +++ b/internal/conf/path.go @@ -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) } diff --git a/internal/core/core.go b/internal/core/core.go index 2f935c18..93d828f3 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -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, diff --git a/internal/core/forward_test.go b/internal/core/forward_test.go new file mode 100644 index 00000000..24f21ae1 --- /dev/null +++ b/internal/core/forward_test.go @@ -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)) +} diff --git a/internal/core/path.go b/internal/core/path.go index 51af92d2..1bba5f0a 100644 --- a/internal/core/path.go +++ b/internal/core/path.go @@ -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) +} diff --git a/internal/core/path_manager.go b/internal/core/path_manager.go index ed82db3e..248c3b92 100644 --- a/internal/core/path_manager.go +++ b/internal/core/path_manager.go @@ -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") + } +} diff --git a/internal/defs/api_forward.go b/internal/defs/api_forward.go new file mode 100644 index 00000000..f3c773da --- /dev/null +++ b/internal/defs/api_forward.go @@ -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"` +} diff --git a/internal/defs/api_path.go b/internal/defs/api_path.go index 74c747b4..33d8c377 100644 --- a/internal/defs/api_path.go +++ b/internal/defs/api_path.go @@ -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. diff --git a/internal/forward/dest.go b/internal/forward/dest.go new file mode 100644 index 00000000..ce5958e4 --- /dev/null +++ b/internal/forward/dest.go @@ -0,0 +1,11 @@ +package forward + +import ( + "context" +) + +// Dest is a protocol-specific forward destination. +type Dest interface { + Run(context.Context) error + OutboundBytes() uint64 +} diff --git a/internal/forward/dest_handler.go b/internal/forward/dest_handler.go new file mode 100644 index 00000000..95cf76ee --- /dev/null +++ b/internal/forward/dest_handler.go @@ -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, + } +} diff --git a/internal/forward/dest_handler_test.go b/internal/forward/dest_handler_test.go new file mode 100644 index 00000000..7cf5566e --- /dev/null +++ b/internal/forward/dest_handler_test.go @@ -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) + }) + } +} diff --git a/internal/forward/manager.go b/internal/forward/manager.go new file mode 100644 index 00000000..71d26010 --- /dev/null +++ b/internal/forward/manager.go @@ -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} +} diff --git a/internal/forward/manager_test.go b/internal/forward/manager_test.go new file mode 100644 index 00000000..73c2e032 --- /dev/null +++ b/internal/forward/manager_test.go @@ -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) + }) + } +} diff --git a/internal/forward/rtmp/dest.go b/internal/forward/rtmp/dest.go new file mode 100644 index 00000000..e3b9bfc2 --- /dev/null +++ b/internal/forward/rtmp/dest.go @@ -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 + } +} diff --git a/internal/forward/rtmp/dest_test.go b/internal/forward/rtmp/dest_test.go new file mode 100644 index 00000000..79f834dd --- /dev/null +++ b/internal/forward/rtmp/dest_test.go @@ -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") + } +} diff --git a/internal/forward/rtsp/dest.go b/internal/forward/rtsp/dest.go new file mode 100644 index 00000000..da912a9b --- /dev/null +++ b/internal/forward/rtsp/dest.go @@ -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 + } +} diff --git a/internal/forward/rtsp/dest_test.go b/internal/forward/rtsp/dest_test.go new file mode 100644 index 00000000..14d625ec --- /dev/null +++ b/internal/forward/rtsp/dest_test.go @@ -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 +} diff --git a/internal/forward/srt/dest.go b/internal/forward/srt/dest.go new file mode 100644 index 00000000..25efb0ff --- /dev/null +++ b/internal/forward/srt/dest.go @@ -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 + } +} diff --git a/internal/forward/srt/dest_test.go b/internal/forward/srt/dest_test.go new file mode 100644 index 00000000..0e843754 --- /dev/null +++ b/internal/forward/srt/dest_test.go @@ -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") + } +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 3ebf95e6..7247cc0c 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -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 diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go index 7316e1df..0de9479f 100644 --- a/internal/metrics/metrics_test.go +++ b/internal/metrics/metrics_test.go @@ -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", diff --git a/internal/protocols/rtmp/from_stream.go b/internal/protocols/rtmp/from_stream.go index 98e62b63..18b50cc3 100644 --- a/internal/protocols/rtmp/from_stream.go +++ b/internal/protocols/rtmp/from_stream.go @@ -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 diff --git a/internal/protocols/rtmp/from_stream_test.go b/internal/protocols/rtmp/from_stream_test.go index 574162f0..0a4cfe83 100644 --- a/internal/protocols/rtmp/from_stream_test.go +++ b/internal/protocols/rtmp/from_stream_test.go @@ -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) diff --git a/internal/servers/rtmp/conn.go b/internal/servers/rtmp/conn.go index 2ffba0df..2932864b 100644 --- a/internal/servers/rtmp/conn.go +++ b/internal/servers/rtmp/conn.go @@ -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 } diff --git a/mediamtx.yml b/mediamtx.yml index bba771c1..248bf911 100644 --- a/mediamtx.yml +++ b/mediamtx.yml @@ -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