From a56408db19669b7d53ef3246071bb90bd49a185f Mon Sep 17 00:00:00 2001 From: Alessandro Ros Date: Sat, 31 Jan 2026 14:44:58 +0100 Subject: [PATCH] add always available streams (#5335) When the publisher or source of a stream is offline, the server can be configured to fill gaps in the stream with a video that is played on repeat until a publisher comes back online. This allows readers to stay connected regardless of the state of the stream. The offline video and any future online stream are concatenated without decoding or re-encoding packets, using the original codec. --- README.md | 1 + api/openapi.yaml | 24 ++ docs/1-kickoff/1-introduction.md | 1 + docs/1-kickoff/2-install.md | 2 + docs/2-usage/07-always-available.md | 29 ++ docs/2-usage/{07-record.md => 08-record.md} | 0 .../{08-playback.md => 09-playback.md} | 0 docs/2-usage/{09-forward.md => 10-forward.md} | 0 docs/2-usage/{10-proxy.md => 11-proxy.md} | 0 ...t-snapshots.md => 12-extract-snapshots.md} | 0 ...blishing.md => 13-on-demand-publishing.md} | 0 ...mps.md => 14-route-absolute-timestamps.md} | 0 ...=> 15-expose-the-server-in-a-subfolder.md} | 0 ...te.md => 16-embed-streams-in-a-website.md} | 0 ...6-start-on-boot.md => 17-start-on-boot.md} | 0 docs/2-usage/{17-logging.md => 18-logging.md} | 0 docs/2-usage/{18-hooks.md => 19-hooks.md} | 0 .../{19-control-api.md => 20-control-api.md} | 0 docs/2-usage/{20-metrics.md => 21-metrics.md} | 0 .../{21-performance.md => 22-performance.md} | 0 ...eatures.md => 23-srt-specific-features.md} | 0 ...ures.md => 24-webrtc-specific-features.md} | 0 ...atures.md => 25-rtsp-specific-features.md} | 0 ...atures.md => 26-rtmp-specific-features.md} | 0 ...ket-loss.md => 27-decrease-packet-loss.md} | 0 go.mod | 2 +- go.sum | 4 +- internal/conf/always_available_track.go | 67 ++++ internal/conf/auth_method.go | 44 +-- internal/conf/conf.go | 3 + internal/conf/conf_test.go | 11 +- internal/conf/encryption.go | 47 +-- internal/conf/path.go | 94 ++++- internal/conf/record_format.go | 36 +- internal/conf/rtsp_range_type.go | 52 +-- internal/core/path.go | 155 +++++++-- internal/core/path_manager.go | 2 +- internal/defs/path.go | 12 +- internal/hooks/on_ready.go | 8 +- internal/playback/on_get_test.go | 27 +- internal/playback/on_list_test.go | 10 +- internal/playback/segment_fmp4.go | 10 +- internal/protocols/hls/to_stream.go | 17 +- internal/protocols/hls/to_stream_test.go | 11 +- internal/protocols/mpegts/to_stream.go | 22 +- internal/protocols/mpegts/to_stream_test.go | 7 +- internal/protocols/rtmp/from_stream_test.go | 76 ++-- internal/protocols/rtmp/to_stream.go | 22 +- internal/protocols/rtsp/to_stream.go | 4 +- internal/protocols/webrtc/from_stream_test.go | 12 +- internal/protocols/webrtc/to_stream.go | 4 +- internal/protocols/webrtc/to_stream_test.go | 4 +- internal/recorder/format_fmp4.go | 1 - internal/recorder/recorder_test.go | 119 +++++-- internal/servers/hls/server_test.go | 46 ++- internal/servers/rtmp/conn.go | 6 +- internal/servers/rtmp/server.go | 2 +- internal/servers/rtmp/server_test.go | 26 +- internal/servers/rtsp/server.go | 2 +- internal/servers/rtsp/server_test.go | 30 +- internal/servers/rtsp/session.go | 8 +- internal/servers/srt/conn.go | 6 +- internal/servers/srt/server.go | 2 +- internal/servers/srt/server_test.go | 24 +- internal/servers/webrtc/server.go | 2 +- internal/servers/webrtc/server_test.go | 24 +- internal/servers/webrtc/session.go | 6 +- internal/staticsources/handler.go | 8 +- internal/staticsources/hls/source.go | 8 +- internal/staticsources/mpegts/source.go | 6 +- internal/staticsources/rpicamera/source.go | 28 +- internal/staticsources/rtmp/source.go | 6 +- internal/staticsources/rtp/source.go | 10 +- internal/staticsources/rtsp/source.go | 6 +- internal/staticsources/srt/source.go | 6 +- internal/staticsources/webrtc/source.go | 6 +- internal/stream/offline_av1.mp4 | Bin 0 -> 10274 bytes internal/stream/offline_h264.mp4 | Bin 0 -> 18347 bytes internal/stream/offline_h265.mp4 | Bin 0 -> 17557 bytes internal/stream/offline_sub_stream.go | 68 ++++ internal/stream/offline_sub_stream_track.go | 327 ++++++++++++++++++ internal/stream/offline_vp9.mp4 | Bin 0 -> 31267 bytes internal/stream/rtp_encoder.go | 12 +- internal/stream/stream.go | 291 ++++++++++++++-- internal/stream/stream_format.go | 153 +------- internal/stream/stream_media.go | 4 +- internal/stream/stream_test.go | 139 +++++++- internal/stream/sub_stream.go | 157 +++++++++ internal/stream/sub_stream_format.go | 188 ++++++++++ internal/stream/sub_stream_media.go | 35 ++ internal/test/formats.go | 7 +- internal/test/path_manager.go | 4 +- internal/test/static_source_parent.go | 12 +- internal/testapidocs/apidocs_test.go | 47 +-- mediamtx.yml | 17 + 95 files changed, 2028 insertions(+), 641 deletions(-) create mode 100644 docs/2-usage/07-always-available.md rename docs/2-usage/{07-record.md => 08-record.md} (100%) rename docs/2-usage/{08-playback.md => 09-playback.md} (100%) rename docs/2-usage/{09-forward.md => 10-forward.md} (100%) rename docs/2-usage/{10-proxy.md => 11-proxy.md} (100%) rename docs/2-usage/{11-extract-snapshots.md => 12-extract-snapshots.md} (100%) rename docs/2-usage/{12-on-demand-publishing.md => 13-on-demand-publishing.md} (100%) rename docs/2-usage/{13-route-absolute-timestamps.md => 14-route-absolute-timestamps.md} (100%) rename docs/2-usage/{14-expose-the-server-in-a-subfolder.md => 15-expose-the-server-in-a-subfolder.md} (100%) rename docs/2-usage/{15-embed-streams-in-a-website.md => 16-embed-streams-in-a-website.md} (100%) rename docs/2-usage/{16-start-on-boot.md => 17-start-on-boot.md} (100%) rename docs/2-usage/{17-logging.md => 18-logging.md} (100%) rename docs/2-usage/{18-hooks.md => 19-hooks.md} (100%) rename docs/2-usage/{19-control-api.md => 20-control-api.md} (100%) rename docs/2-usage/{20-metrics.md => 21-metrics.md} (100%) rename docs/2-usage/{21-performance.md => 22-performance.md} (100%) rename docs/2-usage/{22-srt-specific-features.md => 23-srt-specific-features.md} (100%) rename docs/2-usage/{23-webrtc-specific-features.md => 24-webrtc-specific-features.md} (100%) rename docs/2-usage/{24-rtsp-specific-features.md => 25-rtsp-specific-features.md} (100%) rename docs/2-usage/{25-rtmp-specific-features.md => 26-rtmp-specific-features.md} (100%) rename docs/2-usage/{26-decrease-packet-loss.md => 27-decrease-packet-loss.md} (100%) create mode 100644 internal/conf/always_available_track.go create mode 100644 internal/stream/offline_av1.mp4 create mode 100644 internal/stream/offline_h264.mp4 create mode 100644 internal/stream/offline_h265.mp4 create mode 100644 internal/stream/offline_sub_stream.go create mode 100644 internal/stream/offline_sub_stream_track.go create mode 100644 internal/stream/offline_vp9.mp4 create mode 100644 internal/stream/sub_stream.go create mode 100644 internal/stream/sub_stream_format.go create mode 100644 internal/stream/sub_stream_media.go diff --git a/README.md b/README.md index 85010774..7fc8b761 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ _MediaMTX_ is a ready-to-use and zero-dependency real-time media server and medi - 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/usage/always-available) even when the publisher is offline - [Record](https://mediamtx.org/docs/usage/record) streams to disk in fMP4 or MPEG-TS format - [Playback](https://mediamtx.org/docs/usage/playback) recorded streams - [Authenticate](https://mediamtx.org/docs/usage/authentication) users with internal, HTTP or JWT authentication diff --git a/api/openapi.yaml b/api/openapi.yaml index 2a7fddaa..855cffd4 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -377,6 +377,16 @@ components: useAbsoluteTimestamp: type: boolean + # Always available + alwaysAvailable: + type: boolean + alwaysAvailableFile: + type: string + alwaysAvailableTracks: + type: array + items: + $ref: '#/components/schemas/AlwaysAvailableTrack' + # Record record: type: boolean @@ -1088,6 +1098,20 @@ components: items: $ref: '#/components/schemas/SRTConn' + AlwaysAvailableTrack: + type: object + properties: + codec: + type: string + sampleRate: + type: integer + format: int64 + channelCount: + type: integer + format: int64 + muLaw: + type: boolean + WebRTCICEServer: type: object properties: diff --git a/docs/1-kickoff/1-introduction.md b/docs/1-kickoff/1-introduction.md index 623ff2c7..10082f19 100644 --- a/docs/1-kickoff/1-introduction.md +++ b/docs/1-kickoff/1-introduction.md @@ -11,6 +11,7 @@ Main features: - 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](/docs/usage/always-available) even when the publisher is offline - [Record](/docs/usage/record) streams to disk in fMP4 or MPEG-TS format - [Playback](/docs/usage/playback) recorded streams - [Authenticate](/docs/usage/authentication) users with internal, HTTP or JWT authentication diff --git a/docs/1-kickoff/2-install.md b/docs/1-kickoff/2-install.md index 97bc0dcf..37f2889c 100644 --- a/docs/1-kickoff/2-install.md +++ b/docs/1-kickoff/2-install.md @@ -59,6 +59,8 @@ COPY --from=mediamtx.yml / RUN apt update && apt install -y \ (insert here additional utilities) + +ENTRYPOINT [ "/mediamtx" ] ``` And then build it: diff --git a/docs/2-usage/07-always-available.md b/docs/2-usage/07-always-available.md new file mode 100644 index 00000000..fecaac9c --- /dev/null +++ b/docs/2-usage/07-always-available.md @@ -0,0 +1,29 @@ +# Always-available streams + +When the publisher or source of a stream is offline, the server can be configured to fill gaps in the stream with a video that is played on repeat until a publisher comes back online. This allows readers to stay connected regardless of the state of the stream. The offline video and any future online stream are concatenated without decoding or re-encoding packets, using the original codec. + +This feature can be enabled by toggling the `alwaysAvailable` flag and filling `alwaysAvailableTracks`: + +```yml +paths: + mypath: + alwaysAvailable: true + alwaysAvailableTracks: + # Available values are: AV1, VP9, H265, H264, Opus, MPEG4Audio, G711, LPCM + - codec: H264 + # in case of MPEG4Audio, G711, LPCM, sampleRate and ChannelCount must be provided too. + # sampleRate: 48000 + # channelCount: 2 + # in case of G711, muLaw must be provided too. + # muLaw: false +``` + +By default, the server uses a default offline video with the text "STREAM IS OFFLINE". This can be changed by importing the video from a MP4 file: + +```yml +paths: + mypath: + alwaysAvailable: true + # Path to the MP4 file that is played on repeat. If not provided, a default video will be used instead. + alwaysAvailableFile: "./h264.mp4" +``` diff --git a/docs/2-usage/07-record.md b/docs/2-usage/08-record.md similarity index 100% rename from docs/2-usage/07-record.md rename to docs/2-usage/08-record.md diff --git a/docs/2-usage/08-playback.md b/docs/2-usage/09-playback.md similarity index 100% rename from docs/2-usage/08-playback.md rename to docs/2-usage/09-playback.md diff --git a/docs/2-usage/09-forward.md b/docs/2-usage/10-forward.md similarity index 100% rename from docs/2-usage/09-forward.md rename to docs/2-usage/10-forward.md diff --git a/docs/2-usage/10-proxy.md b/docs/2-usage/11-proxy.md similarity index 100% rename from docs/2-usage/10-proxy.md rename to docs/2-usage/11-proxy.md diff --git a/docs/2-usage/11-extract-snapshots.md b/docs/2-usage/12-extract-snapshots.md similarity index 100% rename from docs/2-usage/11-extract-snapshots.md rename to docs/2-usage/12-extract-snapshots.md diff --git a/docs/2-usage/12-on-demand-publishing.md b/docs/2-usage/13-on-demand-publishing.md similarity index 100% rename from docs/2-usage/12-on-demand-publishing.md rename to docs/2-usage/13-on-demand-publishing.md diff --git a/docs/2-usage/13-route-absolute-timestamps.md b/docs/2-usage/14-route-absolute-timestamps.md similarity index 100% rename from docs/2-usage/13-route-absolute-timestamps.md rename to docs/2-usage/14-route-absolute-timestamps.md diff --git a/docs/2-usage/14-expose-the-server-in-a-subfolder.md b/docs/2-usage/15-expose-the-server-in-a-subfolder.md similarity index 100% rename from docs/2-usage/14-expose-the-server-in-a-subfolder.md rename to docs/2-usage/15-expose-the-server-in-a-subfolder.md diff --git a/docs/2-usage/15-embed-streams-in-a-website.md b/docs/2-usage/16-embed-streams-in-a-website.md similarity index 100% rename from docs/2-usage/15-embed-streams-in-a-website.md rename to docs/2-usage/16-embed-streams-in-a-website.md diff --git a/docs/2-usage/16-start-on-boot.md b/docs/2-usage/17-start-on-boot.md similarity index 100% rename from docs/2-usage/16-start-on-boot.md rename to docs/2-usage/17-start-on-boot.md diff --git a/docs/2-usage/17-logging.md b/docs/2-usage/18-logging.md similarity index 100% rename from docs/2-usage/17-logging.md rename to docs/2-usage/18-logging.md diff --git a/docs/2-usage/18-hooks.md b/docs/2-usage/19-hooks.md similarity index 100% rename from docs/2-usage/18-hooks.md rename to docs/2-usage/19-hooks.md diff --git a/docs/2-usage/19-control-api.md b/docs/2-usage/20-control-api.md similarity index 100% rename from docs/2-usage/19-control-api.md rename to docs/2-usage/20-control-api.md diff --git a/docs/2-usage/20-metrics.md b/docs/2-usage/21-metrics.md similarity index 100% rename from docs/2-usage/20-metrics.md rename to docs/2-usage/21-metrics.md diff --git a/docs/2-usage/21-performance.md b/docs/2-usage/22-performance.md similarity index 100% rename from docs/2-usage/21-performance.md rename to docs/2-usage/22-performance.md diff --git a/docs/2-usage/22-srt-specific-features.md b/docs/2-usage/23-srt-specific-features.md similarity index 100% rename from docs/2-usage/22-srt-specific-features.md rename to docs/2-usage/23-srt-specific-features.md diff --git a/docs/2-usage/23-webrtc-specific-features.md b/docs/2-usage/24-webrtc-specific-features.md similarity index 100% rename from docs/2-usage/23-webrtc-specific-features.md rename to docs/2-usage/24-webrtc-specific-features.md diff --git a/docs/2-usage/24-rtsp-specific-features.md b/docs/2-usage/25-rtsp-specific-features.md similarity index 100% rename from docs/2-usage/24-rtsp-specific-features.md rename to docs/2-usage/25-rtsp-specific-features.md diff --git a/docs/2-usage/25-rtmp-specific-features.md b/docs/2-usage/26-rtmp-specific-features.md similarity index 100% rename from docs/2-usage/25-rtmp-specific-features.md rename to docs/2-usage/26-rtmp-specific-features.md diff --git a/docs/2-usage/26-decrease-packet-loss.md b/docs/2-usage/27-decrease-packet-loss.md similarity index 100% rename from docs/2-usage/26-decrease-packet-loss.md rename to docs/2-usage/27-decrease-packet-loss.md diff --git a/go.mod b/go.mod index 439667f7..4cb6d807 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/bluenviron/gohlslib/v2 v2.2.5-0.20260117214804-b8c1ff42629d github.com/bluenviron/gortmplib v0.2.0 github.com/bluenviron/gortsplib/v5 v5.2.2 - github.com/bluenviron/mediacommon/v2 v2.6.0 + github.com/bluenviron/mediacommon/v2 v2.6.1-0.20260130191353-17b8857753e7 github.com/datarhei/gosrt v0.9.0 github.com/fsnotify/fsnotify v1.9.0 github.com/gin-contrib/pprof v1.5.3 diff --git a/go.sum b/go.sum index 745b7a67..231539a4 100644 --- a/go.sum +++ b/go.sum @@ -41,8 +41,8 @@ github.com/bluenviron/gortmplib v0.2.0 h1:j15eeHrgVh6Avg9oAx+r4w0HugTqrIqLBsYnhs github.com/bluenviron/gortmplib v0.2.0/go.mod h1:yzobxBF8zusF2nKbEOF69zIIL429j0kaCWc/euNdvO4= github.com/bluenviron/gortsplib/v5 v5.2.2 h1:5q2viB8PGxWOSXNhVvj8buyr1wighLbHqRZ0U7MLM3o= github.com/bluenviron/gortsplib/v5 v5.2.2/go.mod h1:xkVBOAnR4fzaerPN650CBb7N+zUUsj7PI2HiY1TP7Co= -github.com/bluenviron/mediacommon/v2 v2.6.0 h1:wZAPXwv7V78Cx2x7cToYIHOLToHl6APcvHbdQT+gOkg= -github.com/bluenviron/mediacommon/v2 v2.6.0/go.mod h1:5V15TiOfeaNVmZPVuOqAwqQSWyvMV86/dijDKu5q9Zs= +github.com/bluenviron/mediacommon/v2 v2.6.1-0.20260130191353-17b8857753e7 h1:Cqh4LLMu+yMhdPwzqppj64iMog+A7bKVnYFXshO6MR8= +github.com/bluenviron/mediacommon/v2 v2.6.1-0.20260130191353-17b8857753e7/go.mod h1:5V15TiOfeaNVmZPVuOqAwqQSWyvMV86/dijDKu5q9Zs= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= diff --git a/internal/conf/always_available_track.go b/internal/conf/always_available_track.go new file mode 100644 index 00000000..29efc64c --- /dev/null +++ b/internal/conf/always_available_track.go @@ -0,0 +1,67 @@ +package conf + +import ( + "fmt" + + "github.com/bluenviron/mediamtx/internal/conf/jsonwrapper" +) + +// Codec is a codec of AlwaysAvailableTrack. +type Codec string + +// available codecs. +const ( + CodecAV1 Codec = "AV1" + CodecVP9 Codec = "VP9" + CodecH265 Codec = "H265" + CodecH264 Codec = "H264" + CodecMPEG4Audio Codec = "MPEG4Audio" + CodecOpus Codec = "Opus" + CodecG711 Codec = "G711" + CodecLPCM Codec = "LPCM" +) + +// UnmarshalEnv implements env.Unmarshaler. +func (d *Codec) UnmarshalEnv(_ string, v string) error { + return jsonwrapper.Unmarshal([]byte(`"`+v+`"`), d) +} + +// AlwaysAvailableTrack is an item of alwaysAvailableTracks. +type AlwaysAvailableTrack struct { + Codec Codec `json:"codec"` + SampleRate int `json:"sampleRate"` + ChannelCount int `json:"channelCount"` + MULaw bool `json:"muLaw"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (t *AlwaysAvailableTrack) UnmarshalJSON(b []byte) error { + type alias AlwaysAvailableTrack + err := jsonwrapper.Unmarshal(b, (*alias)(t)) + if err != nil { + return err + } + + switch t.Codec { + case CodecAV1, CodecVP9, CodecH265, CodecH264, CodecOpus: + if t.SampleRate != 0 { + return fmt.Errorf("sampleRate must not be specified for codec '%s'", t.Codec) + } + if t.ChannelCount != 0 { + return fmt.Errorf("channelCount must not be specified for codec '%s'", t.Codec) + } + + case CodecMPEG4Audio, CodecG711, CodecLPCM: + if t.SampleRate == 0 { + return fmt.Errorf("sampleRate is mandatory for codec '%s'", t.Codec) + } + if t.ChannelCount == 0 { + return fmt.Errorf("channelCount is mandatory for codec '%s'", t.Codec) + } + + default: + return fmt.Errorf("unsupported codec '%s'", t.Codec) + } + + return nil +} diff --git a/internal/conf/auth_method.go b/internal/conf/auth_method.go index f22506ef..ade90ff7 100644 --- a/internal/conf/auth_method.go +++ b/internal/conf/auth_method.go @@ -1,59 +1,33 @@ package conf import ( - "encoding/json" "fmt" "github.com/bluenviron/mediamtx/internal/conf/jsonwrapper" ) // AuthMethod is an authentication method. -type AuthMethod int +type AuthMethod string // authentication methods. const ( - AuthMethodInternal AuthMethod = iota - AuthMethodHTTP - AuthMethodJWT + AuthMethodInternal AuthMethod = "internal" + AuthMethodHTTP AuthMethod = "http" + AuthMethodJWT AuthMethod = "jwt" ) -// MarshalJSON implements json.Marshaler. -func (d AuthMethod) MarshalJSON() ([]byte, error) { - var out string - - switch d { - case AuthMethodInternal: - out = "internal" - - case AuthMethodHTTP: - out = "http" - - default: - out = "jwt" - } - - return json.Marshal(out) -} - // UnmarshalJSON implements json.Unmarshaler. func (d *AuthMethod) UnmarshalJSON(b []byte) error { - var in string - if err := jsonwrapper.Unmarshal(b, &in); err != nil { + type alias AuthMethod + if err := jsonwrapper.Unmarshal(b, (*alias)(d)); err != nil { return err } - switch in { - case "internal": - *d = AuthMethodInternal - - case "http": - *d = AuthMethodHTTP - - case "jwt": - *d = AuthMethodJWT + switch *d { + case AuthMethodInternal, AuthMethodHTTP, AuthMethodJWT: default: - return fmt.Errorf("invalid authMethod: '%s'", in) + return fmt.Errorf("invalid authMethod: '%s'", *d) } return nil diff --git a/internal/conf/conf.go b/internal/conf/conf.go index 8dbe1007..9e554f25 100644 --- a/internal/conf/conf.go +++ b/internal/conf/conf.go @@ -420,6 +420,7 @@ func (conf *Conf) setDefaults() { conf.UDPMaxPayloadSize = 1472 // Authentication + conf.AuthMethod = AuthMethodInternal conf.AuthInternalUsers = defaultAuthInternalUsers conf.AuthHTTPExclude = []AuthInternalUserPermission{ { @@ -461,6 +462,7 @@ func (conf *Conf) setDefaults() { // RTSP server conf.RTSP = true + conf.RTSPEncryption = EncryptionNo conf.RTSPTransports = RTSPTransports{ gortsplib.ProtocolUDP: {}, gortsplib.ProtocolUDPMulticast: {}, @@ -483,6 +485,7 @@ func (conf *Conf) setDefaults() { // RTMP server conf.RTMP = true + conf.RTMPEncryption = EncryptionNo conf.RTMPAddress = ":1935" conf.RTMPSAddress = ":1936" conf.RTMPServerKey = "server.key" diff --git a/internal/conf/conf_test.go b/internal/conf/conf_test.go index 67160ae8..ff79dd00 100644 --- a/internal/conf/conf_test.go +++ b/internal/conf/conf_test.go @@ -48,10 +48,13 @@ func TestConfFromFile(t *testing.T) { pa, ok := conf.Paths["cam1"] require.Equal(t, true, ok) require.Equal(t, &Path{ - Name: "cam1", - Source: "publisher", - SourceOnDemandStartTimeout: 10 * Duration(time.Second), - SourceOnDemandCloseAfter: 10 * Duration(time.Second), + Name: "cam1", + Source: "publisher", + SourceOnDemandStartTimeout: 10 * Duration(time.Second), + SourceOnDemandCloseAfter: 10 * Duration(time.Second), + AlwaysAvailableTracks: []AlwaysAvailableTrack{ + {Codec: "H264"}, + }, RecordPath: "./recordings/%path/%Y-%m-%d_%H-%M-%S-%f", RecordFormat: RecordFormatFMP4, RecordPartDuration: Duration(1 * time.Second), diff --git a/internal/conf/encryption.go b/internal/conf/encryption.go index 2f915b5f..bdd26753 100644 --- a/internal/conf/encryption.go +++ b/internal/conf/encryption.go @@ -1,59 +1,40 @@ package conf import ( - "encoding/json" "fmt" "github.com/bluenviron/mediamtx/internal/conf/jsonwrapper" ) // Encryption is the rtspEncryption / rtmpEncryption parameter. -type Encryption int +type Encryption string // values. const ( - EncryptionNo Encryption = iota - EncryptionOptional - EncryptionStrict + EncryptionNo Encryption = "no" + EncryptionOptional Encryption = "optional" + EncryptionStrict Encryption = "strict" ) -// MarshalJSON implements json.Marshaler. -func (d Encryption) MarshalJSON() ([]byte, error) { - var out string - - switch d { - case EncryptionNo: - out = "no" - - case EncryptionOptional: - out = "optional" - - default: - out = "strict" - } - - return json.Marshal(out) -} - // UnmarshalJSON implements json.Unmarshaler. func (d *Encryption) UnmarshalJSON(b []byte) error { - var in string - if err := jsonwrapper.Unmarshal(b, &in); err != nil { + type alias Encryption + if err := jsonwrapper.Unmarshal(b, (*alias)(d)); err != nil { return err } - switch in { - case "no", "false": + switch *d { + case "false": *d = EncryptionNo - - case "optional": - *d = EncryptionOptional - - case "strict", "yes", "true": + case "true", "yes": *d = EncryptionStrict + } + + switch *d { + case EncryptionNo, EncryptionOptional, EncryptionStrict: default: - return fmt.Errorf("invalid encryption: '%s'", in) + return fmt.Errorf("invalid encryption: '%s'", *d) } return nil diff --git a/internal/conf/path.go b/internal/conf/path.go index 8644b0de..fe31a47e 100644 --- a/internal/conf/path.go +++ b/internal/conf/path.go @@ -4,6 +4,7 @@ import ( "fmt" "net" "net/url" + "os" "reflect" "regexp" "sort" @@ -11,6 +12,8 @@ import ( "time" "github.com/bluenviron/gortsplib/v5/pkg/base" + "github.com/bluenviron/mediacommon/v2/pkg/formats/mp4/codecs" + "github.com/bluenviron/mediacommon/v2/pkg/formats/pmp4" "github.com/bluenviron/mediamtx/internal/logger" ) @@ -63,6 +66,34 @@ func checkRedirect(v string) error { return nil } +func checkAlwaysAvailableFile(fpath string) error { + f, err := os.Open(fpath) + if err != nil { + return err + } + defer f.Close() + + var presentation pmp4.Presentation + err = presentation.Unmarshal(f) + if err != nil { + return err + } + + if len(presentation.Tracks) == 0 { + return fmt.Errorf("file does not contain any track") + } + + for _, track := range presentation.Tracks { + switch track.Codec.(type) { + case *codecs.AV1, *codecs.VP9, *codecs.H265, *codecs.H264, *codecs.Opus, *codecs.MPEG4Audio, *codecs.LPCM: + default: + return fmt.Errorf("unsupported codec %T", track.Codec) + } + } + + return nil +} + // FindPathConf returns the configuration corresponding to the given path name. func FindPathConf(pathConfs map[string]*Path, name string) (*Path, []string, error) { // normal path @@ -120,6 +151,11 @@ type Path struct { Fallback string `json:"fallback"` UseAbsoluteTimestamp bool `json:"useAbsoluteTimestamp"` + // Always available + AlwaysAvailable bool `json:"alwaysAvailable"` + AlwaysAvailableFile string `json:"alwaysAvailableFile"` + AlwaysAvailableTracks []AlwaysAvailableTrack `json:"alwaysAvailableTracks"` + // Record Record bool `json:"record"` Playback *bool `json:"playback,omitempty"` // deprecated @@ -235,6 +271,11 @@ func (pconf *Path) setDefaults() { pconf.SourceOnDemandStartTimeout = 10 * Duration(time.Second) pconf.SourceOnDemandCloseAfter = 10 * Duration(time.Second) + // Always available + pconf.AlwaysAvailableTracks = []AlwaysAvailableTrack{ + {Codec: "H264"}, + } + // Record pconf.RecordPath = "./recordings/%path/%Y-%m-%d_%H-%M-%S-%f" pconf.RecordFormat = RecordFormatFMP4 @@ -317,25 +358,15 @@ func (pconf *Path) validate( // common configuration errors - if pconf.Source != "publisher" && pconf.Source != "redirect" && - pconf.Regexp != nil && !pconf.SourceOnDemand { - return fmt.Errorf("a path with a regular expression (or path 'all') and a static source" + - " must have 'sourceOnDemand' set to true") - } - if pconf.SRTPublishPassphrase != "" && pconf.Source != "publisher" { return fmt.Errorf("'srtPublishPassphase' can only be used when source is 'publisher'") } - if pconf.SourceOnDemand && pconf.Source == "publisher" { - return fmt.Errorf("'sourceOnDemand' is useless when source is 'publisher'") - } - if pconf.Source != "redirect" && pconf.SourceRedirect != "" { return fmt.Errorf("'sourceRedirect' is useless when source is not 'redirect'") } - // source-dependent settings + // General switch { case pconf.Source == "publisher": @@ -613,6 +644,17 @@ func (pconf *Path) validate( return fmt.Errorf("invalid source: '%s'", pconf.Source) } + if pconf.SourceOnDemand { + if pconf.Source == "publisher" { + return fmt.Errorf("'sourceOnDemand' is useless when source is 'publisher'") + } + } else { + if pconf.Source != "publisher" && pconf.Source != "redirect" && pconf.Regexp != nil { + return fmt.Errorf("a path with a regular expression (or path 'all') and a static source" + + " must have 'sourceOnDemand' set to true") + } + } + if pconf.SRTReadPassphrase != "" { err := checkSRTPassphrase(pconf.SRTReadPassphrase) if err != nil { @@ -627,6 +669,35 @@ func (pconf *Path) validate( } } + // Always available + + if pconf.AlwaysAvailable { + if pconf.Regexp != nil { + return fmt.Errorf("'alwaysAvailable' cannot be used in a path with a regular expression (or path 'all')") + } + + if pconf.SourceOnDemand { + return fmt.Errorf("'sourceOnDemand' is not compatible with 'alwaysAvailable'") + } + + if pconf.RunOnDemand != "" || pconf.RunOnUnDemand != "" { + return fmt.Errorf("'runOnDemand' and 'runOnUnDemand' cannot be used with 'alwaysAvailable'") + } + + if pconf.AlwaysAvailableFile != "" { + err := checkAlwaysAvailableFile(pconf.AlwaysAvailableFile) + if err != nil { + return fmt.Errorf("invalid 'alwaysAvailableVideo': %w", err) + } + } else if len(pconf.AlwaysAvailableTracks) == 0 { + return fmt.Errorf("'alwaysAvailableTracks' must contain at least one track") + } + + if pconf.UseAbsoluteTimestamp { + return fmt.Errorf("'useAbsoluteTimestamp' cannot be used with 'alwaysAvailable'") + } + } + // Record if pconf.Playback != nil { @@ -733,6 +804,7 @@ func (pconf *Path) validate( return fmt.Errorf("a path with a regular expression (or path 'all')" + " does not support option 'runOnInit'; use another path") } + if (pconf.RunOnDemand != "" || pconf.RunOnUnDemand != "") && pconf.Source != "publisher" { return fmt.Errorf("'runOnDemand' and 'runOnUnDemand' can be used only when source is 'publisher'") } diff --git a/internal/conf/record_format.go b/internal/conf/record_format.go index 91425b6d..a5a3cdc7 100644 --- a/internal/conf/record_format.go +++ b/internal/conf/record_format.go @@ -1,52 +1,32 @@ package conf import ( - "encoding/json" "fmt" "github.com/bluenviron/mediamtx/internal/conf/jsonwrapper" ) // RecordFormat is the recordFormat parameter. -type RecordFormat int +type RecordFormat string // supported values. const ( - RecordFormatFMP4 RecordFormat = iota - RecordFormatMPEGTS + RecordFormatFMP4 RecordFormat = "fmp4" + RecordFormatMPEGTS RecordFormat = "mpegts" ) -// MarshalJSON implements json.Marshaler. -func (d RecordFormat) MarshalJSON() ([]byte, error) { - var out string - - switch d { - case RecordFormatMPEGTS: - out = "mpegts" - - default: - out = "fmp4" - } - - return json.Marshal(out) -} - // UnmarshalJSON implements json.Unmarshaler. func (d *RecordFormat) UnmarshalJSON(b []byte) error { - var in string - if err := jsonwrapper.Unmarshal(b, &in); err != nil { + type alias RecordFormat + if err := jsonwrapper.Unmarshal(b, (*alias)(d)); err != nil { return err } - switch in { - case "mpegts": - *d = RecordFormatMPEGTS - - case "fmp4": - *d = RecordFormatFMP4 + switch *d { + case RecordFormatFMP4, RecordFormatMPEGTS: default: - return fmt.Errorf("invalid record format '%s'", in) + return fmt.Errorf("invalid record format '%s'", *d) } return nil diff --git a/internal/conf/rtsp_range_type.go b/internal/conf/rtsp_range_type.go index 01e97eab..cd5c9aaa 100644 --- a/internal/conf/rtsp_range_type.go +++ b/internal/conf/rtsp_range_type.go @@ -1,66 +1,34 @@ package conf import ( - "encoding/json" "fmt" "github.com/bluenviron/mediamtx/internal/conf/jsonwrapper" ) // RTSPRangeType is the type used in the Range header. -type RTSPRangeType int +type RTSPRangeType string // supported values. const ( - RTSPRangeTypeUndefined RTSPRangeType = iota - RTSPRangeTypeClock - RTSPRangeTypeNPT - RTSPRangeTypeSMPTE + RTSPRangeTypeUndefined RTSPRangeType = "" + RTSPRangeTypeClock RTSPRangeType = "clock" + RTSPRangeTypeNPT RTSPRangeType = "npt" + RTSPRangeTypeSMPTE RTSPRangeType = "smpte" ) -// MarshalJSON implements json.Marshaler. -func (d RTSPRangeType) MarshalJSON() ([]byte, error) { - var out string - - switch d { - case RTSPRangeTypeClock: - out = "clock" - - case RTSPRangeTypeNPT: - out = "npt" - - case RTSPRangeTypeSMPTE: - out = "smpte" - - default: - out = "" - } - - return json.Marshal(out) -} - // UnmarshalJSON implements json.Unmarshaler. func (d *RTSPRangeType) UnmarshalJSON(b []byte) error { - var in string - if err := jsonwrapper.Unmarshal(b, &in); err != nil { + type alias RTSPRangeType + if err := jsonwrapper.Unmarshal(b, (*alias)(d)); err != nil { return err } - switch in { - case "clock": - *d = RTSPRangeTypeClock - - case "npt": - *d = RTSPRangeTypeNPT - - case "smpte": - *d = RTSPRangeTypeSMPTE - - case "": - *d = RTSPRangeTypeUndefined + switch *d { + case RTSPRangeTypeUndefined, RTSPRangeTypeClock, RTSPRangeTypeNPT, RTSPRangeTypeSMPTE: default: - return fmt.Errorf("invalid rtsp range type: '%s'", in) + return fmt.Errorf("invalid rtsp range type: '%s'", *d) } return nil diff --git a/internal/core/path.go b/internal/core/path.go index 066187fa..b482f51f 100644 --- a/internal/core/path.go +++ b/internal/core/path.go @@ -86,7 +86,8 @@ type path struct { publisherQuery string stream *stream.Stream recorder *recorder.Recorder - readyTime time.Time + availableTime time.Time + onlineTime time.Time onUnDemandHook func(string) onNotReadyHook func() readers map[defs.Reader]struct{} @@ -158,7 +159,7 @@ func (pa *path) Name() string { return pa.name } -func (pa *path) isReady() bool { +func (pa *path) isAvailable() bool { return pa.stream != nil } @@ -166,6 +167,13 @@ func (pa *path) run() { defer close(pa.done) defer pa.wg.Done() + if pa.conf.AlwaysAvailable { + err := pa.setAvailable(nil, true) + if err != nil { + panic(err) + } + } + if pa.conf.Source == "redirect" { pa.source = &sourceRedirect{} } else if pa.conf.HasStaticSource() { @@ -218,7 +226,7 @@ func (pa *path) run() { } if pa.stream != nil { - pa.setNotReady() + pa.setNotAvailable() } if pa.source != nil { @@ -329,7 +337,10 @@ func (pa *path) doOnDemandStaticSourceReadyTimer() { } func (pa *path) doOnDemandStaticSourceCloseTimer() { - pa.setNotReady() + if pa.conf.AlwaysAvailable { + panic("should not happen") + } + pa.setNotAvailable() pa.onDemandStaticSourceStop("not needed by anyone") } @@ -379,12 +390,31 @@ func (pa *path) doReloadConf(newConf *conf.Path) { } func (pa *path) doSourceStaticSetReady(req defs.PathSourceStaticSetReadyReq) { - err := pa.setReady(req.Desc, req.UseRTPPackets, req.ReplaceNTP) + if !pa.conf.AlwaysAvailable { + err := pa.setAvailable(req.Desc, req.ReplaceNTP) + if err != nil { + req.Res <- defs.PathSourceStaticSetReadyRes{Err: err} + return + } + } + + subStream := &stream.SubStream{ + Stream: pa.stream, + UseRTPPackets: req.UseRTPPackets, + } + if pa.conf.AlwaysAvailable { + subStream.CurDesc = req.Desc + } + err := subStream.Initialize() if err != nil { req.Res <- defs.PathSourceStaticSetReadyRes{Err: err} return } + if pa.conf.AlwaysAvailable { + pa.onlineTime = time.Now() + } + if pa.conf.HasOnDemandStaticSource() { pa.onDemandStaticSourceReadyTimer.Stop() pa.onDemandStaticSourceReadyTimer = emptyTimer() @@ -393,11 +423,18 @@ func (pa *path) doSourceStaticSetReady(req defs.PathSourceStaticSetReadyReq) { pa.consumeOnHoldRequests() - req.Res <- defs.PathSourceStaticSetReadyRes{Stream: pa.stream} + req.Res <- defs.PathSourceStaticSetReadyRes{SubStream: subStream} } func (pa *path) doSourceStaticSetNotReady(req defs.PathSourceStaticSetNotReadyReq) { - pa.setNotReady() + if !pa.conf.AlwaysAvailable { + pa.setNotAvailable() + } else { + err := pa.stream.StartOfflineSubStream() + if err != nil { + panic("should not happen") + } + } // send response before calling onDemandStaticSourceStop() // in order to avoid a deadlock due to staticsources.Handler.stop() @@ -476,16 +513,34 @@ func (pa *path) doAddPublisher(req defs.PathAddPublisherReq) { pa.source = req.Author pa.publisherQuery = req.AccessRequest.Query - err := pa.setReady(req.Desc, req.UseRTPPackets, req.ReplaceNTP) + req.Author.Log(logger.Info, "is publishing to path '%s'", + pa.name) + + if !pa.conf.AlwaysAvailable { + err := pa.setAvailable(req.Desc, req.ReplaceNTP) + if err != nil { + pa.source = nil + req.Res <- defs.PathAddPublisherRes{Err: err} + return + } + } + + subStream := &stream.SubStream{ + Stream: pa.stream, + UseRTPPackets: req.UseRTPPackets, + } + if pa.conf.AlwaysAvailable { + subStream.CurDesc = req.Desc + } + err := subStream.Initialize() if err != nil { - pa.source = nil req.Res <- defs.PathAddPublisherRes{Err: err} return } - req.Author.Log(logger.Info, "is publishing to path '%s', %s", - pa.name, - defs.MediasInfo(req.Desc.Medias)) + if pa.conf.AlwaysAvailable { + pa.onlineTime = time.Now() + } if pa.conf.HasOnDemandPublisher() && pa.onDemandPublisherState != pathOnDemandStateInitial { pa.onDemandPublisherReadyTimer.Stop() @@ -496,8 +551,8 @@ func (pa *path) doAddPublisher(req defs.PathAddPublisherReq) { pa.consumeOnHoldRequests() req.Res <- defs.PathAddPublisherRes{ - Path: pa, - Stream: pa.stream, + Path: pa, + SubStream: subStream, } } @@ -550,6 +605,14 @@ func (pa *path) doAPIPathsGet(req pathAPIPathsGetReq) { data: &defs.APIPath{ Name: pa.name, ConfName: pa.conf.Name, + Ready: pa.isAvailable(), + ReadyTime: func() *time.Time { + if !pa.isAvailable() { + return nil + } + v := pa.availableTime + return &v + }(), Source: func() *defs.APIPathSource { if pa.source == nil { return nil @@ -557,28 +620,20 @@ func (pa *path) doAPIPathsGet(req pathAPIPathsGetReq) { v := pa.source.APISourceDescribe() return v }(), - Ready: pa.isReady(), - ReadyTime: func() *time.Time { - if !pa.isReady() { - return nil - } - v := pa.readyTime - return &v - }(), Tracks: func() []string { - if !pa.isReady() { + if !pa.isAvailable() { return []string{} } return defs.MediasToCodecs(pa.stream.Desc.Medias) }(), BytesReceived: func() uint64 { - if !pa.isReady() { + if !pa.isAvailable() { return 0 } return pa.stream.BytesReceived() }(), BytesSent: func() uint64 { - if !pa.isReady() { + if !pa.isAvailable() { return 0 } return pa.stream.BytesSent() @@ -688,35 +743,52 @@ func (pa *path) onDemandPublisherStop(reason string) { pa.onDemandPublisherState = pathOnDemandStateInitial } -func (pa *path) setReady(desc *description.Session, useRTPPackets bool, replaceNTP bool) error { +func (pa *path) setAvailable(desc *description.Session, replaceNTP bool) error { pa.stream = &stream.Stream{ - Desc: desc, - UseRTPPackets: useRTPPackets, - WriteQueueSize: pa.writeQueueSize, - RTPMaxPayloadSize: pa.rtpMaxPayloadSize, - ReplaceNTP: replaceNTP, - Parent: pa.source, + Desc: desc, + AlwaysAvailable: pa.conf.AlwaysAvailable, + AlwaysAvailableFile: pa.conf.AlwaysAvailableFile, + AlwaysAvailableTracks: pa.conf.AlwaysAvailableTracks, + WriteQueueSize: pa.writeQueueSize, + RTPMaxPayloadSize: pa.rtpMaxPayloadSize, + ReplaceNTP: replaceNTP, + Parent: pa, } err := pa.stream.Initialize() if err != nil { return err } - pa.readyTime = time.Now() + pa.availableTime = time.Now() + + if !pa.conf.AlwaysAvailable { + pa.onlineTime = time.Now() + } if pa.conf.Record { pa.startRecording() } + var sourceDesc *defs.APIPathSource + if pa.source != nil { + sourceDesc = pa.source.APISourceDescribe() + } + pa.onNotReadyHook = hooks.OnReady(hooks.OnReadyParams{ Logger: pa, ExternalCmdPool: pa.externalCmdPool, Conf: pa.conf, ExternalCmdEnv: pa.ExternalCmdEnv(), - Desc: *pa.source.APISourceDescribe(), + Desc: sourceDesc, Query: pa.publisherQuery, }) + if pa.conf.AlwaysAvailable { + pa.Log(logger.Info, "stream is available, %s", defs.MediasInfo(pa.stream.Desc.Medias)) + } else { + pa.Log(logger.Info, "stream is available and online, %s", defs.MediasInfo(pa.stream.Desc.Medias)) + } + pa.parent.pathReady(pa) return nil @@ -736,7 +808,7 @@ func (pa *path) consumeOnHoldRequests() { pa.readerAddRequestsOnHold = nil } -func (pa *path) setNotReady() { +func (pa *path) setNotAvailable() { pa.parent.pathNotReady(pa) for r := range pa.readers { @@ -805,7 +877,14 @@ func (pa *path) executeRemoveReader(r defs.Reader) { } func (pa *path) executeRemovePublisher() { - pa.setNotReady() + if !pa.conf.AlwaysAvailable { + pa.setNotAvailable() + } else { + err := pa.stream.StartOfflineSubStream() + if err != nil { + panic("should not happen") + } + } pa.source = nil } @@ -900,11 +979,11 @@ func (pa *path) describe(req defs.PathDescribeReq) defs.PathDescribeRes { } // addPublisher is called by a publisher through pathManager. -func (pa *path) addPublisher(req defs.PathAddPublisherReq) (defs.Path, *stream.Stream, error) { +func (pa *path) addPublisher(req defs.PathAddPublisherReq) (defs.Path, *stream.SubStream, error) { select { case pa.chAddPublisher <- req: res := <-req.Res - return res.Path, res.Stream, res.Err + return res.Path, res.SubStream, res.Err case <-pa.ctx.Done(): return nil, nil, fmt.Errorf("terminated") } diff --git a/internal/core/path_manager.go b/internal/core/path_manager.go index dce1eb98..c3cc1d98 100644 --- a/internal/core/path_manager.go +++ b/internal/core/path_manager.go @@ -533,7 +533,7 @@ func (pm *pathManager) Describe(req defs.PathDescribeReq) defs.PathDescribeRes { } // AddPublisher is called by a publisher. -func (pm *pathManager) AddPublisher(req defs.PathAddPublisherReq) (defs.Path, *stream.Stream, error) { +func (pm *pathManager) AddPublisher(req defs.PathAddPublisherReq) (defs.Path, *stream.SubStream, error) { req.Res = make(chan defs.PathAddPublisherRes) select { case pm.chAddPublisher <- req: diff --git a/internal/defs/path.go b/internal/defs/path.go index b774af5c..51202128 100644 --- a/internal/defs/path.go +++ b/internal/defs/path.go @@ -57,9 +57,9 @@ type PathDescribeReq struct { // PathAddPublisherRes contains the response of AddPublisher(). type PathAddPublisherRes struct { - Path Path - Stream *stream.Stream - Err error + Path Path + SubStream *stream.SubStream + Err error } // PathAddPublisherReq contains arguments of AddPublisher(). @@ -99,10 +99,10 @@ type PathRemoveReaderReq struct { Res chan struct{} } -// PathSourceStaticSetReadyRes contains the response of SetReadu(). +// PathSourceStaticSetReadyRes contains the response of SetReady(). type PathSourceStaticSetReadyRes struct { - Stream *stream.Stream - Err error + SubStream *stream.SubStream + Err error } // PathSourceStaticSetReadyReq contains arguments of SetReady(). diff --git a/internal/hooks/on_ready.go b/internal/hooks/on_ready.go index f125f0a9..67327232 100644 --- a/internal/hooks/on_ready.go +++ b/internal/hooks/on_ready.go @@ -13,7 +13,7 @@ type OnReadyParams struct { ExternalCmdPool *externalcmd.Pool Conf *conf.Path ExternalCmdEnv externalcmd.Environment - Desc defs.APIPathSource + Desc *defs.APIPathSource Query string } @@ -25,8 +25,10 @@ func OnReady(params OnReadyParams) func() { if params.Conf.RunOnReady != "" || params.Conf.RunOnNotReady != "" { env = params.ExternalCmdEnv env["MTX_QUERY"] = params.Query - env["MTX_SOURCE_TYPE"] = params.Desc.Type - env["MTX_SOURCE_ID"] = params.Desc.ID + if params.Desc != nil { + env["MTX_SOURCE_TYPE"] = params.Desc.Type + env["MTX_SOURCE_ID"] = params.Desc.ID + } } if params.Conf.RunOnReady != "" { diff --git a/internal/playback/on_get_test.go b/internal/playback/on_get_test.go index 540d1e6d..656db5b7 100644 --- a/internal/playback/on_get_test.go +++ b/internal/playback/on_get_test.go @@ -488,8 +488,9 @@ func TestOnGet(t *testing.T) { WriteTimeout: conf.Duration(10 * time.Second), PathConfs: map[string]*conf.Path{ "mypath": { - Name: "mypath", - RecordPath: filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f"), + Name: "mypath", + RecordPath: filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f"), + RecordFormat: conf.RecordFormatFMP4, }, }, AuthManager: test.NilAuthManager, @@ -660,9 +661,10 @@ func TestOnGet(t *testing.T) { TimeOffset: 48000, Codec: &mcodecs.MPEG4Audio{ Config: mpeg4audio.AudioSpecificConfig{ - Type: mpeg4audio.ObjectTypeAACLC, - SampleRate: 48000, - ChannelCount: 2, + Type: mpeg4audio.ObjectTypeAACLC, + SampleRate: 48000, + ChannelCount: 2, + ChannelConfig: 2, }, }, Samples: []*pmp4.Sample{ @@ -714,8 +716,9 @@ func TestOnGetDifferentInit(t *testing.T) { WriteTimeout: conf.Duration(10 * time.Second), PathConfs: map[string]*conf.Path{ "mypath": { - Name: "mypath", - RecordPath: filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f"), + Name: "mypath", + RecordPath: filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f"), + RecordFormat: conf.RecordFormatFMP4, }, }, AuthManager: test.NilAuthManager, @@ -836,8 +839,9 @@ func TestOnGetInMiddleOfLastSample(t *testing.T) { WriteTimeout: conf.Duration(10 * time.Second), PathConfs: map[string]*conf.Path{ "mypath": { - Name: "mypath", - RecordPath: filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f"), + Name: "mypath", + RecordPath: filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f"), + RecordFormat: conf.RecordFormatFMP4, }, }, AuthManager: test.NilAuthManager, @@ -963,8 +967,9 @@ func TestOnGetBetweenSegments(t *testing.T) { WriteTimeout: conf.Duration(10 * time.Second), PathConfs: map[string]*conf.Path{ "mypath": { - Name: "mypath", - RecordPath: filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f"), + Name: "mypath", + RecordPath: filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f"), + RecordFormat: conf.RecordFormatFMP4, }, }, AuthManager: test.NilAuthManager, diff --git a/internal/playback/on_list_test.go b/internal/playback/on_list_test.go index 6a92f0ca..ddf532f0 100644 --- a/internal/playback/on_list_test.go +++ b/internal/playback/on_list_test.go @@ -64,8 +64,9 @@ func TestOnList(t *testing.T) { WriteTimeout: conf.Duration(10 * time.Second), PathConfs: map[string]*conf.Path{ "mypath": { - Name: "mypath", - RecordPath: filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f"), + Name: "mypath", + RecordPath: filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f"), + RecordFormat: conf.RecordFormatFMP4, }, }, AuthManager: &test.AuthManager{ @@ -294,8 +295,9 @@ func TestOnListCachedDuration(t *testing.T) { WriteTimeout: conf.Duration(10 * time.Second), PathConfs: map[string]*conf.Path{ "mypath": { - Name: "mypath", - RecordPath: filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f"), + Name: "mypath", + RecordPath: filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f"), + RecordFormat: conf.RecordFormatFMP4, }, }, AuthManager: test.NilAuthManager, diff --git a/internal/playback/segment_fmp4.go b/internal/playback/segment_fmp4.go index afa7c30e..4cadb349 100644 --- a/internal/playback/segment_fmp4.go +++ b/internal/playback/segment_fmp4.go @@ -146,28 +146,28 @@ func segmentFMP4ReadHeader(r io.ReadSeeker) (*fmp4.Init, time.Duration, error) { // read mvhd var mvhd amp4.Mvhd - mvhdSize, err := amp4.Unmarshal(r, uint64(moovSize-8), &mvhd, amp4.Context{}) + _, err = amp4.Unmarshal(r, uint64(moovSize-8), &mvhd, amp4.Context{}) if err != nil { return nil, 0, err } d := time.Duration(mvhd.DurationV0) * time.Second / time.Duration(mvhd.Timescale) - // read moov + // read ftyp and moov - _, err = r.Seek(int64(-mvhdSize-8-8), io.SeekCurrent) + _, err = r.Seek(0, io.SeekStart) if err != nil { return nil, 0, err } - buf = make([]byte, uint64(moovSize)) + buf = make([]byte, uint64(ftypSize+moovSize)) _, err = io.ReadFull(r, buf) if err != nil { return nil, 0, err } - // pass moov to fmp4.Init + // pass ftyp and moov to fmp4.Init var init fmp4.Init err = init.Unmarshal(bytes.NewReader(buf)) diff --git a/internal/protocols/hls/to_stream.go b/internal/protocols/hls/to_stream.go index aa12027d..d84e71e5 100644 --- a/internal/protocols/hls/to_stream.go +++ b/internal/protocols/hls/to_stream.go @@ -9,7 +9,6 @@ import ( "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" "github.com/bluenviron/mediamtx/internal/ntpestimator" "github.com/bluenviron/mediamtx/internal/stream" "github.com/bluenviron/mediamtx/internal/unit" @@ -35,7 +34,7 @@ func ToStream( c *gohlslib.Client, tracks []*gohlslib.Track, pathConf *conf.Path, - strm **stream.Stream, + subStream **stream.SubStream, ) ([]*description.Media, error) { var ntpStat ntpState var ntpStatMutex sync.Mutex @@ -74,7 +73,7 @@ func ToStream( case ntpStateUnavailable: _, avail := c.AbsoluteTime(ctrack) if avail { - (*strm).Parent.Log(logger.Warn, "absolute timestamp appeared after stream started, we are not using it") + // absolute timestamp appeared after stream started, we are not using it ntpStat = ntpStateReplace } return ntpEstimator.Estimate(pts) @@ -97,7 +96,7 @@ func ToStream( newClockRate := medi.Formats[0].ClockRate() c.OnDataAV1(ctrack, func(pts int64, tu [][]byte) { - (*strm).WriteUnit(medi, medi.Formats[0], &unit.Unit{ + (*subStream).WriteUnit(medi, medi.Formats[0], &unit.Unit{ NTP: handleNTP(pts), PTS: multiplyAndDivide(pts, int64(newClockRate), int64(ctrack.ClockRate)), Payload: unit.PayloadAV1(tu), @@ -114,7 +113,7 @@ func ToStream( newClockRate := medi.Formats[0].ClockRate() c.OnDataVP9(ctrack, func(pts int64, frame []byte) { - (*strm).WriteUnit(medi, medi.Formats[0], &unit.Unit{ + (*subStream).WriteUnit(medi, medi.Formats[0], &unit.Unit{ NTP: handleNTP(pts), PTS: multiplyAndDivide(pts, int64(newClockRate), int64(ctrack.ClockRate)), Payload: unit.PayloadVP9(frame), @@ -134,7 +133,7 @@ func ToStream( newClockRate := medi.Formats[0].ClockRate() c.OnDataH26x(ctrack, func(pts int64, _ int64, au [][]byte) { - (*strm).WriteUnit(medi, medi.Formats[0], &unit.Unit{ + (*subStream).WriteUnit(medi, medi.Formats[0], &unit.Unit{ NTP: handleNTP(pts), PTS: multiplyAndDivide(pts, int64(newClockRate), int64(ctrack.ClockRate)), Payload: unit.PayloadH265(au), @@ -154,7 +153,7 @@ func ToStream( newClockRate := medi.Formats[0].ClockRate() c.OnDataH26x(ctrack, func(pts int64, _ int64, au [][]byte) { - (*strm).WriteUnit(medi, medi.Formats[0], &unit.Unit{ + (*subStream).WriteUnit(medi, medi.Formats[0], &unit.Unit{ NTP: handleNTP(pts), PTS: multiplyAndDivide(pts, int64(newClockRate), int64(ctrack.ClockRate)), Payload: unit.PayloadH264(au), @@ -172,7 +171,7 @@ func ToStream( newClockRate := medi.Formats[0].ClockRate() c.OnDataOpus(ctrack, func(pts int64, packets [][]byte) { - (*strm).WriteUnit(medi, medi.Formats[0], &unit.Unit{ + (*subStream).WriteUnit(medi, medi.Formats[0], &unit.Unit{ NTP: handleNTP(pts), PTS: multiplyAndDivide(pts, int64(newClockRate), int64(ctrack.ClockRate)), Payload: unit.PayloadOpus(packets), @@ -193,7 +192,7 @@ func ToStream( newClockRate := medi.Formats[0].ClockRate() c.OnDataMPEG4Audio(ctrack, func(pts int64, aus [][]byte) { - (*strm).WriteUnit(medi, medi.Formats[0], &unit.Unit{ + (*subStream).WriteUnit(medi, medi.Formats[0], &unit.Unit{ NTP: handleNTP(pts), PTS: multiplyAndDivide(pts, int64(newClockRate), int64(ctrack.ClockRate)), Payload: unit.PayloadMPEG4Audio(aus), diff --git a/internal/protocols/hls/to_stream_test.go b/internal/protocols/hls/to_stream_test.go index 5492a7c4..2f502fb0 100644 --- a/internal/protocols/hls/to_stream_test.go +++ b/internal/protocols/hls/to_stream_test.go @@ -86,6 +86,7 @@ func TestToStream(t *testing.T) { defer s.Shutdown(context.Background()) var strm *stream.Stream + var subStream *stream.SubStream done := make(chan struct{}) r := &stream.Reader{Parent: test.NilLogger} @@ -96,7 +97,7 @@ func TestToStream(t *testing.T) { OnTracks: func(tracks []*gohlslib.Track) error { medias, err2 := ToStream(c, tracks, &conf.Path{ UseAbsoluteTimestamp: true, - }, &strm) + }, &subStream) require.NoError(t, err2) require.Equal(t, []*description.Media{{ @@ -109,7 +110,6 @@ func TestToStream(t *testing.T) { strm = &stream.Stream{ Desc: &description.Session{Medias: medias}, - UseRTPPackets: false, WriteQueueSize: 512, RTPMaxPayloadSize: 1450, Parent: test.NilLogger, @@ -117,6 +117,13 @@ func TestToStream(t *testing.T) { err2 = strm.Initialize() require.NoError(t, err2) + subStream = &stream.SubStream{ + Stream: strm, + UseRTPPackets: false, + } + err2 = subStream.Initialize() + require.NoError(t, err2) + n := 0 r.OnData( diff --git a/internal/protocols/mpegts/to_stream.go b/internal/protocols/mpegts/to_stream.go index 8ebd3857..adee2167 100644 --- a/internal/protocols/mpegts/to_stream.go +++ b/internal/protocols/mpegts/to_stream.go @@ -24,7 +24,7 @@ var errNoSupportedCodecs = errors.New( // ToStream maps a MPEG-TS stream to a MediaMTX stream. func ToStream( r *EnhancedReader, - strm **stream.Stream, + subStream **stream.SubStream, l logger.Writer, ) ([]*description.Media, error) { var medias []*description.Media //nolint:prealloc @@ -48,7 +48,7 @@ func ToStream( r.OnDataH265(track, func(pts int64, _ int64, au [][]byte) error { pts = td.Decode(pts) - (*strm).WriteUnit(medi, medi.Formats[0], &unit.Unit{ + (*subStream).WriteUnit(medi, medi.Formats[0], &unit.Unit{ PTS: pts, // no conversion is needed since clock rate is 90khz in both MPEG-TS and RTSP Payload: unit.PayloadH265(au), }) @@ -67,7 +67,7 @@ func ToStream( r.OnDataH264(track, func(pts int64, _ int64, au [][]byte) error { pts = td.Decode(pts) - (*strm).WriteUnit(medi, medi.Formats[0], &unit.Unit{ + (*subStream).WriteUnit(medi, medi.Formats[0], &unit.Unit{ PTS: pts, // no conversion is needed since clock rate is 90khz in both MPEG-TS and RTSP Payload: unit.PayloadH264(au), }) @@ -85,7 +85,7 @@ func ToStream( r.OnDataMPEGxVideo(track, func(pts int64, frame []byte) error { pts = td.Decode(pts) - (*strm).WriteUnit(medi, medi.Formats[0], &unit.Unit{ + (*subStream).WriteUnit(medi, medi.Formats[0], &unit.Unit{ PTS: pts, // no conversion is needed since clock rate is 90khz in both MPEG-TS and RTSP Payload: unit.PayloadMPEG4Video(frame), }) @@ -101,7 +101,7 @@ func ToStream( r.OnDataMPEGxVideo(track, func(pts int64, frame []byte) error { pts = td.Decode(pts) - (*strm).WriteUnit(medi, medi.Formats[0], &unit.Unit{ + (*subStream).WriteUnit(medi, medi.Formats[0], &unit.Unit{ PTS: pts, // no conversion is needed since clock rate is 90khz in both MPEG-TS and RTSP Payload: unit.PayloadMPEG1Video(frame), }) @@ -120,7 +120,7 @@ func ToStream( r.OnDataOpus(track, func(pts int64, packets [][]byte) error { pts = td.Decode(pts) - (*strm).WriteUnit(medi, medi.Formats[0], &unit.Unit{ + (*subStream).WriteUnit(medi, medi.Formats[0], &unit.Unit{ PTS: multiplyAndDivide(pts, int64(medi.Formats[0].ClockRate()), 90000), Payload: unit.PayloadOpus(packets), }) @@ -137,7 +137,7 @@ func ToStream( r.OnDataKLV(track, func(pts int64, uni []byte) error { pts = td.Decode(pts) - (*strm).WriteUnit(medi, medi.Formats[0], &unit.Unit{ + (*subStream).WriteUnit(medi, medi.Formats[0], &unit.Unit{ PTS: pts, Payload: unit.PayloadKLV(uni), }) @@ -159,7 +159,7 @@ func ToStream( r.OnDataMPEG4Audio(track, func(pts int64, aus [][]byte) error { pts = td.Decode(pts) - (*strm).WriteUnit(medi, medi.Formats[0], &unit.Unit{ + (*subStream).WriteUnit(medi, medi.Formats[0], &unit.Unit{ PTS: multiplyAndDivide(pts, int64(medi.Formats[0].ClockRate()), 90000), Payload: unit.PayloadMPEG4Audio(aus), }) @@ -210,7 +210,7 @@ func ToStream( return err } - (*strm).WriteUnit(medi, medi.Formats[0], &unit.Unit{ + (*subStream).WriteUnit(medi, medi.Formats[0], &unit.Unit{ PTS: pts, Payload: unit.PayloadMPEG4AudioLATM(buf), }) @@ -230,7 +230,7 @@ func ToStream( r.OnDataMPEG1Audio(track, func(pts int64, frames [][]byte) error { pts = td.Decode(pts) - (*strm).WriteUnit(medi, medi.Formats[0], &unit.Unit{ + (*subStream).WriteUnit(medi, medi.Formats[0], &unit.Unit{ PTS: pts, // no conversion is needed since clock rate is 90khz in both MPEG-TS and RTSP Payload: unit.PayloadMPEG1Audio(frames), }) @@ -250,7 +250,7 @@ func ToStream( r.OnDataAC3(track, func(pts int64, frame []byte) error { pts = td.Decode(pts) - (*strm).WriteUnit(medi, medi.Formats[0], &unit.Unit{ + (*subStream).WriteUnit(medi, medi.Formats[0], &unit.Unit{ PTS: multiplyAndDivide(pts, int64(medi.Formats[0].ClockRate()), 90000), Payload: unit.PayloadAC3{frame}, }) diff --git a/internal/protocols/mpegts/to_stream_test.go b/internal/protocols/mpegts/to_stream_test.go index d8be208d..efbb169e 100644 --- a/internal/protocols/mpegts/to_stream_test.go +++ b/internal/protocols/mpegts/to_stream_test.go @@ -134,9 +134,10 @@ func TestToStream(t *testing.T) { Programs: []*mpeg4audio.StreamMuxConfigProgram{{ Layers: []*mpeg4audio.StreamMuxConfigLayer{{ AudioSpecificConfig: &mpeg4audio.AudioSpecificConfig{ - Type: 2, - SampleRate: 48000, - ChannelCount: 2, + Type: 2, + SampleRate: 48000, + ChannelCount: 2, + ChannelConfig: 2, }, LatmBufferFullness: 255, }}, diff --git a/internal/protocols/rtmp/from_stream_test.go b/internal/protocols/rtmp/from_stream_test.go index b70b99f6..435e9ca2 100644 --- a/internal/protocols/rtmp/from_stream_test.go +++ b/internal/protocols/rtmp/from_stream_test.go @@ -72,7 +72,7 @@ func TestFromStream(t *testing.T) { name string medias []*description.Media expectedTracks []*gortmplib.Track - writeUnits func([]*description.Media, *stream.Stream) + writeUnits func([]*description.Media, *stream.SubStream) }{ { name: "h264 + aac", @@ -93,15 +93,15 @@ func TestFromStream(t *testing.T) { Config: test.FormatMPEG4Audio.Config, }}, }, - writeUnits: func(medias []*description.Media, strm *stream.Stream) { - strm.WriteUnit(medias[0], medias[0].Formats[0], &unit.Unit{ + writeUnits: func(medias []*description.Media, subStream *stream.SubStream) { + subStream.WriteUnit(medias[0], medias[0].Formats[0], &unit.Unit{ PTS: 0, Payload: unit.PayloadH264{ {5, 2}, // IDR }, }) - strm.WriteUnit(medias[1], medias[1].Formats[0], &unit.Unit{ + subStream.WriteUnit(medias[1], medias[1].Formats[0], &unit.Unit{ PTS: 90000 * 5, Payload: unit.PayloadMPEG4Audio{ {3, 4}, @@ -121,9 +121,9 @@ func TestFromStream(t *testing.T) { expectedTracks: []*gortmplib.Track{ {Codec: &codecs.AV1{}}, }, - writeUnits: func(medias []*description.Media, strm *stream.Stream) { + writeUnits: func(medias []*description.Media, subStream *stream.SubStream) { for i := range 2 { - strm.WriteUnit(medias[0], medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(medias[0], medias[0].Formats[0], &unit.Unit{ PTS: 90000 * 2 * int64(i), Payload: unit.PayloadAV1{{ 0x0a, 0x0e, 0x00, 0x00, 0x00, 0x4a, 0xab, 0xbf, @@ -145,9 +145,9 @@ func TestFromStream(t *testing.T) { expectedTracks: []*gortmplib.Track{ {Codec: &codecs.VP9{}}, }, - writeUnits: func(medias []*description.Media, strm *stream.Stream) { + writeUnits: func(medias []*description.Media, subStream *stream.SubStream) { for i := range 2 { - strm.WriteUnit(medias[0], medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(medias[0], medias[0].Formats[0], &unit.Unit{ PTS: 90000 * 2 * int64(i), Payload: unit.PayloadVP9{1, 2}, }) @@ -175,9 +175,9 @@ func TestFromStream(t *testing.T) { PPS: h265PPS, }}, }, - writeUnits: func(medias []*description.Media, strm *stream.Stream) { + writeUnits: func(medias []*description.Media, subStream *stream.SubStream) { for i := range 2 { - strm.WriteUnit(medias[0], medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(medias[0], medias[0].Formats[0], &unit.Unit{ PTS: 90000 * 2 * int64(i), Payload: unit.PayloadH265{{ 0x2a, 0x01, 0xad, 0xe0, 0xf5, 0x34, 0x11, 0x0b, @@ -200,9 +200,9 @@ func TestFromStream(t *testing.T) { PPS: test.FormatH264.PPS, }}, }, - writeUnits: func(medias []*description.Media, strm *stream.Stream) { + writeUnits: func(medias []*description.Media, subStream *stream.SubStream) { for i := range 2 { - strm.WriteUnit(medias[0], medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(medias[0], medias[0].Formats[0], &unit.Unit{ PTS: 90000 * 2 * int64(i), Payload: unit.PayloadH264{ {5, 2}, // IDR @@ -226,9 +226,9 @@ func TestFromStream(t *testing.T) { ChannelCount: 2, }}, }, - writeUnits: func(medias []*description.Media, strm *stream.Stream) { + writeUnits: func(medias []*description.Media, subStream *stream.SubStream) { for i := range 2 { - strm.WriteUnit(medias[0], medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(medias[0], medias[0].Formats[0], &unit.Unit{ PTS: 90000 * 5 * int64(i), Payload: unit.PayloadOpus{ {3, 4}, @@ -249,9 +249,9 @@ func TestFromStream(t *testing.T) { Config: test.FormatMPEG4Audio.Config, }}, }, - writeUnits: func(medias []*description.Media, strm *stream.Stream) { + writeUnits: func(medias []*description.Media, subStream *stream.SubStream) { for i := range 2 { - strm.WriteUnit(medias[0], medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(medias[0], medias[0].Formats[0], &unit.Unit{ PTS: 90000 * 5 * int64(i), Payload: unit.PayloadMPEG4Audio{ {3, 4}, @@ -270,9 +270,9 @@ func TestFromStream(t *testing.T) { expectedTracks: []*gortmplib.Track{ {Codec: &codecs.MPEG1Audio{}}, }, - writeUnits: func(medias []*description.Media, strm *stream.Stream) { + writeUnits: func(medias []*description.Media, subStream *stream.SubStream) { for i := range 2 { - strm.WriteUnit(medias[0], medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(medias[0], medias[0].Formats[0], &unit.Unit{ PTS: 90000 * 5 * int64(i), Payload: unit.PayloadMPEG1Audio{ { @@ -299,9 +299,9 @@ func TestFromStream(t *testing.T) { ChannelCount: 1, }}, }, - writeUnits: func(medias []*description.Media, strm *stream.Stream) { + writeUnits: func(medias []*description.Media, subStream *stream.SubStream) { for i := range 2 { - strm.WriteUnit(medias[0], medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(medias[0], medias[0].Formats[0], &unit.Unit{ PTS: 90000 * 5 * int64(i), Payload: unit.PayloadAC3{ { @@ -377,9 +377,9 @@ func TestFromStream(t *testing.T) { SampleRate: 8000, }}, }, - writeUnits: func(medias []*description.Media, strm *stream.Stream) { + writeUnits: func(medias []*description.Media, subStream *stream.SubStream) { for i := range 2 { - strm.WriteUnit(medias[0], medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(medias[0], medias[0].Formats[0], &unit.Unit{ PTS: 90000 * 5 * int64(i), Payload: unit.PayloadG711{ 3, 4, @@ -406,9 +406,9 @@ func TestFromStream(t *testing.T) { SampleRate: 8000, }}, }, - writeUnits: func(medias []*description.Media, strm *stream.Stream) { + writeUnits: func(medias []*description.Media, subStream *stream.SubStream) { for i := range 2 { - strm.WriteUnit(medias[0], medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(medias[0], medias[0].Formats[0], &unit.Unit{ PTS: 90000 * 5 * int64(i), Payload: unit.PayloadG711{ 3, 4, @@ -435,9 +435,9 @@ func TestFromStream(t *testing.T) { ChannelCount: 2, }}, }, - writeUnits: func(medias []*description.Media, strm *stream.Stream) { + writeUnits: func(medias []*description.Media, subStream *stream.SubStream) { for i := range 2 { - strm.WriteUnit(medias[0], medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(medias[0], medias[0].Formats[0], &unit.Unit{ PTS: 90000 * 5 * int64(i), Payload: unit.PayloadLPCM{ 3, 4, 5, 6, @@ -496,8 +496,8 @@ func TestFromStream(t *testing.T) { Config: test.FormatMPEG4Audio.Config, }}, }, - writeUnits: func(medias []*description.Media, strm *stream.Stream) { - strm.WriteUnit(medias[0], medias[0].Formats[0], &unit.Unit{ + writeUnits: func(medias []*description.Media, subStream *stream.SubStream) { + subStream.WriteUnit(medias[0], medias[0].Formats[0], &unit.Unit{ Payload: unit.PayloadH265{ { 0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x01, 0x60, @@ -522,7 +522,7 @@ func TestFromStream(t *testing.T) { }, }) - strm.WriteUnit(medias[1], medias[1].Formats[0], &unit.Unit{ + subStream.WriteUnit(medias[1], medias[1].Formats[0], &unit.Unit{ Payload: unit.PayloadH264{ h264DefaultSPS, h264DefaultPPS, @@ -530,24 +530,24 @@ func TestFromStream(t *testing.T) { }, }) - strm.WriteUnit(medias[2], medias[2].Formats[0], &unit.Unit{ + subStream.WriteUnit(medias[2], medias[2].Formats[0], &unit.Unit{ Payload: unit.PayloadVP9{1, 2}, }) - strm.WriteUnit(medias[3], medias[3].Formats[0], &unit.Unit{ + subStream.WriteUnit(medias[3], medias[3].Formats[0], &unit.Unit{ Payload: unit.PayloadAV1{{ 0x0a, 0x0e, 0x00, 0x00, 0x00, 0x4a, 0xab, 0xbf, 0xc3, 0x77, 0x6b, 0xe4, 0x40, 0x40, 0x40, 0x41, }}, }) - strm.WriteUnit(medias[4], medias[4].Formats[0], &unit.Unit{ + subStream.WriteUnit(medias[4], medias[4].Formats[0], &unit.Unit{ Payload: unit.PayloadOpus{ {3, 4}, }, }) - strm.WriteUnit(medias[5], medias[5].Formats[0], &unit.Unit{ + subStream.WriteUnit(medias[5], medias[5].Formats[0], &unit.Unit{ PTS: 90000 * 5, Payload: unit.PayloadMPEG4Audio{ {3, 4}, @@ -563,7 +563,6 @@ func TestFromStream(t *testing.T) { strm := &stream.Stream{ Desc: &description.Session{Medias: medias}, - UseRTPPackets: false, WriteQueueSize: 512, RTPMaxPayloadSize: 1450, Parent: test.NilLogger, @@ -571,6 +570,13 @@ func TestFromStream(t *testing.T) { err := strm.Initialize() require.NoError(t, err) + subStream := &stream.SubStream{ + Stream: strm, + UseRTPPackets: false, + } + err = subStream.Initialize() + require.NoError(t, err) + ln, err := net.Listen("tcp", "127.0.0.1:9121") require.NoError(t, err) defer ln.Close() @@ -619,7 +625,7 @@ func TestFromStream(t *testing.T) { strm.AddReader(r) defer strm.RemoveReader(r) - tc.writeUnits(medias, strm) + tc.writeUnits(medias, subStream) <-done }) diff --git a/internal/protocols/rtmp/to_stream.go b/internal/protocols/rtmp/to_stream.go index 53a36017..b2538203 100644 --- a/internal/protocols/rtmp/to_stream.go +++ b/internal/protocols/rtmp/to_stream.go @@ -34,7 +34,7 @@ func fourCCToString(c message.FourCC) string { // ToStream maps a RTMP stream to a MediaMTX stream. func ToStream( r *gortmplib.Reader, - strm **stream.Stream, + subStream **stream.SubStream, ) ([]*description.Media, error) { var medias []*description.Media @@ -51,7 +51,7 @@ func ToStream( medias = append(medias, medi) r.OnDataAV1(track, func(pts time.Duration, tu [][]byte) { - (*strm).WriteUnit(medi, forma, &unit.Unit{ + (*subStream).WriteUnit(medi, forma, &unit.Unit{ PTS: durationToTimestamp(pts, forma.ClockRate()), Payload: unit.PayloadAV1(tu), }) @@ -68,7 +68,7 @@ func ToStream( medias = append(medias, medi) r.OnDataVP9(track, func(pts time.Duration, frame []byte) { - (*strm).WriteUnit(medi, forma, &unit.Unit{ + (*subStream).WriteUnit(medi, forma, &unit.Unit{ PTS: durationToTimestamp(pts, forma.ClockRate()), Payload: unit.PayloadVP9(frame), }) @@ -88,7 +88,7 @@ func ToStream( medias = append(medias, medi) r.OnDataH265(track, func(pts time.Duration, _ time.Duration, au [][]byte) { - (*strm).WriteUnit(medi, forma, &unit.Unit{ + (*subStream).WriteUnit(medi, forma, &unit.Unit{ PTS: durationToTimestamp(pts, forma.ClockRate()), Payload: unit.PayloadH265(au), }) @@ -108,7 +108,7 @@ func ToStream( medias = append(medias, medi) r.OnDataH264(track, func(pts time.Duration, _ time.Duration, au [][]byte) { - (*strm).WriteUnit(medi, forma, &unit.Unit{ + (*subStream).WriteUnit(medi, forma, &unit.Unit{ PTS: durationToTimestamp(pts, forma.ClockRate()), Payload: unit.PayloadH264(au), }) @@ -126,7 +126,7 @@ func ToStream( medias = append(medias, medi) r.OnDataOpus(track, func(pts time.Duration, packet []byte) { - (*strm).WriteUnit(medi, forma, &unit.Unit{ + (*subStream).WriteUnit(medi, forma, &unit.Unit{ PTS: durationToTimestamp(pts, forma.ClockRate()), Payload: unit.PayloadOpus{packet}, }) @@ -147,7 +147,7 @@ func ToStream( medias = append(medias, medi) r.OnDataMPEG4Audio(track, func(pts time.Duration, au []byte) { - (*strm).WriteUnit(medi, forma, &unit.Unit{ + (*subStream).WriteUnit(medi, forma, &unit.Unit{ PTS: durationToTimestamp(pts, forma.ClockRate()), Payload: unit.PayloadMPEG4Audio{au}, }) @@ -162,7 +162,7 @@ func ToStream( medias = append(medias, medi) r.OnDataMPEG1Audio(track, func(pts time.Duration, frame []byte) { - (*strm).WriteUnit(medi, forma, &unit.Unit{ + (*subStream).WriteUnit(medi, forma, &unit.Unit{ PTS: durationToTimestamp(pts, forma.ClockRate()), Payload: unit.PayloadMPEG1Audio{frame}, }) @@ -181,7 +181,7 @@ func ToStream( medias = append(medias, medi) r.OnDataAC3(track, func(pts time.Duration, frame []byte) { - (*strm).WriteUnit(medi, forma, &unit.Unit{ + (*subStream).WriteUnit(medi, forma, &unit.Unit{ PTS: durationToTimestamp(pts, forma.ClockRate()), Payload: unit.PayloadAC3{frame}, }) @@ -210,7 +210,7 @@ func ToStream( medias = append(medias, medi) r.OnDataG711(track, func(pts time.Duration, samples []byte) { - (*strm).WriteUnit(medi, forma, &unit.Unit{ + (*subStream).WriteUnit(medi, forma, &unit.Unit{ PTS: durationToTimestamp(pts, forma.ClockRate()), Payload: unit.PayloadG711(samples), }) @@ -230,7 +230,7 @@ func ToStream( medias = append(medias, medi) r.OnDataLPCM(track, func(pts time.Duration, samples []byte) { - (*strm).WriteUnit(medi, forma, &unit.Unit{ + (*subStream).WriteUnit(medi, forma, &unit.Unit{ PTS: durationToTimestamp(pts, forma.ClockRate()), Payload: unit.PayloadLPCM(samples), }) diff --git a/internal/protocols/rtsp/to_stream.go b/internal/protocols/rtsp/to_stream.go index 97f5da81..b45577be 100644 --- a/internal/protocols/rtsp/to_stream.go +++ b/internal/protocols/rtsp/to_stream.go @@ -33,7 +33,7 @@ func ToStream( source rtspSource, medias []*description.Media, pathConf *conf.Path, - strm **stream.Stream, + subStream **stream.SubStream, log logger.Writer, ) { for _, medi := range medias { @@ -83,7 +83,7 @@ func ToStream( return } - (*strm).WriteUnit(cmedi, cforma, &unit.Unit{ + (*subStream).WriteUnit(cmedi, cforma, &unit.Unit{ PTS: pts, NTP: ntp, RTPPackets: []*rtp.Packet{pkt}, diff --git a/internal/protocols/webrtc/from_stream_test.go b/internal/protocols/webrtc/from_stream_test.go index b03a3d2a..52fe9493 100644 --- a/internal/protocols/webrtc/from_stream_test.go +++ b/internal/protocols/webrtc/from_stream_test.go @@ -96,7 +96,6 @@ func TestFromStreamResampleOpus(t *testing.T) { }}, }, }}, - UseRTPPackets: true, WriteQueueSize: 512, RTPMaxPayloadSize: 1450, ReplaceNTP: false, @@ -105,6 +104,13 @@ func TestFromStreamResampleOpus(t *testing.T) { err := strm.Initialize() require.NoError(t, err) + subStream := &stream.SubStream{ + Stream: strm, + UseRTPPackets: true, + } + err = subStream.Initialize() + require.NoError(t, err) + pc1 := &PeerConnection{ LocalRandomUDP: true, IPsFromInterfaces: true, @@ -153,7 +159,7 @@ func TestFromStreamResampleOpus(t *testing.T) { strm.AddReader(r) defer strm.RemoveReader(r) - strm.WriteUnit(strm.Desc.Medias[0], strm.Desc.Medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(strm.Desc.Medias[0], strm.Desc.Medias[0].Formats[0], &unit.Unit{ PTS: 0, NTP: time.Now(), RTPPackets: []*rtp.Packet{{ @@ -169,7 +175,7 @@ func TestFromStreamResampleOpus(t *testing.T) { }}, }) - strm.WriteUnit(strm.Desc.Medias[0], strm.Desc.Medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(strm.Desc.Medias[0], strm.Desc.Medias[0].Formats[0], &unit.Unit{ PTS: 0, NTP: time.Now(), RTPPackets: []*rtp.Packet{{ diff --git a/internal/protocols/webrtc/to_stream.go b/internal/protocols/webrtc/to_stream.go index 920f4a2f..850a62d3 100644 --- a/internal/protocols/webrtc/to_stream.go +++ b/internal/protocols/webrtc/to_stream.go @@ -33,7 +33,7 @@ var errNoSupportedCodecsTo = errors.New( func ToStream( pc *PeerConnection, pathConf *conf.Path, - strm **stream.Stream, + subStream **stream.SubStream, log logger.Writer, ) ([]*description.Media, error) { var medias []*description.Media //nolint:prealloc @@ -197,7 +197,7 @@ func ToStream( return } - (*strm).WriteUnit(medi, forma, &unit.Unit{ + (*subStream).WriteUnit(medi, forma, &unit.Unit{ PTS: pts, NTP: ntp, RTPPackets: []*rtp.Packet{pkt}, diff --git a/internal/protocols/webrtc/to_stream_test.go b/internal/protocols/webrtc/to_stream_test.go index b259e54c..abbd8945 100644 --- a/internal/protocols/webrtc/to_stream_test.go +++ b/internal/protocols/webrtc/to_stream_test.go @@ -405,8 +405,8 @@ func TestToStream(t *testing.T) { err = pc2.GatherIncomingTracks() require.NoError(t, err) - var strm *stream.Stream - medias, err := ToStream(pc2, &conf.Path{}, &strm, nil) + var subStream *stream.SubStream + medias, err := ToStream(pc2, &conf.Path{}, &subStream, nil) require.NoError(t, err) require.Equal(t, ca.out, medias[0].Formats[0]) }) diff --git a/internal/recorder/format_fmp4.go b/internal/recorder/format_fmp4.go index 5670ef1b..5aad8f84 100644 --- a/internal/recorder/format_fmp4.go +++ b/internal/recorder/format_fmp4.go @@ -816,7 +816,6 @@ func (f *formatFMP4) initialize() bool { parsed = true codec.SampleRate = syncInfo.SampleRate() codec.ChannelCount = bsi.ChannelCount() - codec.Fscod = syncInfo.Fscod codec.Bsid = bsi.Bsid codec.Bsmod = bsi.Bsmod codec.Acmod = bsi.Acmod diff --git a/internal/recorder/recorder_test.go b/internal/recorder/recorder_test.go index 8ef2b1ae..e9af32da 100644 --- a/internal/recorder/recorder_test.go +++ b/internal/recorder/recorder_test.go @@ -72,12 +72,12 @@ func TestRecorder(t *testing.T) { }, }} - writeToStream := func(strm *stream.Stream, startDTS int64, startNTP time.Time) { + writeToStream := func(subStream *stream.SubStream, startDTS int64, startNTP time.Time) { for i := range 2 { pts := startDTS + int64(i)*100*90000/1000 ntp := startNTP.Add(time.Duration(i*100) * time.Millisecond) - strm.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ PTS: pts, NTP: ntp, Payload: unit.PayloadH264{ @@ -87,7 +87,7 @@ func TestRecorder(t *testing.T) { }, }) - strm.WriteUnit(desc.Medias[1], desc.Medias[1].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[1], desc.Medias[1].Formats[0], &unit.Unit{ PTS: pts, Payload: unit.PayloadH265{ { @@ -110,17 +110,17 @@ func TestRecorder(t *testing.T) { }, }) - strm.WriteUnit(desc.Medias[2], desc.Medias[2].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[2], desc.Medias[2].Formats[0], &unit.Unit{ PTS: pts * int64(desc.Medias[2].Formats[0].ClockRate()) / 90000, Payload: unit.PayloadMPEG4Audio{{1, 2, 3, 4}}, }) - strm.WriteUnit(desc.Medias[3], desc.Medias[3].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[3], desc.Medias[3].Formats[0], &unit.Unit{ PTS: pts * int64(desc.Medias[3].Formats[0].ClockRate()) / 90000, Payload: unit.PayloadG711{1, 2, 3, 4}, }) - strm.WriteUnit(desc.Medias[4], desc.Medias[4].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[4], desc.Medias[4].Formats[0], &unit.Unit{ PTS: pts * int64(desc.Medias[4].Formats[0].ClockRate()) / 90000, Payload: unit.PayloadLPCM{1, 2, 3, 4}, }) @@ -131,7 +131,6 @@ func TestRecorder(t *testing.T) { t.Run(ca, func(t *testing.T) { strm := &stream.Stream{ Desc: desc, - UseRTPPackets: false, WriteQueueSize: 512, RTPMaxPayloadSize: 1450, Parent: test.NilLogger, @@ -140,6 +139,13 @@ func TestRecorder(t *testing.T) { require.NoError(t, err) defer strm.Close() + subStream := &stream.SubStream{ + Stream: strm, + UseRTPPackets: false, + } + err = subStream.Initialize() + require.NoError(t, err) + dir, err := os.MkdirTemp("", "mediamtx-agent") require.NoError(t, err) defer os.RemoveAll(dir) @@ -204,16 +210,16 @@ func TestRecorder(t *testing.T) { } w.Initialize() - writeToStream(strm, + writeToStream(subStream, 50*90000, time.Date(2008, 5, 20, 22, 15, 25, 0, time.UTC)) - writeToStream(strm, + writeToStream(subStream, 52*90000, time.Date(2008, 5, 20, 22, 15, 27, 0, time.UTC)) // simulate a write error - strm.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ PTS: 0, Payload: unit.PayloadH264{ {5}, // IDR @@ -274,9 +280,10 @@ func TestRecorder(t *testing.T) { TimeScale: 44100, Codec: &mcodecs.MPEG4Audio{ Config: mpeg4audio.AudioSpecificConfig{ - Type: 2, - SampleRate: 44100, - ChannelCount: 2, + Type: 2, + SampleRate: 44100, + ChannelCount: 2, + ChannelConfig: 2, }, }, }, @@ -320,7 +327,7 @@ func TestRecorder(t *testing.T) { time.Sleep(50 * time.Millisecond) - writeToStream(strm, + writeToStream(subStream, 300*90000, time.Date(2010, 5, 20, 22, 15, 25, 0, time.UTC)) @@ -419,7 +426,6 @@ func TestRecorderFMP4NegativeInitialDTS(t *testing.T) { strm := &stream.Stream{ Desc: desc, - UseRTPPackets: false, WriteQueueSize: 512, RTPMaxPayloadSize: 1450, Parent: test.NilLogger, @@ -428,6 +434,13 @@ func TestRecorderFMP4NegativeInitialDTS(t *testing.T) { require.NoError(t, err) defer strm.Close() + subStream := &stream.SubStream{ + Stream: strm, + UseRTPPackets: false, + } + err = subStream.Initialize() + require.NoError(t, err) + dir, err := os.MkdirTemp("", "mediamtx-agent") require.NoError(t, err) defer os.RemoveAll(dir) @@ -447,7 +460,7 @@ func TestRecorderFMP4NegativeInitialDTS(t *testing.T) { w.Initialize() for i := range 3 { - strm.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ PTS: -50*90000/1000 + (int64(i) * 200 * 90000 / 1000), NTP: time.Date(2008, 5, 20, 22, 15, 25, 0, time.UTC), Payload: unit.PayloadH264{ @@ -457,7 +470,7 @@ func TestRecorderFMP4NegativeInitialDTS(t *testing.T) { }, }) - strm.WriteUnit(desc.Medias[1], desc.Medias[1].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[1], desc.Medias[1].Formats[0], &unit.Unit{ PTS: -100*44100/1000 + (int64(i) * 200 * 44100 / 1000), Payload: unit.PayloadMPEG4Audio{{1, 2, 3, 4}}, }) @@ -508,7 +521,6 @@ func TestRecorderFMP4NegativeDTSDiff(t *testing.T) { strm := &stream.Stream{ Desc: desc, - UseRTPPackets: false, WriteQueueSize: 512, RTPMaxPayloadSize: 1450, Parent: test.NilLogger, @@ -517,6 +529,13 @@ func TestRecorderFMP4NegativeDTSDiff(t *testing.T) { require.NoError(t, err) defer strm.Close() + subStream := &stream.SubStream{ + Stream: strm, + UseRTPPackets: false, + } + err = subStream.Initialize() + require.NoError(t, err) + dir, err := os.MkdirTemp("", "mediamtx-agent") require.NoError(t, err) defer os.RemoveAll(dir) @@ -535,25 +554,25 @@ func TestRecorderFMP4NegativeDTSDiff(t *testing.T) { } w.Initialize() - strm.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ PTS: 44100, NTP: time.Date(2008, 5, 20, 22, 15, 25, 0, time.UTC), Payload: unit.PayloadMPEG4Audio{{1, 2}}, }) - strm.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ PTS: 3 * 44100, NTP: time.Date(2008, 5, 20, 22, 15, 25, 0, time.UTC), Payload: unit.PayloadMPEG4Audio{{1, 2}}, }) - strm.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ PTS: 2 * 44100, NTP: time.Date(2008, 5, 20, 22, 15, 25, 0, time.UTC), Payload: unit.PayloadMPEG4Audio{{1, 2}}, }) - strm.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ PTS: 4 * 44100, NTP: time.Date(2008, 5, 20, 22, 15, 25, 0, time.UTC), Payload: unit.PayloadMPEG4Audio{{1, 2}}, @@ -602,7 +621,6 @@ func TestRecorderSkipTracksPartial(t *testing.T) { strm := &stream.Stream{ Desc: desc, - UseRTPPackets: false, WriteQueueSize: 512, RTPMaxPayloadSize: 1450, Parent: test.NilLogger, @@ -611,6 +629,13 @@ func TestRecorderSkipTracksPartial(t *testing.T) { require.NoError(t, err) defer strm.Close() + subStream := &stream.SubStream{ + Stream: strm, + UseRTPPackets: false, + } + err = subStream.Initialize() + require.NoError(t, err) + dir, err := os.MkdirTemp("", "mediamtx-agent") require.NoError(t, err) defer os.RemoveAll(dir) @@ -664,7 +689,6 @@ func TestRecorderSkipTracksFull(t *testing.T) { strm := &stream.Stream{ Desc: desc, - UseRTPPackets: false, WriteQueueSize: 512, RTPMaxPayloadSize: 1450, Parent: test.NilLogger, @@ -673,6 +697,13 @@ func TestRecorderSkipTracksFull(t *testing.T) { require.NoError(t, err) defer strm.Close() + subStream := &stream.SubStream{ + Stream: strm, + UseRTPPackets: false, + } + err = subStream.Initialize() + require.NoError(t, err) + dir, err := os.MkdirTemp("", "mediamtx-agent") require.NoError(t, err) defer os.RemoveAll(dir) @@ -728,7 +759,6 @@ func TestRecorderFMP4SegmentSwitch(t *testing.T) { strm := &stream.Stream{ Desc: desc, - UseRTPPackets: false, WriteQueueSize: 512, RTPMaxPayloadSize: 1450, Parent: test.NilLogger, @@ -737,6 +767,13 @@ func TestRecorderFMP4SegmentSwitch(t *testing.T) { require.NoError(t, err) defer strm.Close() + subStream := &stream.SubStream{ + Stream: strm, + UseRTPPackets: false, + } + err = subStream.Initialize() + require.NoError(t, err) + dir, err := os.MkdirTemp("", "mediamtx-agent") require.NoError(t, err) defer os.RemoveAll(dir) @@ -767,7 +804,7 @@ func TestRecorderFMP4SegmentSwitch(t *testing.T) { pts := 50 * time.Second ntp := time.Date(2008, 5, 20, 22, 15, 25, 0, time.UTC) - strm.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ PTS: int64(pts) * 90000 / int64(time.Second), NTP: ntp, Payload: unit.PayloadH264{ @@ -778,7 +815,7 @@ func TestRecorderFMP4SegmentSwitch(t *testing.T) { pts += 700 * time.Millisecond ntp = ntp.Add(700 * time.Millisecond) - strm.WriteUnit(desc.Medias[1], desc.Medias[1].Formats[0], &unit.Unit{ // segment switch should happen here + subStream.WriteUnit(desc.Medias[1], desc.Medias[1].Formats[0], &unit.Unit{ // segment switch should happen here PTS: int64(pts) * 44100 / int64(time.Second), NTP: ntp, Payload: unit.PayloadMPEG4Audio{{1, 2}}, @@ -787,7 +824,7 @@ func TestRecorderFMP4SegmentSwitch(t *testing.T) { pts += 400 * time.Millisecond ntp = ntp.Add(400 * time.Millisecond) - strm.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ PTS: int64(pts) * 90000 / int64(time.Second), NTP: ntp, Payload: unit.PayloadH264{ @@ -798,7 +835,7 @@ func TestRecorderFMP4SegmentSwitch(t *testing.T) { pts += 100 * time.Millisecond ntp = ntp.Add(100 * time.Millisecond) - strm.WriteUnit(desc.Medias[1], desc.Medias[1].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[1], desc.Medias[1].Formats[0], &unit.Unit{ PTS: int64(pts) * 44100 / int64(time.Second), NTP: ntp, Payload: unit.PayloadMPEG4Audio{{3, 4}}, @@ -807,7 +844,7 @@ func TestRecorderFMP4SegmentSwitch(t *testing.T) { pts += 400 * time.Millisecond ntp = ntp.Add(400 * time.Millisecond) - strm.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ PTS: int64(pts) * 90000 / int64(time.Second), NTP: ntp, Payload: unit.PayloadH264{ @@ -851,7 +888,6 @@ func TestRecorderTimeDriftDetector(t *testing.T) { strm := &stream.Stream{ Desc: desc, - UseRTPPackets: false, WriteQueueSize: 512, RTPMaxPayloadSize: 1450, Parent: test.NilLogger, @@ -860,6 +896,13 @@ func TestRecorderTimeDriftDetector(t *testing.T) { require.NoError(t, err) defer strm.Close() + subStream := &stream.SubStream{ + Stream: strm, + UseRTPPackets: false, + } + err = subStream.Initialize() + require.NoError(t, err) + dir, err := os.MkdirTemp("", "mediamtx-agent") require.NoError(t, err) defer os.RemoveAll(dir) @@ -916,7 +959,7 @@ func TestRecorderTimeDriftDetector(t *testing.T) { pts := startDTS + int64(i)*100*90000/1000 ntp := startNTP.Add(time.Duration(i*100) * time.Millisecond) - strm.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ PTS: pts, NTP: ntp, Payload: unit.PayloadH264{ @@ -926,7 +969,7 @@ func TestRecorderTimeDriftDetector(t *testing.T) { }, }) - strm.WriteUnit(desc.Medias[1], desc.Medias[1].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[1], desc.Medias[1].Formats[0], &unit.Unit{ PTS: pts * int64(desc.Medias[1].Formats[0].ClockRate()) / 90000, NTP: ntp, Payload: unit.PayloadMPEG4Audio{{1, 2, 3, 4}}, @@ -945,7 +988,7 @@ func TestRecorderTimeDriftDetector(t *testing.T) { pts := startDTS + int64(i)*100*90000/1000 ntp := startNTP.Add(time.Duration(i*100) * time.Millisecond) - strm.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ PTS: pts, NTP: ntp, Payload: unit.PayloadH264{ @@ -955,7 +998,7 @@ func TestRecorderTimeDriftDetector(t *testing.T) { }, }) - strm.WriteUnit(desc.Medias[1], desc.Medias[1].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[1], desc.Medias[1].Formats[0], &unit.Unit{ PTS: pts * int64(desc.Medias[1].Formats[0].ClockRate()) / 90000, NTP: ntp, Payload: unit.PayloadMPEG4Audio{{1, 2, 3, 4}}, @@ -967,7 +1010,7 @@ func TestRecorderTimeDriftDetector(t *testing.T) { driftedPTS := startDTS + 15*100*90000/1000 driftedNTP := startNTP.Add(15*100*time.Millisecond + 6*time.Second) // 6 second drift - strm.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ PTS: driftedPTS, NTP: driftedNTP, Payload: unit.PayloadH264{ @@ -995,7 +1038,7 @@ func TestRecorderTimeDriftDetector(t *testing.T) { pts := restartDTS + int64(i)*100*90000/1000 ntp := restartNTP.Add(time.Duration(i*100) * time.Millisecond) - strm.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ PTS: pts, NTP: ntp, Payload: unit.PayloadH264{ @@ -1005,7 +1048,7 @@ func TestRecorderTimeDriftDetector(t *testing.T) { }, }) - strm.WriteUnit(desc.Medias[1], desc.Medias[1].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[1], desc.Medias[1].Formats[0], &unit.Unit{ PTS: pts * int64(desc.Medias[1].Formats[0].ClockRate()) / 90000, NTP: ntp, Payload: unit.PayloadMPEG4Audio{{1, 2, 3, 4}}, diff --git a/internal/servers/hls/server_test.go b/internal/servers/hls/server_test.go index f824c7b3..ec246395 100644 --- a/internal/servers/hls/server_test.go +++ b/internal/servers/hls/server_test.go @@ -186,7 +186,6 @@ func TestServerRead(t *testing.T) { strm := &stream.Stream{ Desc: desc, - UseRTPPackets: false, WriteQueueSize: 512, RTPMaxPayloadSize: 1450, ReplaceNTP: false, @@ -195,6 +194,13 @@ func TestServerRead(t *testing.T) { err := strm.Initialize() require.NoError(t, err) + subStream := &stream.SubStream{ + Stream: strm, + UseRTPPackets: false, + } + err = subStream.Initialize() + require.NoError(t, err) + pm := &dummyPathManager{ findPathConfImpl: func(req defs.PathFindPathConfReq) (*conf.Path, error) { require.Equal(t, "teststream", req.AccessRequest.Name) @@ -251,9 +257,10 @@ func TestServerRead(t *testing.T) { { Codec: &codecs.MPEG4Audio{ Config: mpeg4audio.AudioSpecificConfig{ - Type: 2, - ChannelCount: 2, - SampleRate: 44100, + Type: 2, + ChannelCount: 2, + ChannelConfig: 2, + SampleRate: 44100, }, }, ClockRate: 90000, @@ -287,14 +294,14 @@ func TestServerRead(t *testing.T) { strm.WaitForReaders() for i := range 2 { - strm.WriteUnit(test.MediaH264, test.FormatH264, &unit.Unit{ + subStream.WriteUnit(test.MediaH264, test.FormatH264, &unit.Unit{ NTP: time.Time{}, PTS: int64(i) * 90000, Payload: unit.PayloadH264{ {5, 1}, // IDR }, }) - strm.WriteUnit(test.MediaMPEG4Audio, test.FormatMPEG4Audio, &unit.Unit{ + subStream.WriteUnit(test.MediaMPEG4Audio, test.FormatMPEG4Audio, &unit.Unit{ NTP: time.Time{}, PTS: int64(i) * 44100, Payload: unit.PayloadMPEG4Audio{{1, 2}}, @@ -328,14 +335,14 @@ func TestServerRead(t *testing.T) { strm.WaitForReaders() for i := range 2 { - strm.WriteUnit(test.MediaH264, test.FormatH264, &unit.Unit{ + subStream.WriteUnit(test.MediaH264, test.FormatH264, &unit.Unit{ NTP: time.Time{}, PTS: int64(i) * 90000, Payload: unit.PayloadH264{ {5, 1}, // IDR }, }) - strm.WriteUnit(test.MediaMPEG4Audio, test.FormatMPEG4Audio, &unit.Unit{ + subStream.WriteUnit(test.MediaMPEG4Audio, test.FormatMPEG4Audio, &unit.Unit{ NTP: time.Time{}, PTS: int64(i) * 44100, Payload: unit.PayloadMPEG4Audio{{1, 2}}, @@ -359,9 +366,10 @@ func TestServerRead(t *testing.T) { { Codec: &codecs.MPEG4Audio{ Config: mpeg4audio.AudioSpecificConfig{ - Type: 2, - ChannelCount: 2, - SampleRate: 44100, + Type: 2, + ChannelCount: 2, + ChannelConfig: 2, + SampleRate: 44100, }, }, ClockRate: 90000, @@ -408,7 +416,6 @@ func TestServerDirectory(t *testing.T) { strm := &stream.Stream{ Desc: desc, - UseRTPPackets: false, WriteQueueSize: 512, RTPMaxPayloadSize: 1450, Parent: test.NilLogger, @@ -416,6 +423,13 @@ func TestServerDirectory(t *testing.T) { err = strm.Initialize() require.NoError(t, err) + subStream := &stream.SubStream{ + Stream: strm, + UseRTPPackets: false, + } + err = subStream.Initialize() + require.NoError(t, err) + pm := &dummyPathManager{ addReaderImpl: func(_ defs.PathAddReaderReq) (defs.Path, *stream.Stream, error) { return &dummyPath{}, strm, nil @@ -457,7 +471,6 @@ func TestServerDynamicAlwaysRemux(t *testing.T) { strm := &stream.Stream{ Desc: desc, - UseRTPPackets: false, WriteQueueSize: 512, RTPMaxPayloadSize: 1450, Parent: test.NilLogger, @@ -465,6 +478,13 @@ func TestServerDynamicAlwaysRemux(t *testing.T) { err := strm.Initialize() require.NoError(t, err) + subStream := &stream.SubStream{ + Stream: strm, + UseRTPPackets: false, + } + err = subStream.Initialize() + require.NoError(t, err) + done := make(chan struct{}) pm := &dummyPathManager{ diff --git a/internal/servers/rtmp/conn.go b/internal/servers/rtmp/conn.go index 6c675289..18f94d65 100644 --- a/internal/servers/rtmp/conn.go +++ b/internal/servers/rtmp/conn.go @@ -229,15 +229,15 @@ func (c *conn) runPublish() error { return err } - var strm *stream.Stream + var subStream *stream.SubStream - medias, err := rtmp.ToStream(r, &strm) + medias, err := rtmp.ToStream(r, &subStream) if err != nil { return err } var path defs.Path - path, strm, err = c.pathManager.AddPublisher(defs.PathAddPublisherReq{ + path, subStream, err = c.pathManager.AddPublisher(defs.PathAddPublisherReq{ Author: c, Desc: &description.Session{Medias: medias}, UseRTPPackets: false, diff --git a/internal/servers/rtmp/server.go b/internal/servers/rtmp/server.go index 6a16acb7..c024497a 100644 --- a/internal/servers/rtmp/server.go +++ b/internal/servers/rtmp/server.go @@ -63,7 +63,7 @@ type serverMetrics interface { } type serverPathManager interface { - AddPublisher(req defs.PathAddPublisherReq) (defs.Path, *stream.Stream, error) + AddPublisher(req defs.PathAddPublisherReq) (defs.Path, *stream.SubStream, error) AddReader(req defs.PathAddReaderReq) (defs.Path, *stream.Stream, error) } diff --git a/internal/servers/rtmp/server_test.go b/internal/servers/rtmp/server_test.go index defa53c5..b249ea63 100644 --- a/internal/servers/rtmp/server_test.go +++ b/internal/servers/rtmp/server_test.go @@ -69,7 +69,7 @@ func TestServerPublish(t *testing.T) { n := 0 pathManager := &test.PathManager{ - AddPublisherImpl: func(req defs.PathAddPublisherReq) (defs.Path, *stream.Stream, error) { + AddPublisherImpl: func(req defs.PathAddPublisherReq) (defs.Path, *stream.SubStream, error) { require.Equal(t, "teststream", req.AccessRequest.Name) require.Equal(t, "user=myuser&pass=mypass¶m=value", req.AccessRequest.Query) require.Equal(t, "myuser", req.AccessRequest.Credentials.User) @@ -77,7 +77,6 @@ func TestServerPublish(t *testing.T) { strm = &stream.Stream{ Desc: req.Desc, - UseRTPPackets: false, WriteQueueSize: 512, RTPMaxPayloadSize: 1450, Parent: test.NilLogger, @@ -85,6 +84,13 @@ func TestServerPublish(t *testing.T) { err := strm.Initialize() require.NoError(t, err) + subStream := &stream.SubStream{ + Stream: strm, + UseRTPPackets: false, + } + err = subStream.Initialize() + require.NoError(t, err) + reader = &stream.Reader{Parent: test.NilLogger} reader.OnData( @@ -112,7 +118,7 @@ func TestServerPublish(t *testing.T) { strm.AddReader(reader) - return &dummyPath{}, strm, nil + return &dummyPath{}, subStream, nil }, } @@ -207,7 +213,6 @@ func TestServerRead(t *testing.T) { strm := &stream.Stream{ Desc: desc, - UseRTPPackets: false, WriteQueueSize: 512, RTPMaxPayloadSize: 1450, Parent: test.NilLogger, @@ -215,6 +220,13 @@ func TestServerRead(t *testing.T) { err := strm.Initialize() require.NoError(t, err) + subStream := &stream.SubStream{ + Stream: strm, + UseRTPPackets: false, + } + err = subStream.Initialize() + require.NoError(t, err) + pathManager := &test.PathManager{ AddReaderImpl: func(req defs.PathAddReaderReq) (defs.Path, *stream.Stream, error) { require.Equal(t, "teststream", req.AccessRequest.Name) @@ -269,14 +281,14 @@ func TestServerRead(t *testing.T) { strm.WaitForReaders() go func() { - strm.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ NTP: time.Time{}, Payload: unit.PayloadH264{ {5, 2, 3, 4}, // IDR }, }) - strm.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ NTP: time.Time{}, PTS: 2 * 90000, Payload: unit.PayloadH264{ @@ -284,7 +296,7 @@ func TestServerRead(t *testing.T) { }, }) - strm.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ NTP: time.Time{}, PTS: 3 * 90000, Payload: unit.PayloadH264{ diff --git a/internal/servers/rtsp/server.go b/internal/servers/rtsp/server.go index 4c583cd9..4fb916a1 100644 --- a/internal/servers/rtsp/server.go +++ b/internal/servers/rtsp/server.go @@ -78,7 +78,7 @@ type serverMetrics interface { type serverPathManager interface { FindPathConf(req defs.PathFindPathConfReq) (*conf.Path, error) Describe(req defs.PathDescribeReq) defs.PathDescribeRes - AddPublisher(_ defs.PathAddPublisherReq) (defs.Path, *stream.Stream, error) + AddPublisher(_ defs.PathAddPublisherReq) (defs.Path, *stream.SubStream, error) AddReader(_ defs.PathAddReaderReq) (defs.Path, *stream.Stream, error) } diff --git a/internal/servers/rtsp/server_test.go b/internal/servers/rtsp/server_test.go index 17d863e1..e6a71a65 100644 --- a/internal/servers/rtsp/server_test.go +++ b/internal/servers/rtsp/server_test.go @@ -81,14 +81,13 @@ func TestServerPublish(t *testing.T) { return &conf.Path{}, nil }, - AddPublisherImpl: func(req defs.PathAddPublisherReq) (defs.Path, *stream.Stream, error) { + AddPublisherImpl: func(req defs.PathAddPublisherReq) (defs.Path, *stream.SubStream, error) { require.Equal(t, "teststream", req.AccessRequest.Name) require.Equal(t, "param=value", req.AccessRequest.Query) require.True(t, req.AccessRequest.SkipAuth) strm = &stream.Stream{ Desc: req.Desc, - UseRTPPackets: true, WriteQueueSize: 512, RTPMaxPayloadSize: 1450, Parent: test.NilLogger, @@ -96,6 +95,13 @@ func TestServerPublish(t *testing.T) { err := strm.Initialize() require.NoError(t, err) + subStream := &stream.SubStream{ + Stream: strm, + UseRTPPackets: true, + } + err = subStream.Initialize() + require.NoError(t, err) + reader = &stream.Reader{Parent: test.NilLogger} reader.OnData( @@ -113,7 +119,7 @@ func TestServerPublish(t *testing.T) { strm.AddReader(reader) - return &dummyPath{}, strm, nil + return &dummyPath{}, subStream, nil }, } @@ -176,7 +182,6 @@ func TestServerRead(t *testing.T) { strm := &stream.Stream{ Desc: desc, - UseRTPPackets: false, WriteQueueSize: 512, RTPMaxPayloadSize: 1450, Parent: test.NilLogger, @@ -184,6 +189,13 @@ func TestServerRead(t *testing.T) { err := strm.Initialize() require.NoError(t, err) + subStream := &stream.SubStream{ + Stream: strm, + UseRTPPackets: false, + } + err = subStream.Initialize() + require.NoError(t, err) + n := 0 pathManager := &test.PathManager{ @@ -301,7 +313,7 @@ func TestServerRead(t *testing.T) { _, err = reader.Play(nil) require.NoError(t, err) - strm.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ NTP: time.Time{}, Payload: unit.PayloadH264{ {5, 2, 3, 4}, // IDR @@ -320,7 +332,6 @@ func TestServerRedirect(t *testing.T) { strm := &stream.Stream{ Desc: desc, - UseRTPPackets: true, WriteQueueSize: 512, RTPMaxPayloadSize: 1450, Parent: test.NilLogger, @@ -328,6 +339,13 @@ func TestServerRedirect(t *testing.T) { err := strm.Initialize() require.NoError(t, err) + subStream := &stream.SubStream{ + Stream: strm, + UseRTPPackets: true, + } + err = subStream.Initialize() + require.NoError(t, err) + pathManager := &test.PathManager{ DescribeImpl: func(req defs.PathDescribeReq) defs.PathDescribeRes { if req.AccessRequest.Name == "path1" { diff --git a/internal/servers/rtsp/session.go b/internal/servers/rtsp/session.go index b634ed77..d02aee87 100644 --- a/internal/servers/rtsp/session.go +++ b/internal/servers/rtsp/session.go @@ -51,6 +51,7 @@ type session struct { pathConf *conf.Path // record only path defs.Path stream *stream.Stream + subStream *stream.SubStream onUnreadHook func() packetsLost *counterdumper.Dumper decodeErrors *errordumper.Dumper @@ -136,6 +137,7 @@ func (s *session) onClose(err error) { s.path = nil s.stream = nil + s.subStream = nil s.discardedFrames.Stop() s.decodeErrors.Stop() @@ -310,7 +312,7 @@ func (s *session) onPlay(_ *gortsplib.ServerHandlerOnPlayCtx) (*base.Response, e // onRecord is called by rtspServer. func (s *session) onRecord(_ *gortsplib.ServerHandlerOnRecordCtx) (*base.Response, error) { - path, stream, err := s.pathManager.AddPublisher(defs.PathAddPublisherReq{ + path, subStream, err := s.pathManager.AddPublisher(defs.PathAddPublisherReq{ Author: s, Desc: s.rsession.AnnouncedDescription(), UseRTPPackets: true, @@ -333,11 +335,11 @@ func (s *session) onRecord(_ *gortsplib.ServerHandlerOnRecordCtx) (*base.Respons s.rsession, s.rsession.AnnouncedDescription().Medias, path.SafeConf(), - &s.stream, + &s.subStream, s) s.path = path - s.stream = stream + s.subStream = subStream return &base.Response{ StatusCode: base.StatusOK, diff --git a/internal/servers/srt/conn.go b/internal/servers/srt/conn.go index d0696e1b..b4c74b64 100644 --- a/internal/servers/srt/conn.go +++ b/internal/servers/srt/conn.go @@ -210,15 +210,15 @@ func (c *conn) runPublishReader(sconn srt.Conn, streamID *streamID, pathConf *co decodeErrors.Add(err) }) - var strm *stream.Stream + var subStream *stream.SubStream - medias, err := mpegts.ToStream(r, &strm, c) + medias, err := mpegts.ToStream(r, &subStream, c) if err != nil { return err } var path defs.Path - path, strm, err = c.pathManager.AddPublisher(defs.PathAddPublisherReq{ + path, subStream, err = c.pathManager.AddPublisher(defs.PathAddPublisherReq{ Author: c, Desc: &description.Session{Medias: medias}, UseRTPPackets: false, diff --git a/internal/servers/srt/server.go b/internal/servers/srt/server.go index 2001941f..a68228fb 100644 --- a/internal/servers/srt/server.go +++ b/internal/servers/srt/server.go @@ -65,7 +65,7 @@ type serverMetrics interface { type serverPathManager interface { FindPathConf(req defs.PathFindPathConfReq) (*conf.Path, error) - AddPublisher(req defs.PathAddPublisherReq) (defs.Path, *stream.Stream, error) + AddPublisher(req defs.PathAddPublisherReq) (defs.Path, *stream.SubStream, error) AddReader(req defs.PathAddReaderReq) (defs.Path, *stream.Stream, error) } diff --git a/internal/servers/srt/server_test.go b/internal/servers/srt/server_test.go index e3d6b2b1..1bae58cf 100644 --- a/internal/servers/srt/server_test.go +++ b/internal/servers/srt/server_test.go @@ -60,14 +60,13 @@ func TestServerPublish(t *testing.T) { require.Equal(t, "mypass", req.AccessRequest.Credentials.Pass) return &conf.Path{}, nil }, - AddPublisherImpl: func(req defs.PathAddPublisherReq) (defs.Path, *stream.Stream, error) { + AddPublisherImpl: func(req defs.PathAddPublisherReq) (defs.Path, *stream.SubStream, error) { require.Equal(t, "teststream", req.AccessRequest.Name) require.Equal(t, "param=value", req.AccessRequest.Query) require.True(t, req.AccessRequest.SkipAuth) strm = &stream.Stream{ Desc: req.Desc, - UseRTPPackets: false, WriteQueueSize: 512, RTPMaxPayloadSize: 1450, Parent: test.NilLogger, @@ -75,6 +74,13 @@ func TestServerPublish(t *testing.T) { err := strm.Initialize() require.NoError(t, err) + subStream := &stream.SubStream{ + Stream: strm, + UseRTPPackets: false, + } + err = subStream.Initialize() + require.NoError(t, err) + reader = &stream.Reader{Parent: test.NilLogger} reader.OnData( @@ -107,7 +113,7 @@ func TestServerPublish(t *testing.T) { strm.AddReader(reader) - return &dummyPath{}, strm, nil + return &dummyPath{}, subStream, nil }, } @@ -182,7 +188,6 @@ func TestServerRead(t *testing.T) { strm := &stream.Stream{ Desc: desc, - UseRTPPackets: false, WriteQueueSize: 512, RTPMaxPayloadSize: 1450, Parent: test.NilLogger, @@ -190,6 +195,13 @@ func TestServerRead(t *testing.T) { err := strm.Initialize() require.NoError(t, err) + subStream := &stream.SubStream{ + Stream: strm, + UseRTPPackets: false, + } + err = subStream.Initialize() + require.NoError(t, err) + pathManager := &test.PathManager{ AddReaderImpl: func(req defs.PathAddReaderReq) (defs.Path, *stream.Stream, error) { require.Equal(t, "teststream", req.AccessRequest.Name) @@ -232,7 +244,7 @@ func TestServerRead(t *testing.T) { strm.WaitForReaders() - strm.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ NTP: time.Time{}, Payload: unit.PayloadH264{ {5, 1}, // IDR @@ -262,7 +274,7 @@ func TestServerRead(t *testing.T) { return nil }) - strm.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ NTP: time.Time{}, Payload: unit.PayloadH264{ {5, 2}, diff --git a/internal/servers/webrtc/server.go b/internal/servers/webrtc/server.go index cdfa68aa..61b5a3ad 100644 --- a/internal/servers/webrtc/server.go +++ b/internal/servers/webrtc/server.go @@ -176,7 +176,7 @@ type serverMetrics interface { type serverPathManager interface { FindPathConf(req defs.PathFindPathConfReq) (*conf.Path, error) - AddPublisher(req defs.PathAddPublisherReq) (defs.Path, *stream.Stream, error) + AddPublisher(req defs.PathAddPublisherReq) (defs.Path, *stream.SubStream, error) AddReader(req defs.PathAddReaderReq) (defs.Path, *stream.Stream, error) } diff --git a/internal/servers/webrtc/server_test.go b/internal/servers/webrtc/server_test.go index 2759b01c..8bd30c79 100644 --- a/internal/servers/webrtc/server_test.go +++ b/internal/servers/webrtc/server_test.go @@ -211,14 +211,13 @@ func TestServerPublish(t *testing.T) { require.Equal(t, "mypass", req.AccessRequest.Credentials.Pass) return &conf.Path{}, nil }, - AddPublisherImpl: func(req defs.PathAddPublisherReq) (defs.Path, *stream.Stream, error) { + AddPublisherImpl: func(req defs.PathAddPublisherReq) (defs.Path, *stream.SubStream, error) { require.Equal(t, "teststream", req.AccessRequest.Name) require.Equal(t, "param=value", req.AccessRequest.Query) require.True(t, req.AccessRequest.SkipAuth) strm = &stream.Stream{ Desc: req.Desc, - UseRTPPackets: true, WriteQueueSize: 512, RTPMaxPayloadSize: 1450, Parent: test.NilLogger, @@ -226,6 +225,13 @@ func TestServerPublish(t *testing.T) { err := strm.Initialize() require.NoError(t, err) + subStream := &stream.SubStream{ + Stream: strm, + UseRTPPackets: true, + } + err = subStream.Initialize() + require.NoError(t, err) + reader = &stream.Reader{Parent: test.NilLogger} reader.OnData( @@ -246,7 +252,7 @@ func TestServerPublish(t *testing.T) { strm.AddReader(reader) - return &dummyPath{}, strm, nil + return &dummyPath{}, subStream, nil }, } @@ -467,7 +473,6 @@ func TestServerRead(t *testing.T) { strm := &stream.Stream{ Desc: desc, - UseRTPPackets: (ca.unit.Payload == nil), WriteQueueSize: 512, RTPMaxPayloadSize: 1450, Parent: test.NilLogger, @@ -475,6 +480,13 @@ func TestServerRead(t *testing.T) { err := strm.Initialize() require.NoError(t, err) + subStream := &stream.SubStream{ + Stream: strm, + UseRTPPackets: (ca.unit.Payload == nil), + } + err = subStream.Initialize() + require.NoError(t, err) + pathManager := &test.PathManager{ FindPathConfImpl: func(req defs.PathFindPathConfReq) (*conf.Path, error) { require.Equal(t, "teststream", req.AccessRequest.Name) @@ -537,13 +549,13 @@ func TestServerRead(t *testing.T) { if ca.unit.Payload == nil { clone := *ca.unit.RTPPackets[0] - strm.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ PTS: 0, NTP: time.Time{}, RTPPackets: []*rtp.Packet{&clone}, }) } else { - strm.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], r.Interface().(*unit.Unit)) + subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], r.Interface().(*unit.Unit)) } }() diff --git a/internal/servers/webrtc/session.go b/internal/servers/webrtc/session.go index 26016a04..4387ca06 100644 --- a/internal/servers/webrtc/session.go +++ b/internal/servers/webrtc/session.go @@ -233,15 +233,15 @@ func (s *session) runPublish() (int, error) { return 0, err } - var strm *stream.Stream + var subStream *stream.SubStream - medias, err := webrtc.ToStream(pc, pathConf, &strm, s) + medias, err := webrtc.ToStream(pc, pathConf, &subStream, s) if err != nil { return 0, err } var path defs.Path - path, strm, err = s.pathManager.AddPublisher(defs.PathAddPublisherReq{ + path, subStream, err = s.pathManager.AddPublisher(defs.PathAddPublisherReq{ Author: s, Desc: &description.Session{Medias: medias}, UseRTPPackets: true, diff --git a/internal/staticsources/handler.go b/internal/staticsources/handler.go index 75dca01d..1d436575 100644 --- a/internal/staticsources/handler.go +++ b/internal/staticsources/handler.go @@ -309,13 +309,7 @@ func (s *Handler) SetReady(req defs.PathSourceStaticSetReadyReq) defs.PathSource req.Res = make(chan defs.PathSourceStaticSetReadyRes) select { case s.chInstanceSetReady <- req: - res := <-req.Res - - if res.Err == nil { - s.instance.Log(logger.Info, "ready: %s", defs.MediasInfo(req.Desc.Medias)) - } - - return res + return <-req.Res case <-s.ctx.Done(): return defs.PathSourceStaticSetReadyRes{Err: fmt.Errorf("terminated")} diff --git a/internal/staticsources/hls/source.go b/internal/staticsources/hls/source.go index eafa7ac2..6b4d642d 100644 --- a/internal/staticsources/hls/source.go +++ b/internal/staticsources/hls/source.go @@ -37,10 +37,10 @@ func (s *Source) Log(level logger.Level, format string, args ...any) { // Run implements StaticSource. func (s *Source) Run(params defs.StaticSourceRunParams) error { - var strm *stream.Stream + var subStream *stream.SubStream defer func() { - if strm != nil { + if subStream != nil { s.Parent.SetNotReady(defs.PathSourceStaticSetNotReadyReq{}) } }() @@ -91,7 +91,7 @@ func (s *Source) Run(params defs.StaticSourceRunParams) error { decodeErrors.Add(err) }, OnTracks: func(tracks []*gohlslib.Track) error { - medias, err2 := hls.ToStream(c, tracks, params.Conf, &strm) + medias, err2 := hls.ToStream(c, tracks, params.Conf, &subStream) if err2 != nil { return err2 } @@ -105,7 +105,7 @@ func (s *Source) Run(params defs.StaticSourceRunParams) error { return res.Err } - strm = res.Stream + subStream = res.SubStream return nil }, diff --git a/internal/staticsources/mpegts/source.go b/internal/staticsources/mpegts/source.go index d9812fc6..f5d3b09e 100644 --- a/internal/staticsources/mpegts/source.go +++ b/internal/staticsources/mpegts/source.go @@ -113,9 +113,9 @@ func (s *Source) runReader(nc net.Conn) error { decodeErrors.Add(err) }) - var strm *stream.Stream + var subStream *stream.SubStream - medias, err := mpegts.ToStream(mr, &strm, s) + medias, err := mpegts.ToStream(mr, &subStream, s) if err != nil { return err } @@ -131,7 +131,7 @@ func (s *Source) runReader(nc net.Conn) error { defer s.Parent.SetNotReady(defs.PathSourceStaticSetNotReadyReq{}) - strm = res.Stream + subStream = res.SubStream for { nc.SetReadDeadline(time.Now().Add(time.Duration(s.ReadTimeout))) diff --git a/internal/staticsources/rpicamera/source.go b/internal/staticsources/rpicamera/source.go index 71bd1d06..c288eb60 100644 --- a/internal/staticsources/rpicamera/source.go +++ b/internal/staticsources/rpicamera/source.go @@ -153,10 +153,10 @@ func (s *Source) runPrimary(params defs.StaticSourceRunParams) error { medias = append(medias, mediaSecondary) } - var strm *stream.Stream + var subStream *stream.SubStream initializeStream := func() { - if strm == nil { + if subStream == nil { res := s.Parent.SetReady(defs.PathSourceStaticSetReadyReq{ Desc: &description.Session{Medias: medias}, UseRTPPackets: true, @@ -166,7 +166,7 @@ func (s *Source) runPrimary(params defs.StaticSourceRunParams) error { panic("should not happen") } - strm = res.Stream + subStream = res.SubStream } } @@ -190,7 +190,7 @@ func (s *Source) runPrimary(params defs.StaticSourceRunParams) error { for _, pkt := range pkts { pkt.Timestamp = uint32(pts) - strm.WriteUnit(medi, medi.Formats[0], &unit.Unit{ + subStream.WriteUnit(medi, medi.Formats[0], &unit.Unit{ PTS: pts, NTP: ntp, RTPPackets: []*rtp.Packet{pkt}, @@ -221,7 +221,7 @@ func (s *Source) runPrimary(params defs.StaticSourceRunParams) error { for _, pkt := range pkts { pkt.Timestamp = uint32(pts) pkt.PayloadType = 96 - strm.WriteUnit(mediaSecondary, mediaSecondary.Formats[0], &unit.Unit{ + subStream.WriteUnit(mediaSecondary, mediaSecondary.Formats[0], &unit.Unit{ PTS: pts, NTP: ntp, RTPPackets: []*rtp.Packet{pkt}, @@ -231,7 +231,7 @@ func (s *Source) runPrimary(params defs.StaticSourceRunParams) error { } defer func() { - if strm != nil { + if subStream != nil { s.Parent.SetNotReady(defs.PathSourceStaticSetNotReadyReq{}) } }() @@ -271,7 +271,7 @@ func (s *Source) runSecondary(params defs.StaticSourceRunParams) error { r.ctx, r.ctxCancel = context.WithCancel(context.Background()) defer r.ctxCancel() - path, origStream, err := s.waitForPrimary(r, params) + path, primaryStream, err := s.waitForPrimary(r, params) if err != nil { return err } @@ -294,8 +294,8 @@ func (s *Source) runSecondary(params defs.StaticSourceRunParams) error { rdr := &stream.Reader{Parent: s} rdr.OnData( - origStream.Desc.Medias[1], - origStream.Desc.Medias[1].Formats[0], + primaryStream.Desc.Medias[1], + primaryStream.Desc.Medias[1].Formats[0], func(u *unit.Unit) error { pkt := u.RTPPackets[0] @@ -305,7 +305,7 @@ func (s *Source) runSecondary(params defs.StaticSourceRunParams) error { } newPkt.PayloadType = 26 - res.Stream.WriteUnit(media, media.Formats[0], &unit.Unit{ + res.SubStream.WriteUnit(media, media.Formats[0], &unit.Unit{ PTS: u.PTS, NTP: u.NTP, RTPPackets: []*rtp.Packet{newPkt}, @@ -313,8 +313,8 @@ func (s *Source) runSecondary(params defs.StaticSourceRunParams) error { return nil }) - origStream.AddReader(rdr) - defer origStream.RemoveReader(rdr) + primaryStream.AddReader(rdr) + defer primaryStream.RemoveReader(rdr) select { case err = <-rdr.Error(): @@ -333,7 +333,7 @@ func (s *Source) waitForPrimary( params defs.StaticSourceRunParams, ) (defs.Path, *stream.Stream, error) { for { - path, origStream, err := s.Parent.AddReader(defs.PathAddReaderReq{ + path, primaryStream, err := s.Parent.AddReader(defs.PathAddReaderReq{ Author: r, AccessRequest: defs.PathAccessRequest{ Name: params.Conf.RPICameraPrimaryName, @@ -354,7 +354,7 @@ func (s *Source) waitForPrimary( return nil, nil, err } - return path, origStream, nil + return path, primaryStream, nil } } diff --git a/internal/staticsources/rtmp/source.go b/internal/staticsources/rtmp/source.go index 630bd05d..ad5c7ae7 100644 --- a/internal/staticsources/rtmp/source.go +++ b/internal/staticsources/rtmp/source.go @@ -101,9 +101,9 @@ func (s *Source) runReader(conn *gortmplib.Client) error { return err } - var strm *stream.Stream + var subStream *stream.SubStream - medias, err := rtmp.ToStream(r, &strm) + medias, err := rtmp.ToStream(r, &subStream) if err != nil { return err } @@ -123,7 +123,7 @@ func (s *Source) runReader(conn *gortmplib.Client) error { defer s.Parent.SetNotReady(defs.PathSourceStaticSetNotReadyReq{}) - strm = res.Stream + subStream = res.SubStream conn.NetConn().SetWriteDeadline(time.Time{}) diff --git a/internal/staticsources/rtp/source.go b/internal/staticsources/rtp/source.go index 547b998c..ebfb532b 100644 --- a/internal/staticsources/rtp/source.go +++ b/internal/staticsources/rtp/source.go @@ -132,7 +132,7 @@ func (s *Source) runReader(desc *description.Session, nc net.Conn) error { decodeErrors.Start() defer decodeErrors.Stop() - var strm *stream.Stream + var subStream *stream.SubStream timeDecoder := &rtptime.GlobalDecoder{} timeDecoder.Initialize() @@ -167,14 +167,14 @@ func (s *Source) runReader(desc *description.Session, nc net.Conn) error { var pkt rtp.Packet err = pkt.Unmarshal(buf[:n]) if err != nil { - if strm != nil { + if subStream != nil { decodeErrors.Add(err) continue } return err } - if strm == nil { + if subStream == nil { res := s.Parent.SetReady(defs.PathSourceStaticSetReadyReq{ Desc: desc, UseRTPPackets: true, @@ -186,7 +186,7 @@ func (s *Source) runReader(desc *description.Session, nc net.Conn) error { defer s.Parent.SetNotReady(defs.PathSourceStaticSetNotReadyReq{}) - strm = res.Stream + subStream = res.SubStream } media, ok := mediasByPayloadType[pkt.PayloadType] @@ -208,7 +208,7 @@ func (s *Source) runReader(desc *description.Session, nc net.Conn) error { continue } - strm.WriteUnit(media.desc, forma.desc, &unit.Unit{ + subStream.WriteUnit(media.desc, forma.desc, &unit.Unit{ PTS: pts, RTPPackets: []*rtp.Packet{pkt}, }) diff --git a/internal/staticsources/rtsp/source.go b/internal/staticsources/rtsp/source.go index 2186d972..c5f75036 100644 --- a/internal/staticsources/rtsp/source.go +++ b/internal/staticsources/rtsp/source.go @@ -234,13 +234,13 @@ func (s *Source) runInner(c *gortsplib.Client, u *base.URL, pathConf *conf.Path) Medias: medias, } - var strm *stream.Stream + var subStream *stream.SubStream rtsp.ToStream( c, desc2.Medias, pathConf, - &strm, + &subStream, s) res := s.Parent.SetReady(defs.PathSourceStaticSetReadyReq{ @@ -254,7 +254,7 @@ func (s *Source) runInner(c *gortsplib.Client, u *base.URL, pathConf *conf.Path) defer s.Parent.SetNotReady(defs.PathSourceStaticSetNotReadyReq{}) - strm = res.Stream + subStream = res.SubStream rangeHeader, err := createRangeHeader(pathConf) if err != nil { diff --git a/internal/staticsources/srt/source.go b/internal/staticsources/srt/source.go index 08f19dd6..8dcd67c3 100644 --- a/internal/staticsources/srt/source.go +++ b/internal/staticsources/srt/source.go @@ -98,9 +98,9 @@ func (s *Source) runReader(sconn srt.Conn) error { decodeErrors.Add(err) }) - var strm *stream.Stream + var subStream *stream.SubStream - medias, err := mpegts.ToStream(r, &strm, s) + medias, err := mpegts.ToStream(r, &subStream, s) if err != nil { return err } @@ -116,7 +116,7 @@ func (s *Source) runReader(sconn srt.Conn) error { defer s.Parent.SetNotReady(defs.PathSourceStaticSetNotReadyReq{}) - strm = res.Stream + subStream = res.SubStream for { sconn.SetReadDeadline(time.Now().Add(time.Duration(s.ReadTimeout))) diff --git a/internal/staticsources/webrtc/source.go b/internal/staticsources/webrtc/source.go index 067e1c9c..6fa70be6 100644 --- a/internal/staticsources/webrtc/source.go +++ b/internal/staticsources/webrtc/source.go @@ -67,9 +67,9 @@ func (s *Source) Run(params defs.StaticSourceRunParams) error { return err } - var strm *stream.Stream + var subStream *stream.SubStream - medias, err := webrtc.ToStream(client.PeerConnection(), params.Conf, &strm, s) + medias, err := webrtc.ToStream(client.PeerConnection(), params.Conf, &subStream, s) if err != nil { client.Close() //nolint:errcheck return err @@ -87,7 +87,7 @@ func (s *Source) Run(params defs.StaticSourceRunParams) error { defer s.Parent.SetNotReady(defs.PathSourceStaticSetNotReadyReq{}) - strm = rres.Stream + subStream = rres.SubStream client.StartReading() diff --git a/internal/stream/offline_av1.mp4 b/internal/stream/offline_av1.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..f79cce0e9d541edfeaf0118b1f7ec71eb16ddc56 GIT binary patch literal 10274 zcmaiaWmKHYvi9Ka?hb>yYtX@6g9LYXg1aVYf_spl!95TH1h?Ss?hXOIA=&%v^X+x- zx;67oRX@`JeK_1p)xje81;Wo13}0f-PcmJ6E^An7|Bw@&4w5)VDXYHU|61?altZ zb|Ek<1kn8cdnU7$nVrjD8~_h%GxNXg7p(;zJA#ZI%_6ITy6V~@WX008{wXK)+=7?KErM+P7wfk)zH zU%Q=Pi%9UMRmo^ARP36{TSgPDKZv0$IiZ_l3}FisH% zHbcSB%Xz~2OEdgyEcUPQzx~?(hhO|(er)g9?uznG6oltvVZR~F5W(?N4_U6C! zi;v>J&k^|SxfnY+y|ns2!essDuu_-0Ild4Qn;HL}{IYlexS&$(+4&TeSV?=*6O2ZrsF|XNskHPOZG23ThdKv1Bwjs(28kHkgTx%&w@sKE3t*&e4H+em;*{xn$X;LO9tmk zU}+7vuPDlvmD$<&LRg+KO&P_&^agCK!jNCUrK_oUZIuSTbzE3;S9SkURh6{CqkL~? zK^uWsHmr}O;>X@utn}g5I|$`zM_#;G$i&KBBp@(%QpS?-T!D&q#%$N8g>&y_ho!cu zlXvGT6odD<{b_YvG#|R15-to7Q3yYcw6oHkK_!?RE|C6OWLg8&EeiGt&26^lwhn@0ercDVqs)b<=luO3bV^7% z@N?C@hUgG;!f0^K6IV&)*6Xq}04(J>FdBA}+20?ZdInJn(dA}H94QFhw#O-+N2UY9 z(|6#J@;*E2v$7Df)(RV73ErO3z^9LwD43#rc&Sr<)eo4laPK?4&n_2Pq#7nS@rF*7rI`lw^6twQNYOn>4_4)-h9xmRM^%5XD0OOGnAFD2 zSn$R%)46z?0r_q84&zX+m_Xzx6@}-;h?8?wkW%dUs z%*9WSCh8{I3j=#fnc4s(Zn4S0nE((7BpD+|G>)RjoXq7&6wxj@uo{%umB7uA;&v~< z{d4Bjzk$v&Z{t({W)MA(AJZbfp%a7lpgBxKJ~oA(SP){0Ek@A~w$X{F4uvsejck}8 znjgjitP-d60`H#&&2@P0GgFXY-{^f+_51irij%VIBJX_T_MKB96dL<}UIQQ=m75fY z;OFRj{%!8n8u_F^i7+SryyN+LUKCps#{>&%qF2z;C)AtDL4rRqZL%rdC#Anwt#8BQ zO@+Ee zg{>8B_^JF#%@k*~)x$_<#~VYl{d9%JpUQOKycC{$F^3w}ar+eZ&`Le4K1~#gf#`V* zfWp-=1~ZZsRv{6ztjpU>o0qk$0xjpj_o=sLiRZ~k{m%Z(@sxMvsjB(}Pr7ojRsg0yk@Kdr1eBh+-?XB;AMmu?-GhHFKt^HAB0nIKz@ifWwH}7^+ft z&G2+I7VbpAVf7F&;Smb$qtD+M6ky=Kc1s4q$C(M;No*vsCKL?HVj|Lx4jz>w>nhB}*nJ@cLcC~$pK%-U|Xm+N9qHmkt zb!8WpZPdG>j-NJ}C7dXC!dx;!jz`T4v^>^)<>zk`7m$Sj)q5Y>o*!t*q$#KAEeqQG z^u?@P=lB8t8_-;oRYNWPMrz) zvaJEI(Jg0>ulB4a7y^f(sz>R;jzRk$4Rb>kPTPJ zl^!}Il6dsiAUUCJ1MlK`q*Zp8hmtk+;603lC@=W!17v9q>`--Q57XySFFb}4Xyq`i zg0Fs@aR*J2tf@EOBsKP+FQ{!fUE(};)}zV$l4_@F2vG0tcNnyGF$C!joXv>M-~6Py z)|G26Wk9pu;eB%JCPBARm#GY2X@S?t!%8SxJf0;DV0SSfHyC{bDR-@ZH|>@8IJL(} zl<#0Q8!wLcwc+*l7H=jzEEaQ9Pfu-qWXI#sg|8(F*IE@BiWm>clHUaWX>f-!gVRhf z?4U)Vn5a?SUHvL))c5I{%9% zxb#eyU&g7apIckkZzIu4KI!vY0h3wt^Lo8g$zxZ^W0DfT3ks*PKI=d(ro=zf&l&Q2 zLG;gN{-nE#SD48Lp6m72R627J`XT8FYaFpoXI65hOnv|fpVM!4<`tDNf4`m#xVyl0 z&$VZDm$Zw0V>1&Ku$`n;*bj)?+cWO02R(5_)dwH1SZX;BVWD4%&~{{zMK^5pVa3BK z9gNFRP1D00Jn(ML`^3L{vgOo>SH(IEYubw!6>Zb&r(lhD^JbM84Oq&h>--i5a-pFdHr$37p|OEE}?{0WA^pvxG8OB2&YfLh2}&F7?3Bm3dg!)CNX z6P%#-jpxnS{v`p20D9;5tuDGEwe#fZJz_L%r>b9VXTN#}H6z#|U2D@Xtr~bx&b!US zeLW>9D4=_=Z{&^7*^k1?=R`|0+B4MwdQhQYDw{sF4v>m zJ9)2*rf=X^7OI7xhFn3K@Ie`Za;tDzACg={*0;!(!VN;#y>O#)mDK*DENE_@zl6Qv z*2*XOh&b3if6G97!K8zOP{6|#B*(FGVE~XB6VTLoXIttX629ldo88k`-A~nD36$6p zRLoSIN`2Np^)wL*4klb(FI;rhKVksoNnE>akKFp@T+3A$mR8Ve8;uPHIY?3|65h899luHXR3r5Pch7V|bVOh>{1+Ae!?omz z_l|r5?wx#i^-lR)AsuMrrBI>Oujs94+EmP^%v3*Gz;82W;G%%+<(A5rx^ITk$XyhN zC4s7S4DAILx3}v_pxQk8okkKVH`ygE$C7798tbG zvJ5oAt}zecqHsSWogcS6kEq z|F&05HS|^;wKRWE_@-QXJyYUyHZ;R1a)>VHAZ`mO5d(~!L$?pLPpxKQ042meJ_)Q{ z%ny6MGwhHXGRl>Y$Pc`}D4`KsH;={A^^3Z;yu$i)sOWp^UCO^|i%Dk{z7zH-*WR0A zu$(@$q9a<*cF(N9H8Tx09M0a0vP*@aDM`nlls_@S}R zn6~v;SZNUVWfus27}HLi57e$N4^E~>Q_KCU@bk9i*(%;7u_HpVDUZ)$Me;&Gh6g8$ zOjdt9p1-L)TPcXwfBtZ`hMt3tbv=EASd(Svho(F2_kOT;eQ!GHTnDm+Hi(|CNO14Q zs$9X|mry73J&Y6^9b$Du>BucQO3XC1EKKm$nDCdpp zsM4-$q8yz3x0=urg zCiLG4^Uwj~*i)CaG+$tJMQsbN^aMb`S1Io?HMR%JxER+yuLgu1;Z#Xh2or=-=ioN_ z&$O^=-Q~Qj)1^rc$*dtfw3hR&Gk~i8KS|Z?I1^qnsVpij0e7$O$ zSUB0VEXjGMjB21ls6fH|eHva4oR?lZ%?NGhDy|h40WeNM$RbESX;(V)Y30s>l~`QYBo-CrN_bwxuqEYvj?b0gVtm)*aC3M`devo?hQ-!^4eD3W{P87~+vhD}xWvV%#MgA{1 zBV*2R=1h@2rf9bdHczJA3)Z9te+6@?7Dt*vL8L6reYwyH(&~lzx(qR)%|K^tRrY)s>;0ed=9J%Lw=L&;RxHp`0<;fh;(QW`@_X?`Vzg zDM{t_&gQiM0%A#OW9zjw)qyV8rb@UXVyx4x@>HOAc-o>*rdW`WTIScBBT{{)8|QuM zl#bO<-Su^fbtLxgx(<*5=sJ7Q=+3lCK5Ko#8oA_=Zsl! zR%xX}&wZ?Ei%#4JrB58`J6AoWM0qaj>D43-g+ZY7H$|*)sEkN0HVo@R{CE-{iL z4iHq_5Zp0GoMK`e7&Fu71B}U&8~Op(sV>??iAyI2BNOI3gV_WsqSED0?Dj?Hi8-Gw zyi^^d@gp$42p%d5!=~7t+|+12#>1dFFH*+R7v!3kz|khX3or#h&VLBaJf2{tsQ~|@ zmC8vMIwe~6T_|J-Wa@g-$Ij$Vx4BU#;YM4ilSV>!zy55H_KE)M)>QW3)OW+Ig4oRE zdMD+ii@N?z*P5rf-Z~45W;an9G#Dbu{>p;|Wi^Klt+i8Ia(x6LQofMR62PWY_7Co!sZL zQY6~fc7z#*b8%QHdPX3gEU6=+U{9He-KJAC_3GZOPdhDY{t@%T5amPXgY(?YR-}QB z;&+!@7Uxbx+pFz139Uj`0sH%ktE+LTV+jD!6FpDe;T;p`Lq`hU97B|g-!a5+MCPN? zK4q4`X=4ubAm2lWV)fACy~v^evbUe=jf_!p0r5UU%K%CQ$KV#?z%DnZ_ydW4yvtRT z_!O1Jcp45BF-2NwLbNW_>Iw|aVD^I_lxn&((xn7B#oST zE=*P;i@v2 zuRG4FQym*Fi_RJE5kK&#;KyI;vme3e9r1n6Mm2iy$#`C? zBTU34^seyT=~ijt*_bE?u7u_nH;y03YKZEMF>OGYB#6PA2YOhl?vVfy#afw`2y$V| zOfsVcDg~8na;KwlWDEw7Y$qwOaR=?N|C!$Li?M81GpB@Uxg& zstlLuNpVSDQ=ke3pp3@96@v6@5`s!G%)Z*2=zbS+q8Y$~@uW!4&$?8L#_^4=3j^)M zoDfA?9%a9pgI1A-X)6Wjxk8etq0Un=$*Zuf)AFm%4JQ#caW%4i_Q&n298$e-^gwla z92sk)ji1`yQ&974N0?nDTMyUn3h&SM_0~HSfC%4L*<;mPb=^?k%TqhZM;q?rBW0H@ z%Y)Ji^Dp`d3-xd&Io~>h?jUP(>vBaX?SamyJXUbSK(mGZV|?CT(@;5OkaQgSzoh2^L-Ize2gKxtOpiv!WV!(fnvTCGCs;T;Y>#8+}B5v{B0-+xu_{bdcSBq?hVs#QRY zu{tgLqX?@3!ShVvYQTjg?6UfZ@t4bTo3Q-Q^L9b=;?aX>X?6dXrHbH#lgRA%=&LBG zquT}%ma#SjGg(TaYRA@EN=^;_H@k==($hoAq``jhJKg;QWBp{2hYV%%bCs*uT~0}^ zL9QLZCwpgyCSG{KqrDFjIkaORRq?ms(3I3>jz}`Z8Y{^7a;ch`dL?d3p+zM%lpY)- z+tq>LD_5&Kp7Z!?`)OVRCy686lh09sNczVGwz`{ZRswRA$)NDN=55mDa3D{lS89Zh zQ;Zg2;5s(qVVYK&-VM!Q9mYBaDv9Qjb^=6+X5NlZV<3pl=^b>V*E#PQ@NqeYM$<5i zx?`2b)0lr}teM@%%&4mXwnrkZVv15v!9)S!%TL8?2L_wVoHa!&ue1-SoF{W!%$f7( zSs$Eu)96nfSdCbM9f76)3|-(Myv~5HYiKRw68~ zi|lBG@Xv6onGr8&^z5Dd9(iB@-kmIYK|YjZ9@;t`qr}kp3s7A6Cy-S4V{Ic;Y3{iW zot}G!Wb&9i(U8mVt?ybo%HdI&TlV-VvgpzDw&LE?9i?;aFzwa5$grUjG#j#Uxz;pm z$+6&5q$wia52z?k<+<5n1y@z!CKPUrFh;-&i-xM>dt=D$f>J`xED_C8Y7W&!sxRGR zb!RF@m6Sj7DtTVfPZvp4w=ORSplTcXeZA$WMlE2%tR6b~W>U9^_{goJ2lun&zyny` zELXc0$WEw0K62y`YRYV;o zN^|j>gkG80B}mF?#vg9h;cenp#-mr~A^IEvlC1Lk)u}Ou$&prLIyzLODu-_3z0QGn zjK0F5dEyj&P$BOk9bAuEBqyuvv)A-s${0Xvw<$)IoL@p>h|!pw`SZ4>xPok3Yr5t$ z6v6`_+vBFR6yCJL)|bh~EG~n>SMadPU9-1}&{e8n$3;QlA zrb{>HaKuDkHD*b`5=Jq3cxT*ZpN0nE7qxZ&pE$Y?I>p{ZyMViEPuc29L#Rti zdy9*m6f1<*+ymsAAPjWK?>YC;+lI;4x-Ed7GX#A|CNb8j`R^vL*dGM2=wW?eve=4r zzaVXckiUA)Ix<^$HJ_?PoDs=4WOs^9NwK1fM!G0wSt!olJb$S4na`_U#rwr|?q;nq zqwLuPWHpjo)fA3qfm?$ivvHqwJLTJfe^Wq5k=)HmCUd^f7xwi8%an0VKW8nQXoRs* zNZ&=-L6sMWsQ8huJ7Tc~KkoG4%Xxvc;q%5_>h0}oBmZy*s=*ev>%#^}PNdKIYnDrf z8pLT)sT!j{q4!}XvSA*{xA9Jv+guAQZ`cyCuyu#@lUq3sRJ7+c@e_;Or5BYc+Z^;L zL=rI#>rfFiklt0HETC!i>G`br&H)O$j9JT*va=MYqwh)^N+yK>A>OU1HGh;*3nTeJ|(>qx^L^#IB5fe#+dy+ z#8KE0l`5t0L6KNd>cclfFwXo1#?U6{++Y4R!3+2NK0G$JO59H}in<%c!(hJoV zdu%?OeoD;BuE{BQoQ!v_`j$V~4Zmf;dBSq(=%B}ZKB!^%qSBHku_*JIgzD*i@OKY4 zkuu#AK}6(lMf%@|==%-nad!)Vyw+8|Gv&%BOEt9SWC6k!jPt!jgh)H5eUCuq##Q3V z)Ipsex@;eu$!gKbt zjmmk;V}Y478gQ8^-*8es8!I%>>wRDFMNVB+m5xBPgk#kL9P<*NmHW6(BvX~7DiQER z&1{yR6GIf9w`H_#WURlBWh}3-A%-uOc{R$ix$~2H>ypJC+q`e0Ax-y8EOo3Hud+7z z3aO)YlOY3Ih`2jFGxsxJuZ2Cq8Jdm)s48vi)~Tks&S19(CY*ux%DwEhW#*J1q{G!) zFA|)XE=N%Z#XCH#&S`NckEnd~B z?{?A~1TV8f+9rEb!X+UrASI9AAVV$2^iV-_#P=iDcLt`QR|zWkzqMrv!d7B{oATEx znGL)@C3sb+b8?T7*DXLBl5B`^O1}Vmm6GGI^C6E({7C;CIVzzlfvXY2%0>%GqElXW zS>z`8usZ7~xn?8&Q_jUzi(F9^Z_PS?NDkZz8Jon4m{%NHhi+wwk>ndMS)-#Oy-pD9 zMS+;`P*ZB9ArRr0Cl1)2^sj_20Q@Z z1o*Xiq}ihiiU^v}3>xF4;~)pZLqI12{tn;+JZ$0;@;_(q&~!eb6mn|@a$p0YL!lFm zQz*m$fIt95AmB{lfC7v{MnWf6rcnNkZk3(}a$vDR0T5^^8KnQ*J^=t0G0q4fcELC- zfWY411qXA0FU9=EXF0Hdut1~~<9}GdI5-#yfb%;0pWsQ>qCX_40K#A0|H7NVr65zo zsRMBSQ{bOcfQUf2r2K!V!7N~EEC34c@B04KF&O3ZhZ+|^@F?{U^>6&8K2#u57BS9$ zRtd(zBt!t5hwy)bzi<2gAt43V2ly8r$$} zQD;#F{@&M4@V+)ZfHPuJL+FI#Q2#*#Uv>(dkunfs&746V@YnYJ&Im4#jW!V57WrTJ z4=@QXHG(8{%6~}yteqhc-CFTq!7n5%fw(rZ|H4zj^$`PtqsUYKD%%P0dt0%AgcMK^ zvN(TL9rzc@#vuj^ImsaVcY!algb+<&a-Kls>BN5=zc;po1Cvca93eT8LFUgDD!}ql G<^Kb3q`!dx literal 0 HcmV?d00001 diff --git a/internal/stream/offline_h264.mp4 b/internal/stream/offline_h264.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..e3d8858c4068adda52f61443b3b36604c2db28b0 GIT binary patch literal 18347 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 QREl~981(*0R+7m716j+75&!@I literal 0 HcmV?d00001 diff --git a/internal/stream/offline_h265.mp4 b/internal/stream/offline_h265.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..1df8c366bb63a103ba1c32eefe62e24874d09d82 GIT binary patch literal 17557 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 C!gz)N literal 0 HcmV?d00001 diff --git a/internal/stream/rtp_encoder.go b/internal/stream/rtp_encoder.go index ca32063a..65485f73 100644 --- a/internal/stream/rtp_encoder.go +++ b/internal/stream/rtp_encoder.go @@ -1,6 +1,8 @@ package stream import ( + "fmt" + "github.com/bluenviron/gortsplib/v5/pkg/format" "github.com/bluenviron/gortsplib/v5/pkg/format/rtpac3" "github.com/bluenviron/gortsplib/v5/pkg/format/rtpav1" @@ -25,6 +27,14 @@ func ptrOf[T any](v T) *T { return &v } +type rtpEncoderNotAvailableError struct { + format format.Format +} + +func (e rtpEncoderNotAvailableError) Error() string { + return fmt.Sprintf("RTP encoder not available for format %T", e.format) +} + type rtpEncoder interface { encode(unit.Payload) ([]*rtp.Packet, error) } @@ -377,6 +387,6 @@ func newRTPEncoder( return (*rtpEncoderKLV)(wrapped), nil default: - return nil, nil + return nil, rtpEncoderNotAvailableError{forma} } } diff --git a/internal/stream/stream.go b/internal/stream/stream.go index bdd46dc0..7e206558 100644 --- a/internal/stream/stream.go +++ b/internal/stream/stream.go @@ -2,6 +2,7 @@ package stream import ( + "os" "sync" "sync/atomic" "time" @@ -9,27 +10,224 @@ import ( "github.com/bluenviron/gortsplib/v5" "github.com/bluenviron/gortsplib/v5/pkg/description" "github.com/bluenviron/gortsplib/v5/pkg/format" + "github.com/bluenviron/mediacommon/v2/pkg/codecs/mpeg4audio" + "github.com/bluenviron/mediacommon/v2/pkg/formats/mp4/codecs" + "github.com/bluenviron/mediacommon/v2/pkg/formats/pmp4" "github.com/pion/rtp" + "github.com/bluenviron/mediamtx/internal/conf" "github.com/bluenviron/mediamtx/internal/errordumper" "github.com/bluenviron/mediamtx/internal/logger" - "github.com/bluenviron/mediamtx/internal/unit" ) +func mediasFromAlwaysAvailableFile(alwaysAvailableFile string) ([]*description.Media, error) { + f, err := os.Open(alwaysAvailableFile) + if err != nil { + return nil, err + } + defer f.Close() + + var presentation pmp4.Presentation + err = presentation.Unmarshal(f) + if err != nil { + return nil, err + } + + var medias []*description.Media + + for _, track := range presentation.Tracks { + switch codec := track.Codec.(type) { + case *codecs.AV1: + medias = append(medias, &description.Media{ + Type: description.MediaTypeVideo, + Formats: []format.Format{&format.AV1{ + PayloadTyp: 96, + }}, + }) + + case *codecs.VP9: + medias = append(medias, &description.Media{ + Type: description.MediaTypeVideo, + Formats: []format.Format{&format.VP9{ + PayloadTyp: 96, + }}, + }) + + case *codecs.H265: + medias = append(medias, &description.Media{ + Type: description.MediaTypeVideo, + Formats: []format.Format{&format.H265{ + PayloadTyp: 96, + }}, + }) + + case *codecs.H264: + medias = append(medias, &description.Media{ + Type: description.MediaTypeVideo, + Formats: []format.Format{&format.H264{ + PayloadTyp: 96, + PacketizationMode: 1, + }}, + }) + + case *codecs.Opus: + medias = append(medias, &description.Media{ + Type: description.MediaTypeAudio, + Formats: []format.Format{&format.Opus{ + PayloadTyp: 96, + ChannelCount: 2, + }}, + }) + + case *codecs.MPEG4Audio: + medias = append(medias, &description.Media{ + Type: description.MediaTypeAudio, + Formats: []format.Format{&format.MPEG4Audio{ + PayloadTyp: 96, + SizeLength: 13, + IndexLength: 3, + IndexDeltaLength: 3, + Config: &mpeg4audio.AudioSpecificConfig{ + Type: mpeg4audio.ObjectTypeAACLC, + SampleRate: codec.Config.SampleRate, + ChannelConfig: codec.Config.ChannelConfig, + }, + }}, + }) + + case *codecs.LPCM: + medias = append(medias, &description.Media{ + Type: description.MediaTypeAudio, + Formats: []format.Format{&format.LPCM{ + PayloadTyp: 96, + BitDepth: codec.BitDepth, + SampleRate: codec.SampleRate, + ChannelCount: codec.ChannelCount, + }}, + }) + } + } + + return medias, nil +} + +func mediasFromAlwaysAvailableTracks(alwaysAvailableTracks []conf.AlwaysAvailableTrack) []*description.Media { + var medias []*description.Media + + for _, track := range alwaysAvailableTracks { + switch track.Codec { + case conf.CodecAV1: + medias = append(medias, &description.Media{ + Type: description.MediaTypeVideo, + Formats: []format.Format{&format.AV1{ + PayloadTyp: 96, + }}, + }) + + case conf.CodecVP9: + medias = append(medias, &description.Media{ + Type: description.MediaTypeVideo, + Formats: []format.Format{&format.VP9{ + PayloadTyp: 96, + }}, + }) + + case conf.CodecH265: + medias = append(medias, &description.Media{ + Type: description.MediaTypeVideo, + Formats: []format.Format{&format.H265{ + PayloadTyp: 96, + }}, + }) + + case conf.CodecH264: + medias = append(medias, &description.Media{ + Type: description.MediaTypeVideo, + Formats: []format.Format{&format.H264{ + PayloadTyp: 96, + PacketizationMode: 1, + }}, + }) + + case conf.CodecOpus: + medias = append(medias, &description.Media{ + Type: description.MediaTypeAudio, + Formats: []format.Format{&format.Opus{ + PayloadTyp: 96, + ChannelCount: 2, + }}, + }) + + case conf.CodecMPEG4Audio: + medias = append(medias, &description.Media{ + Type: description.MediaTypeAudio, + Formats: []format.Format{&format.MPEG4Audio{ + PayloadTyp: 96, + SizeLength: 13, + IndexLength: 3, + IndexDeltaLength: 3, + Config: &mpeg4audio.AudioSpecificConfig{ + Type: mpeg4audio.ObjectTypeAACLC, + SampleRate: track.SampleRate, + ChannelConfig: uint8(track.ChannelCount), + }, + }}, + }) + + case conf.CodecG711: + medias = append(medias, &description.Media{ + Type: description.MediaTypeAudio, + Formats: []format.Format{&format.G711{ + PayloadTyp: func() uint8 { + switch { + case track.ChannelCount == 1 && track.MULaw: + return 0 + case track.ChannelCount == 1 && !track.MULaw: + return 8 + default: + return 96 + } + }(), + MULaw: track.MULaw, + SampleRate: track.SampleRate, + ChannelCount: track.ChannelCount, + }}, + }) + + case conf.CodecLPCM: + medias = append(medias, &description.Media{ + Type: description.MediaTypeAudio, + Formats: []format.Format{&format.LPCM{ + PayloadTyp: 96, + BitDepth: 16, + SampleRate: track.SampleRate, + ChannelCount: track.ChannelCount, + }}, + }) + } + } + + return medias +} + // Stream is a media stream. // It stores tracks, readers and allows to write data to readers, remuxing it when needed. type Stream struct { - Desc *description.Session - UseRTPPackets bool - WriteQueueSize int - RTPMaxPayloadSize int - ReplaceNTP bool - Parent logger.Writer + Desc *description.Session + AlwaysAvailable bool + AlwaysAvailableFile string + AlwaysAvailableTracks []conf.AlwaysAvailableTrack + WriteQueueSize int + RTPMaxPayloadSize int + ReplaceNTP bool + Parent logger.Writer + mutex sync.RWMutex + subStream *SubStream + offlineSubStream *offlineSubStream bytesReceived *uint64 bytesSent *uint64 medias map[*description.Media]*streamMedia - mutex sync.RWMutex rtspStream *gortsplib.ServerStream rtspsStream *gortsplib.ServerStream readers map[*Reader]struct{} @@ -40,6 +238,31 @@ type Stream struct { // Initialize initializes a Stream. func (s *Stream) Initialize() error { + if s.AlwaysAvailable { + if s.Desc != nil { + panic("should not happen") + } + if !s.ReplaceNTP { + panic("should not happen") + } + + var medias []*description.Media + + if s.AlwaysAvailableFile != "" { + var err error + medias, err = mediasFromAlwaysAvailableFile(s.AlwaysAvailableFile) + if err != nil { + return err + } + } else { + medias = mediasFromAlwaysAvailableTracks(s.AlwaysAvailableTracks) + } + + s.Desc = &description.Session{ + Medias: medias, + } + } + s.bytesReceived = new(uint64) s.bytesSent = new(uint64) s.medias = make(map[*description.Media]*streamMedia) @@ -58,9 +281,9 @@ func (s *Stream) Initialize() error { s.processingErrors.Start() for _, media := range s.Desc.Medias { - s.medias[media] = &streamMedia{ + sm := &streamMedia{ media: media, - useRTPPackets: s.UseRTPPackets, + alwaysAvailable: s.AlwaysAvailable, rtpMaxPayloadSize: s.RTPMaxPayloadSize, replaceNTP: s.ReplaceNTP, onBytesReceived: s.onBytesReceived, @@ -69,7 +292,15 @@ func (s *Stream) Initialize() error { processingErrors: s.processingErrors, parent: s.Parent, } - err := s.medias[media].initialize() + err := sm.initialize() + if err != nil { + return err + } + s.medias[media] = sm + } + + if s.AlwaysAvailable { + err := s.StartOfflineSubStream() if err != nil { return err } @@ -80,6 +311,10 @@ func (s *Stream) Initialize() error { // Close closes all resources of the stream. func (s *Stream) Close() { + if s.offlineSubStream != nil { + s.offlineSubStream.close(false) + } + s.processingErrors.Stop() if s.rtspStream != nil { @@ -90,6 +325,29 @@ func (s *Stream) Close() { } } +// StartOfflineSubStream starts the offline substream. +func (s *Stream) StartOfflineSubStream() error { + if !s.AlwaysAvailable { + panic("should not happen") + } + + oss := &offlineSubStream{ + stream: s, + } + err := oss.initialize() + if err != nil { + return err + } + + if s.offlineSubStream != nil { + s.Parent.Log(logger.Info, "stream is offline") + } + + s.offlineSubStream = oss + + return nil +} + // BytesReceived returns received bytes. func (s *Stream) BytesReceived() uint64 { return atomic.LoadUint64(s.bytesReceived) @@ -202,17 +460,6 @@ func (s *Stream) WaitForReaders() { <-s.hasReaders } -// WriteUnit writes a Unit. -func (s *Stream) WriteUnit(medi *description.Media, forma format.Format, u *unit.Unit) { - sm := s.medias[medi] - sf := sm.formats[forma] - - s.mutex.RLock() - defer s.mutex.RUnlock() - - sf.writeUnit(u) -} - func (s *Stream) onBytesReceived(v uint64) { atomic.AddUint64(s.bytesReceived, v) } diff --git a/internal/stream/stream_format.go b/internal/stream/stream_format.go index ff221976..cc46a0ed 100644 --- a/internal/stream/stream_format.go +++ b/internal/stream/stream_format.go @@ -2,7 +2,6 @@ package stream import ( "crypto/rand" - "fmt" "time" "github.com/bluenviron/gortsplib/v5/pkg/description" @@ -15,6 +14,12 @@ import ( "github.com/bluenviron/mediamtx/internal/unit" ) +func multiplyAndDivide(v, m, d int64) int64 { + secs := v / d + dec := v % d + return (secs*m + dec*m/d) +} + func unitSize(u *unit.Unit) uint64 { n := uint64(0) for _, pkt := range u.RTPPackets { @@ -35,7 +40,7 @@ func randUint32() (uint32, error) { type streamFormat struct { format format.Format media *description.Media - useRTPPackets bool + alwaysAvailable bool rtpMaxPayloadSize int replaceNTP bool processingErrors *errordumper.Dumper @@ -44,151 +49,31 @@ type streamFormat struct { writeRTSP func(*description.Media, []*rtp.Packet, time.Time) parent logger.Writer - rtpDecoder rtpDecoder - formatUpdater formatUpdater - unitRemuxer unitRemuxer - rtpEncoder rtpEncoder - ptsOffset uint32 - ntpEstimator *ntpestimator.Estimator - onDatas map[*Reader]OnDataFunc + firstReceived bool + lastPTS int64 + lastSystemTime time.Time + ptsOffset int64 + formatUpdater formatUpdater + unitRemuxer unitRemuxer + rtpEncoder rtpEncoder + rtpTimeOffset uint32 + ntpEstimator *ntpestimator.Estimator + onDatas map[*Reader]OnDataFunc } func (sf *streamFormat) initialize() error { - sf.onDatas = make(map[*Reader]OnDataFunc) - - if sf.useRTPPackets { - var err error - sf.rtpDecoder, err = newRTPDecoder(sf.format) - if err != nil { - return err - } - } + sf.lastSystemTime = time.Now() sf.formatUpdater = newFormatUpdater(sf.format) sf.unitRemuxer = newUnitRemuxer(sf.format) - if !sf.useRTPPackets { - var err error - sf.rtpEncoder, err = newRTPEncoder(sf.format, sf.rtpMaxPayloadSize, nil, nil) - if err != nil { - return err - } - - if sf.rtpEncoder == nil { - return fmt.Errorf("RTP encoder not available for format %T", sf.format) - } - - sf.ptsOffset, err = randUint32() - if err != nil { - return err - } - } - if sf.replaceNTP { sf.ntpEstimator = &ntpestimator.Estimator{ ClockRate: sf.format.ClockRate(), } } - return nil -} - -func (sf *streamFormat) writeUnit(u *unit.Unit) { - err := sf.writeUnitInner(u) - if err != nil { - sf.processingErrors.Add(err) - return - } -} - -func (sf *streamFormat) writeUnitInner(u *unit.Unit) error { - if sf.useRTPPackets { - if len(u.RTPPackets) != 1 { - panic("should not happen") - } - if !u.NilPayload() { - panic("should not happen") - } - - if sf.rtpDecoder != nil { - var err error - u.Payload, err = sf.rtpDecoder.decode(u.RTPPackets[0]) - if err != nil { - return err - } - } - - if sf.rtpEncoder == nil { - for _, pkt := range u.RTPPackets { - if len(pkt.Payload) > sf.rtpMaxPayloadSize { - var err error - sf.rtpEncoder, err = newRTPEncoder(sf.format, sf.rtpMaxPayloadSize, ptrOf(pkt.SSRC), ptrOf(pkt.SequenceNumber)) - if err != nil { - return err - } - - if sf.rtpEncoder == nil { - return fmt.Errorf("RTP payload size (%d) is greater than maximum allowed (%d)", - len(pkt.Payload), sf.rtpMaxPayloadSize) - } - - sf.ptsOffset = pkt.Timestamp - uint32(u.PTS) - - sf.parent.Log(logger.Info, "RTP packets are too big, remuxing them into smaller ones") - break - } - } - } - - if sf.rtpEncoder != nil { - u.RTPPackets = nil - } - } else { - if len(u.RTPPackets) != 0 { - panic("should not happen") - } - if u.NilPayload() { - panic("should not happen") - } - } - - if !u.NilPayload() { - sf.formatUpdater(sf.format, u.Payload) - - u.Payload = sf.unitRemuxer(sf.format, u.Payload) - - if sf.rtpEncoder != nil && !u.NilPayload() { - var err error - u.RTPPackets, err = sf.rtpEncoder.encode(u.Payload) - if err != nil { - return err - } - - for _, pkt := range u.RTPPackets { - pkt.Timestamp += sf.ptsOffset + uint32(u.PTS) - } - } - } - - if sf.replaceNTP { - u.NTP = sf.ntpEstimator.Estimate(u.PTS) - } - - size := unitSize(u) - sf.onBytesReceived(size) - - sf.writeRTSP(sf.media, u.RTPPackets, u.NTP) - - for sr, onData := range sf.onDatas { - csr := sr - cOnData := onData - sr.push(func() error { - if !csr.SkipBytesSent { - sf.onBytesSent(size) - } - return cOnData(u) - }) - } + sf.onDatas = make(map[*Reader]OnDataFunc) return nil } diff --git a/internal/stream/stream_media.go b/internal/stream/stream_media.go index c5933e96..b92d401c 100644 --- a/internal/stream/stream_media.go +++ b/internal/stream/stream_media.go @@ -12,7 +12,7 @@ import ( type streamMedia struct { media *description.Media - useRTPPackets bool + alwaysAvailable bool rtpMaxPayloadSize int replaceNTP bool onBytesReceived func(uint64) @@ -31,7 +31,7 @@ func (sm *streamMedia) initialize() error { sf := &streamFormat{ format: forma, media: sm.media, - useRTPPackets: sm.useRTPPackets, + alwaysAvailable: sm.alwaysAvailable, rtpMaxPayloadSize: sm.rtpMaxPayloadSize, replaceNTP: sm.replaceNTP, processingErrors: sm.processingErrors, diff --git a/internal/stream/stream_test.go b/internal/stream/stream_test.go index 559a13d3..cfba0b63 100644 --- a/internal/stream/stream_test.go +++ b/internal/stream/stream_test.go @@ -1,10 +1,12 @@ package stream import ( + "bytes" "testing" "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" "github.com/bluenviron/mediamtx/internal/unit" "github.com/pion/rtp" @@ -30,7 +32,6 @@ func TestStream(t *testing.T) { strm := &Stream{ Desc: desc, - UseRTPPackets: false, WriteQueueSize: 512, RTPMaxPayloadSize: 1450, } @@ -38,6 +39,13 @@ func TestStream(t *testing.T) { require.NoError(t, err) defer strm.Close() + subStream := &SubStream{ + Stream: strm, + UseRTPPackets: false, + } + err = subStream.Initialize() + require.NoError(t, err) + r := &Reader{} recv := make(chan struct{}) @@ -50,7 +58,7 @@ func TestStream(t *testing.T) { strm.AddReader(r) defer strm.RemoveReader(r) - strm.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ PTS: 30000 * 2, Payload: unit.PayloadH264{ {5, 2}, // IDR @@ -84,6 +92,13 @@ func TestStreamSkipBytesSent(t *testing.T) { require.NoError(t, err) defer strm.Close() + subStream := &SubStream{ + Stream: strm, + UseRTPPackets: false, + } + err = subStream.Initialize() + require.NoError(t, err) + r := &Reader{ SkipBytesSent: true, } @@ -98,7 +113,7 @@ func TestStreamSkipBytesSent(t *testing.T) { strm.AddReader(r) defer strm.RemoveReader(r) - strm.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ PTS: 30000 * 2, Payload: unit.PayloadH264{ {5, 2}, // IDR @@ -128,7 +143,6 @@ func TestStreamResizeOversizedRTPPackets(t *testing.T) { strm := &Stream{ Desc: desc, - UseRTPPackets: true, WriteQueueSize: 512, RTPMaxPayloadSize: 400, Parent: &nilLogger{}, @@ -137,6 +151,13 @@ func TestStreamResizeOversizedRTPPackets(t *testing.T) { require.NoError(t, err) defer strm.Close() + subStream := &SubStream{ + Stream: strm, + UseRTPPackets: true, + } + err = subStream.Initialize() + require.NoError(t, err) + r := &Reader{} recv := make(chan *unit.Unit) @@ -157,7 +178,7 @@ func TestStreamResizeOversizedRTPPackets(t *testing.T) { strm.AddReader(r) defer strm.RemoveReader(r) - strm.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ PTS: 90000, RTPPackets: []*rtp.Packet{ { @@ -179,7 +200,7 @@ func TestStreamResizeOversizedRTPPackets(t *testing.T) { oversizedPayload[i] = byte(i % 256) } - strm.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ + subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ PTS: 90000, RTPPackets: []*rtp.Packet{ { @@ -310,7 +331,6 @@ func TestStreamUpdateFormatParams(t *testing.T) { strm := &Stream{ Desc: desc, - UseRTPPackets: false, WriteQueueSize: 512, RTPMaxPayloadSize: 1450, } @@ -318,6 +338,13 @@ func TestStreamUpdateFormatParams(t *testing.T) { require.NoError(t, err) defer strm.Close() + subStream := &SubStream{ + Stream: strm, + UseRTPPackets: false, + } + err = subStream.Initialize() + require.NoError(t, err) + r := &Reader{} recv := make(chan struct{}) @@ -329,7 +356,8 @@ func TestStreamUpdateFormatParams(t *testing.T) { strm.AddReader(r) defer strm.RemoveReader(r) - strm.WriteUnit(media, forma, u) + subStream.WriteUnit(media, forma, u) + <-recv // Verify that format parameters were updated @@ -1198,7 +1226,6 @@ func TestStreamDecode(t *testing.T) { strm := &Stream{ Desc: desc, - UseRTPPackets: true, WriteQueueSize: 512, RTPMaxPayloadSize: 1450, Parent: &nilLogger{}, @@ -1207,6 +1234,13 @@ func TestStreamDecode(t *testing.T) { require.NoError(t, err) defer strm.Close() + subStream := &SubStream{ + Stream: strm, + UseRTPPackets: true, + } + err = subStream.Initialize() + require.NoError(t, err) + r := &Reader{} recv := make(chan *unit.Unit) @@ -1222,7 +1256,7 @@ func TestStreamDecode(t *testing.T) { defer strm.RemoveReader(r) for _, pkt := range ca.encoded { - strm.WriteUnit(desc.Medias[0], ca.format, &unit.Unit{ + subStream.WriteUnit(desc.Medias[0], ca.format, &unit.Unit{ RTPPackets: []*rtp.Packet{pkt}, }) } @@ -1242,7 +1276,6 @@ func TestStreamEncode(t *testing.T) { strm := &Stream{ Desc: desc, - UseRTPPackets: false, WriteQueueSize: 512, RTPMaxPayloadSize: 1450, Parent: &nilLogger{}, @@ -1251,6 +1284,13 @@ func TestStreamEncode(t *testing.T) { require.NoError(t, err) defer strm.Close() + subStream := &SubStream{ + Stream: strm, + UseRTPPackets: false, + } + err = subStream.Initialize() + require.NoError(t, err) + r := &Reader{} recv := make(chan struct{}) @@ -1268,7 +1308,7 @@ func TestStreamEncode(t *testing.T) { strm.AddReader(r) defer strm.RemoveReader(r) - strm.WriteUnit(desc.Medias[0], ca.format, &unit.Unit{ + subStream.WriteUnit(desc.Medias[0], ca.format, &unit.Unit{ Payload: ca.decoded, }) @@ -1276,3 +1316,78 @@ func TestStreamEncode(t *testing.T) { }) } } + +func TestStreamAlwaysAvailable(t *testing.T) { + strm := &Stream{ + AlwaysAvailable: true, + AlwaysAvailableTracks: []conf.AlwaysAvailableTrack{ + {Codec: conf.CodecH264}, + {Codec: conf.CodecOpus, SampleRate: 48000, ChannelCount: 2}, + }, + WriteQueueSize: 512, + RTPMaxPayloadSize: 1450, + ReplaceNTP: true, + Parent: &nilLogger{}, + } + err := strm.Initialize() + require.NoError(t, err) + defer strm.Close() + + r := &Reader{ + Parent: &nilLogger{}, + } + + recv1 := make(chan struct{}) + recv2 := make(chan struct{}) + var lastPTS int64 + + r.OnData(strm.Desc.Medias[0], strm.Desc.Medias[0].Formats[0], func(u *unit.Unit) error { + require.GreaterOrEqual(t, u.PTS, lastPTS) + lastPTS = u.PTS + + select { + case <-recv1: + default: + close(recv1) + } + + if len(u.Payload.(unit.PayloadH264)) == 3 && bytes.Equal(u.Payload.(unit.PayloadH264)[2], []byte{5, 2}) { + close(recv2) + } + + return nil + }) + + strm.AddReader(r) + defer strm.RemoveReader(r) + + <-recv1 + + desc := &description.Session{Medias: []*description.Media{ + { + Type: description.MediaTypeVideo, + Formats: []format.Format{&format.H264{}}, + }, + { + Type: description.MediaTypeAudio, + Formats: []format.Format{&format.Opus{}}, + }, + }} + + subStream := &SubStream{ + Stream: strm, + CurDesc: desc, + UseRTPPackets: false, + } + err = subStream.Initialize() + require.NoError(t, err) + + subStream.WriteUnit(desc.Medias[0], desc.Medias[0].Formats[0], &unit.Unit{ + PTS: 90000, + Payload: unit.PayloadH264{ + {5, 2}, // IDR + }, + }) + + <-recv2 +} diff --git a/internal/stream/sub_stream.go b/internal/stream/sub_stream.go new file mode 100644 index 00000000..fc81e99c --- /dev/null +++ b/internal/stream/sub_stream.go @@ -0,0 +1,157 @@ +package stream + +import ( + "fmt" + "reflect" + + "github.com/bluenviron/gortsplib/v5/pkg/description" + "github.com/bluenviron/gortsplib/v5/pkg/format" + "github.com/bluenviron/mediamtx/internal/logger" + "github.com/bluenviron/mediamtx/internal/unit" +) + +// FormatsToCodecs returns the name of codecs of given formats. +func FormatsToCodecs(formats []format.Format) []string { + ret := make([]string, len(formats)) + for i, forma := range formats { + ret[i] = forma.Codec() + } + return ret +} + +func gatherFormats(medias []*description.Media) []format.Format { + n := 0 + for _, media := range medias { + n += len(media.Formats) + } + + if n == 0 { + return nil + } + + formats := make([]format.Format, n) + n = 0 + + for _, media := range medias { + n += copy(formats[n:], media.Formats) + } + + return formats +} + +func mediasToCodecs(medias []*description.Media) []string { + return FormatsToCodecs(gatherFormats(medias)) +} + +func mediasAreCompatible(medias1 []*description.Media, medias2 []*description.Media) bool { + if len(medias1) != len(medias2) { + return false + } + + for i := range medias1 { + if len(medias1[i].Formats) != len(medias2[i].Formats) { + return false + } + + for j := range medias1[i].Formats { + if reflect.TypeOf(medias1[i].Formats[j]) != reflect.TypeOf(medias2[i].Formats[j]) { + return false + } + } + } + + return true +} + +// SubStream is a Stream without interruptions. +type SubStream struct { + Stream *Stream + CurDesc *description.Session + UseRTPPackets bool + + medias map[*description.Media]*subStreamMedia +} + +// Initialize initializes the SubStream. +func (ss *SubStream) Initialize() error { + if !ss.Stream.AlwaysAvailable { + if ss.Stream.subStream != nil { + panic("should not happen") + } + + if ss.CurDesc != nil { + panic("should not happen") + } + } else { + if ss.CurDesc == nil { + panic("should not happen") + } + + if !mediasAreCompatible(ss.Stream.Desc.Medias, ss.CurDesc.Medias) { + return fmt.Errorf("want to publish %v, but stream expects %v", + mediasToCodecs(ss.CurDesc.Medias), mediasToCodecs(ss.Stream.Desc.Medias)) + } + } + + if !ss.Stream.AlwaysAvailable { + ss.CurDesc = ss.Stream.Desc + } + + ss.medias = make(map[*description.Media]*subStreamMedia) + + for i, curMedia := range ss.CurDesc.Medias { + media := ss.Stream.Desc.Medias[i] + + ssm := &subStreamMedia{ + curMedia: curMedia, + streamMedia: ss.Stream.medias[media], + useRTPPackets: ss.UseRTPPackets, + } + err := ssm.initialize() + if err != nil { + return err + } + ss.medias[curMedia] = ssm + } + + if ss.Stream.AlwaysAvailable { + if ss.Stream.offlineSubStream != nil { + ss.Stream.Parent.Log(logger.Info, "stream is online") + + // wait for the entire duration of the last sample of the offline sub stream + // to minimize errors in clients. + // TODO: it would be better in the future to wait for the last sample + // of normal sub streams as well (this is currently impossible because + // we don't know the duration of their samples). + ss.Stream.offlineSubStream.close(true) + ss.Stream.offlineSubStream = nil + } + } + + ss.Stream.mutex.Lock() + ss.Stream.subStream = ss + ss.Stream.mutex.Unlock() + + for _, ssm := range ss.medias { + for _, ssf := range ssm.formats { + ssf.initialize2() + } + } + + return nil +} + +// WriteUnit writes a Unit. +func (ss *SubStream) WriteUnit(medi *description.Media, forma format.Format, u *unit.Unit) { + ss.Stream.mutex.RLock() + defer ss.Stream.mutex.RUnlock() + + if ss.Stream.subStream != ss { + return + } + + ssm := ss.medias[medi] + ssf := ssm.formats[forma] + + ssf.writeUnit(u) +} diff --git a/internal/stream/sub_stream_format.go b/internal/stream/sub_stream_format.go new file mode 100644 index 00000000..fdf55fff --- /dev/null +++ b/internal/stream/sub_stream_format.go @@ -0,0 +1,188 @@ +package stream + +import ( + "errors" + "fmt" + "time" + + "github.com/bluenviron/gortsplib/v5/pkg/format" + "github.com/bluenviron/mediamtx/internal/logger" + "github.com/bluenviron/mediamtx/internal/unit" +) + +type subStreamFormat struct { + curFormat format.Format + streamFormat *streamFormat + useRTPPackets bool + + rtpDecoder rtpDecoder + tempRTPEncoder rtpEncoder + tempRTPTimeOffset uint32 +} + +func (ssf *subStreamFormat) initialize() error { + if ssf.useRTPPackets { + var err error + ssf.rtpDecoder, err = newRTPDecoder(ssf.curFormat) + if err != nil { + return err + } + } + + if ssf.streamFormat.rtpEncoder == nil && (!ssf.useRTPPackets || ssf.streamFormat.alwaysAvailable) { + var err error + ssf.tempRTPEncoder, err = newRTPEncoder(ssf.curFormat, ssf.streamFormat.rtpMaxPayloadSize, nil, nil) + if err != nil { + return err + } + + ssf.tempRTPTimeOffset, err = randUint32() + if err != nil { + return err + } + } + + return nil +} + +func (ssf *subStreamFormat) initialize2() { + if ssf.tempRTPEncoder != nil { + if ssf.streamFormat.rtpEncoder == nil { + ssf.streamFormat.rtpEncoder = ssf.tempRTPEncoder + ssf.streamFormat.rtpTimeOffset = ssf.tempRTPTimeOffset + } + + ssf.tempRTPEncoder = nil + ssf.tempRTPTimeOffset = 0 + } + + if ssf.streamFormat.alwaysAvailable { + if ssf.streamFormat.firstReceived { + deltaT := max(1, multiplyAndDivide( + int64(time.Since(ssf.streamFormat.lastSystemTime)), int64(ssf.streamFormat.format.ClockRate()), int64(time.Second))) + ssf.streamFormat.ptsOffset = ssf.streamFormat.lastPTS + deltaT + } + + switch curFormat := ssf.curFormat.(type) { + case *format.H265: + sps, pps, vps := curFormat.SafeParams() + + if sps != nil && pps != nil && vps != nil { + ssf.writeUnit(&unit.Unit{ + PTS: 0, + NTP: time.Time{}, + RTPPackets: nil, + Payload: unit.PayloadH265([][]byte{sps, pps, vps}), + }) + } + + case *format.H264: + sps, pps := curFormat.SafeParams() + + if sps != nil && pps != nil { + ssf.writeUnit(&unit.Unit{ + PTS: 0, + NTP: time.Time{}, + RTPPackets: nil, + Payload: unit.PayloadH264([][]byte{sps, pps}), + }) + } + } + } +} + +func (ssf *subStreamFormat) writeUnit(u *unit.Unit) { + err := ssf.writeUnitInner(u) + if err != nil { + ssf.streamFormat.processingErrors.Add(err) + return + } +} + +func (ssf *subStreamFormat) writeUnitInner(u *unit.Unit) error { + if ssf.streamFormat.alwaysAvailable { + ssf.streamFormat.firstReceived = true + u.PTS += ssf.streamFormat.ptsOffset + if u.PTS > ssf.streamFormat.lastPTS { + ssf.streamFormat.lastPTS = u.PTS + } + ssf.streamFormat.lastSystemTime = time.Now() + } + + if ssf.streamFormat.replaceNTP { + u.NTP = ssf.streamFormat.ntpEstimator.Estimate(u.PTS) + } + + if len(u.RTPPackets) != 0 { + if ssf.rtpDecoder != nil { + var err error + u.Payload, err = ssf.rtpDecoder.decode(u.RTPPackets[0]) + if err != nil { + return err + } + } + + if ssf.streamFormat.rtpEncoder == nil { + for _, pkt := range u.RTPPackets { + if len(pkt.Payload) > ssf.streamFormat.rtpMaxPayloadSize { + var err error + ssf.streamFormat.rtpEncoder, err = newRTPEncoder(ssf.streamFormat.format, ssf.streamFormat.rtpMaxPayloadSize, + ptrOf(pkt.SSRC), ptrOf(pkt.SequenceNumber)) + if err != nil { + var err2 rtpEncoderNotAvailableError + if errors.As(err, &err2) { + return fmt.Errorf("RTP payload size (%d) is greater than maximum allowed (%d)", + len(pkt.Payload), ssf.streamFormat.rtpMaxPayloadSize) + } + return err + } + + ssf.streamFormat.rtpTimeOffset = pkt.Timestamp - uint32(u.PTS) + + ssf.streamFormat.parent.Log(logger.Info, "RTP packets are too big, remuxing them into smaller ones") + break + } + } + } + + if ssf.streamFormat.rtpEncoder != nil { + u.RTPPackets = nil + } + } + + if !u.NilPayload() { + ssf.streamFormat.formatUpdater(ssf.streamFormat.format, u.Payload) + + u.Payload = ssf.streamFormat.unitRemuxer(ssf.streamFormat.format, u.Payload) + + if ssf.streamFormat.rtpEncoder != nil && !u.NilPayload() { + var err error + u.RTPPackets, err = ssf.streamFormat.rtpEncoder.encode(u.Payload) + if err != nil { + return err + } + + for _, pkt := range u.RTPPackets { + pkt.Timestamp += ssf.streamFormat.rtpTimeOffset + uint32(u.PTS) + } + } + } + + size := unitSize(u) + ssf.streamFormat.onBytesReceived(size) + + ssf.streamFormat.writeRTSP(ssf.streamFormat.media, u.RTPPackets, u.NTP) + + for sr, onData := range ssf.streamFormat.onDatas { + csr := sr + cOnData := onData + sr.push(func() error { + if !csr.SkipBytesSent { + ssf.streamFormat.onBytesSent(size) + } + return cOnData(u) + }) + } + + return nil +} diff --git a/internal/stream/sub_stream_media.go b/internal/stream/sub_stream_media.go new file mode 100644 index 00000000..e767a26f --- /dev/null +++ b/internal/stream/sub_stream_media.go @@ -0,0 +1,35 @@ +package stream + +import ( + "github.com/bluenviron/gortsplib/v5/pkg/description" + "github.com/bluenviron/gortsplib/v5/pkg/format" +) + +type subStreamMedia struct { + curMedia *description.Media + streamMedia *streamMedia + useRTPPackets bool + + formats map[format.Format]*subStreamFormat +} + +func (ssm *subStreamMedia) initialize() error { + ssm.formats = make(map[format.Format]*subStreamFormat) + + for i, curFormat := range ssm.curMedia.Formats { + forma := ssm.streamMedia.media.Formats[i] + + ssf := &subStreamFormat{ + curFormat: curFormat, + streamFormat: ssm.streamMedia.formats[forma], + useRTPPackets: ssm.useRTPPackets, + } + err := ssf.initialize() + if err != nil { + return err + } + ssm.formats[curFormat] = ssf + } + + return nil +} diff --git a/internal/test/formats.go b/internal/test/formats.go index 11639756..fc4234a4 100644 --- a/internal/test/formats.go +++ b/internal/test/formats.go @@ -44,9 +44,10 @@ var FormatH265 = &format.H265{ var FormatMPEG4Audio = &format.MPEG4Audio{ PayloadTyp: 96, Config: &mpeg4audio.AudioSpecificConfig{ - Type: 2, - SampleRate: 44100, - ChannelCount: 2, + Type: 2, + SampleRate: 44100, + ChannelCount: 2, + ChannelConfig: 2, }, SizeLength: 13, IndexLength: 3, diff --git a/internal/test/path_manager.go b/internal/test/path_manager.go index 80bdbbb8..8deb8522 100644 --- a/internal/test/path_manager.go +++ b/internal/test/path_manager.go @@ -10,7 +10,7 @@ import ( type PathManager struct { FindPathConfImpl func(req defs.PathFindPathConfReq) (*conf.Path, error) DescribeImpl func(req defs.PathDescribeReq) defs.PathDescribeRes - AddPublisherImpl func(req defs.PathAddPublisherReq) (defs.Path, *stream.Stream, error) + AddPublisherImpl func(req defs.PathAddPublisherReq) (defs.Path, *stream.SubStream, error) AddReaderImpl func(req defs.PathAddReaderReq) (defs.Path, *stream.Stream, error) } @@ -25,7 +25,7 @@ func (pm *PathManager) Describe(req defs.PathDescribeReq) defs.PathDescribeRes { } // AddPublisher implements PathManager. -func (pm *PathManager) AddPublisher(req defs.PathAddPublisherReq) (defs.Path, *stream.Stream, error) { +func (pm *PathManager) AddPublisher(req defs.PathAddPublisherReq) (defs.Path, *stream.SubStream, error) { return pm.AddPublisherImpl(req) } diff --git a/internal/test/static_source_parent.go b/internal/test/static_source_parent.go index c50fda04..2d8bd047 100644 --- a/internal/test/static_source_parent.go +++ b/internal/test/static_source_parent.go @@ -31,7 +31,6 @@ func (p *StaticSourceParent) Close() { func (p *StaticSourceParent) SetReady(req defs.PathSourceStaticSetReadyReq) defs.PathSourceStaticSetReadyRes { p.stream = &stream.Stream{ Desc: req.Desc, - UseRTPPackets: req.UseRTPPackets, WriteQueueSize: 512, RTPMaxPayloadSize: 1450, ReplaceNTP: req.ReplaceNTP, @@ -42,6 +41,15 @@ func (p *StaticSourceParent) SetReady(req defs.PathSourceStaticSetReadyReq) defs panic(err) } + subStream := &stream.SubStream{ + Stream: p.stream, + UseRTPPackets: req.UseRTPPackets, + } + err = subStream.Initialize() + if err != nil { + panic("should not happen") + } + p.reader = &stream.Reader{Parent: NilLogger} p.reader.OnData( @@ -55,7 +63,7 @@ func (p *StaticSourceParent) SetReady(req defs.PathSourceStaticSetReadyReq) defs p.stream.AddReader(p.reader) - return defs.PathSourceStaticSetReadyRes{Stream: p.stream} + return defs.PathSourceStaticSetReadyRes{SubStream: subStream} } // SetNotReady implements parent. diff --git a/internal/testapidocs/apidocs_test.go b/internal/testapidocs/apidocs_test.go index f7743024..ef0aa491 100644 --- a/internal/testapidocs/apidocs_test.go +++ b/internal/testapidocs/apidocs_test.go @@ -72,21 +72,22 @@ func fillProperty(t *testing.T, rt reflect.Type, existing openAPIProperty) openA rt == reflect.TypeOf(conf.Duration(0)) || rt == reflect.TypeOf(conf.IPNetwork{}) || rt == reflect.TypeOf(conf.Credential("")) || - rt == reflect.TypeOf(conf.RecordFormat(0)) || + rt == reflect.TypeOf(conf.RecordFormat("")) || rt == reflect.TypeOf(conf.AuthAction("")) || - rt == reflect.TypeOf(conf.Encryption(0)) || + rt == reflect.TypeOf(conf.Encryption("")) || rt == reflect.TypeOf(conf.RTSPTransport{}) || rt == reflect.TypeOf(conf.StringSize(0)) || - rt == reflect.TypeOf(conf.RTSPRangeType(0)) || + rt == reflect.TypeOf(conf.RTSPRangeType("")) || rt == reflect.TypeOf(conf.LogLevel(0)) || - rt == reflect.TypeOf(conf.AuthMethod(0)) || + rt == reflect.TypeOf(conf.AuthMethod("")) || rt == reflect.TypeOf(conf.LogDestination(0)) || rt == reflect.TypeOf(conf.RTSPAuthMethod(0)) || rt == reflect.TypeOf(conf.HLSVariant(0)) || rt == reflect.TypeOf(defs.APIRTMPConnState("")) || rt == reflect.TypeOf(defs.APIWebRTCSessionState("")) || rt == reflect.TypeOf(defs.APISRTConnState("")) || - rt == reflect.TypeOf(defs.APIRTSPSessionState("")): + rt == reflect.TypeOf(defs.APIRTSPSessionState("")) || + rt == reflect.TypeOf(conf.Codec("")): return openAPIProperty{Type: "string"} case rt == reflect.TypeOf(conf.RTSPTransports{}): @@ -138,8 +139,8 @@ func TestAPIDocs(t *testing.T) { goStruct any }{ { - "Info", - defs.APIInfo{}, + "AlwaysAvailableTrack", + conf.AlwaysAvailableTrack{}, }, { "AuthInternalUser", @@ -153,6 +154,22 @@ func TestAPIDocs(t *testing.T) { "GlobalConf", conf.Conf{}, }, + { + "HLSMuxer", + defs.APIHLSMuxer{}, + }, + { + "HLSMuxerList", + defs.APIHLSMuxerList{}, + }, + { + "Info", + defs.APIInfo{}, + }, + { + "Path", + defs.APIPath{}, + }, { "PathConf", conf.Path{}, @@ -161,29 +178,17 @@ func TestAPIDocs(t *testing.T) { "PathConfList", defs.APIPathConfList{}, }, - { - "Path", - defs.APIPath{}, - }, { "PathList", defs.APIPathList{}, }, - { - "PathSource", - defs.APIPathSource{}, - }, { "PathReader", defs.APIPathReader{}, }, { - "HLSMuxer", - defs.APIHLSMuxer{}, - }, - { - "HLSMuxerList", - defs.APIHLSMuxerList{}, + "PathSource", + defs.APIPathSource{}, }, { "Recording", diff --git a/mediamtx.yml b/mediamtx.yml index 1724a779..fd9d7b13 100644 --- a/mediamtx.yml +++ b/mediamtx.yml @@ -486,6 +486,23 @@ pathDefaults: # Use absolute timestamp of frames, instead of replacing them with the current time. useAbsoluteTimestamp: false + ############################################### + # Default path settings -> Always available + + # Enable always-available mode, in which a file is played on repeat when the stream is not available. + alwaysAvailable: false + # Path to the MP4 file that is played on repeat. If not provided, a default file will be used. + alwaysAvailableFile: '' + # If alwaysAvailableFile is not provided, these are the tracks of the default file. + alwaysAvailableTracks: + # Available values are: AV1, VP9, H265, H264, Opus, MPEG4Audio, G711, LPCM + - codec: H264 + # in case of MPEG4Audio, G711, LPCM, sampleRate and ChannelCount must be provided too. + # sampleRate: 48000 + # channelCount: 2 + # in case of G711, muLaw must be provided too. + # muLaw: false + ############################################### # Default path settings -> Record