diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8d332487..c00433fb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 - with: { go-version: '1.21' } + with: { go-version: '1.22' } - name: Build go2rtc_win64 env: { GOOS: windows, GOARCH: amd64 } @@ -123,7 +123,9 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ${{ github.repository }} + images: | + ${{ github.repository }} + ghcr.io/${{ github.repository }} tags: | type=ref,event=branch type=semver,pattern={{version}},enable=false @@ -142,6 +144,14 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push uses: docker/build-push-action@v5 with: @@ -168,10 +178,12 @@ jobs: id: meta-hw uses: docker/metadata-action@v5 with: - images: ${{ github.repository }} + images: | + ${{ github.repository }} + ghcr.io/${{ github.repository }} flavor: | - suffix=-hardware - latest=false + suffix=-hardware,onlatest=true + latest=auto tags: | type=ref,event=branch type=semver,pattern={{version}},enable=false @@ -189,6 +201,14 @@ jobs: with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push uses: docker/build-push-action@v5 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b23faf53..dc47bdb5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,7 +26,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version: '1.22' - name: Build Go binary run: go build -ldflags "-s -w" -trimpath -o ./go2rtc diff --git a/.gitignore b/.gitignore index 522f6275..04ae894a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,10 @@ go2rtc.yaml go2rtc.json +go2rtc_linux* +go2rtc_mac* +go2rtc_win* + 0_test.go .DS_Store diff --git a/Dockerfile b/Dockerfile index f28f4e2f..b3888820 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ # 0. Prepare images ARG PYTHON_VERSION="3.11" -ARG GO_VERSION="1.21" +ARG GO_VERSION="1.22" ARG NGROK_VERSION="3" FROM python:${PYTHON_VERSION}-alpine AS base @@ -20,6 +20,8 @@ ENV GOARCH=${TARGETARCH} WORKDIR /build +RUN apk add git + # Cache dependencies COPY go.mod go.sum ./ RUN --mount=type=cache,target=/root/.cache/go-build go mod download diff --git a/README.md b/README.md index 3bfe3a48..c31ed748 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

- ![go2rtc](assets/logo.png) + ![go2rtc](assets/logo.gif)
[![stars](https://img.shields.io/github/stars/AlexxIT/go2rtc?style=flat-square&logo=github)](https://github.com/AlexxIT/go2rtc/stargazers) [![docker pulls](https://img.shields.io/docker/pulls/alexxit/go2rtc?style=flat-square&logo=docker&logoColor=white&label=pulls)](https://hub.docker.com/r/alexxit/go2rtc) @@ -131,7 +131,7 @@ Don't forget to fix the rights `chmod +x go2rtc_xxx_xxx` on Linux and Mac. ### go2rtc: Docker -Container [alexxit/go2rtc](https://hub.docker.com/r/alexxit/go2rtc) with support `amd64`, `386`, `arm64`, `arm`. This container is the same as [Home Assistant Add-on](#go2rtc-home-assistant-add-on), but can be used separately from Home Assistant. Container has preinstalled [FFmpeg](#source-ffmpeg), [ngrok](#module-ngrok) and [Python](#source-echo). +The Docker container [`alexxit/go2rtc`](https://hub.docker.com/r/alexxit/go2rtc) supports multiple architectures including `amd64`, `386`, `arm64`, and `arm`. This container offers the same functionality as the [Home Assistant Add-on](#go2rtc-home-assistant-add-on) but is designed to operate independently of Home Assistant. It comes preinstalled with [FFmpeg](#source-ffmpeg), [ngrok](#module-ngrok), and [Python](#source-echo). ### go2rtc: Home Assistant Add-on @@ -429,6 +429,7 @@ streams: stream: exec:ffmpeg -re -i /media/BigBuckBunny.mp4 -c copy -rtsp_transport tcp -f rtsp {output} picam_h264: exec:libcamera-vid -t 0 --inline -o - picam_mjpeg: exec:libcamera-vid -t 0 --codec mjpeg -o - + pi5cam_h264: exec:libcamera-vid -t 0 --libav-format h264 -o - canon: exec:gphoto2 --capture-movie --stdout#killsignal=2#killtimeout=5 play_pcma: exec:ffplay -fflags nobuffer -f alaw -ar 8000 -i -#backchannel=1 play_pcm48k: exec:ffplay -fflags nobuffer -f s16be -ar 48000 -i -#backchannel=1 @@ -552,11 +553,16 @@ echo -n "cloud password" | shasum -a 256 | awk '{print toupper($0)}' [TP-Link Kasa](https://www.kasasmart.com/) non-standard protocol [more info](https://medium.com/@hu3vjeen/reverse-engineering-tp-link-kc100-bac4641bf1cd). +- `username` - urlsafe email, `alex@gmail.com` -> `alex%40gmail.com` +- `password` - base64password, `secret1` -> `c2VjcmV0MQ==` + ```yaml streams: - kasa: kasa://user:pass@192.168.1.123:19443/https/stream/mixed + kc401: kasa://username:password@192.168.1.123:19443/https/stream/mixed ``` +Tested: KD110, KC200, KC401, KC420WS, EC71. + #### Source: GoPro *[New in v1.8.3](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.3)* @@ -773,7 +779,7 @@ POST http://localhost:1984/api/streams?dst=camera1&src=ffmpeg:http://example.com You can publish any stream to streaming services (YouTube, Telegram, etc.) via RTMP/RTMPS. Important: - Supported codecs: H264 for video and AAC for audio -- Pixel format should be `yuv420p`, for cameras with `yuvj420p` format you SHOULD use [transcoding](#source-ffmpeg) +- AAC audio is required for YouTube, videos without audio will not work - You don't need to enable [RTMP module](#module-rtmp) listening for this task You can use API: @@ -786,16 +792,19 @@ Or config file: ```yaml publish: - # publish stream "tplink_tapo" to Telegram - tplink_tapo: rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx - # publish stream "other_camera" to Telegram and YouTube - other_camera: + # publish stream "video_audio_transcode" to Telegram + video_audio_transcode: - rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx - - rtmps://xxx.rtmp.youtube.com/live2/xxxx-xxxx-xxxx-xxxx-xxxx + # publish stream "audio_transcode" to Telegram and YouTube + audio_transcode: + - rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx + - rtmp://xxx.rtmp.youtube.com/live2/xxxx-xxxx-xxxx-xxxx-xxxx streams: - # for TP-Link cameras it's important to use transcoding because of wrong pixel format - tplink_tapo: ffmpeg:rtsp://user:pass@192.168.1.123/stream1#video=h264#hardware#audio=aac + video_audio_transcode: + - ffmpeg:rtsp://user:pass@192.168.1.123/stream1#video=h264#hardware#audio=aac + audio_transcode: + - ffmpeg:rtsp://user:pass@192.168.1.123/stream1#video=copy#audio=aac ``` - **Telegram Desktop App** > Any public or private channel or group (where you admin) > Live stream > Start with... > Start streaming. @@ -1187,6 +1196,10 @@ API examples: - You can use `rotate` param with `90`, `180`, `270` or `-90` values - You can use `hardware`/`hw` param [read more](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration) +**PS.** This module also supports streaming to the server console (terminal) in the **animated ASCII art** format ([read more](https://github.com/AlexxIT/go2rtc/blob/master/internal/mjpeg/README.md)): + +[![](https://img.youtube.com/vi/sHj_3h_sX7M/mqdefault.jpg)](https://www.youtube.com/watch?v=sHj_3h_sX7M) + ### Module: Log You can set different log levels for different modules. diff --git a/assets/logo.gif b/assets/logo.gif new file mode 100644 index 00000000..fcaf45eb Binary files /dev/null and b/assets/logo.gif differ diff --git a/examples/rtsp_client/main.go b/examples/rtsp_client/main.go new file mode 100644 index 00000000..9c2112d1 --- /dev/null +++ b/examples/rtsp_client/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "log" + "os" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/rtsp" + "github.com/AlexxIT/go2rtc/pkg/shell" +) + +func main() { + client := rtsp.NewClient(os.Args[1]) + if err := client.Dial(); err != nil { + log.Panic(err) + } + + client.Medias = []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + {Name: core.CodecPCMU, ClockRate: 8000}, + }, + ID: "streamid=0", + }, + } + if err := client.Announce(); err != nil { + log.Panic(err) + } + if _, err := client.SetupMedia(client.Medias[0]); err != nil { + log.Panic(err) + } + if err := client.Record(); err != nil { + log.Panic(err) + } + + shell.RunUntilSignal() +} diff --git a/go.mod b/go.mod index 5704025a..d3cb791f 100644 --- a/go.mod +++ b/go.mod @@ -1,26 +1,27 @@ module github.com/AlexxIT/go2rtc -go 1.21 +go 1.22 require ( github.com/asticode/go-astits v1.13.0 - github.com/expr-lang/expr v1.16.5 + github.com/expr-lang/expr v1.16.9 github.com/gorilla/websocket v1.5.1 + github.com/mattn/go-isatty v0.0.20 github.com/miekg/dns v1.1.59 - github.com/pion/ice/v2 v2.3.19 + github.com/pion/ice/v2 v2.3.24 github.com/pion/interceptor v0.1.29 github.com/pion/rtcp v1.2.14 github.com/pion/rtp v1.8.6 github.com/pion/sdp/v3 v3.0.9 github.com/pion/srtp/v2 v2.0.18 github.com/pion/stun v0.6.1 - github.com/pion/webrtc/v3 v3.2.39 - github.com/rs/zerolog v1.32.0 + github.com/pion/webrtc/v3 v3.2.40 + github.com/rs/zerolog v1.33.0 github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f github.com/stretchr/testify v1.9.0 github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 - golang.org/x/crypto v0.22.0 + golang.org/x/crypto v0.24.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -30,9 +31,8 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/kr/pretty v0.2.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/pion/datachannel v1.5.6 // indirect - github.com/pion/dtls/v2 v2.2.10 // indirect + github.com/pion/dtls/v2 v2.2.11 // indirect github.com/pion/logging v0.2.2 // indirect github.com/pion/mdns v0.0.12 // indirect github.com/pion/randutil v0.1.0 // indirect @@ -40,9 +40,9 @@ require ( github.com/pion/transport/v2 v2.2.5 // indirect github.com/pion/turn/v2 v2.1.6 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/mod v0.17.0 // indirect - golang.org/x/net v0.24.0 // indirect + golang.org/x/mod v0.18.0 // indirect + golang.org/x/net v0.26.0 // indirect golang.org/x/sync v0.7.0 // indirect - golang.org/x/sys v0.19.0 // indirect - golang.org/x/tools v0.20.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/tools v0.22.0 // indirect ) diff --git a/go.sum b/go.sum index a8a80ebc..727787ac 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/expr-lang/expr v1.16.5 h1:m2hvtguFeVaVNTHj8L7BoAyt7O0PAIBaSVbjdHgRXMs= github.com/expr-lang/expr v1.16.5/go.mod h1:uCkhfG+x7fcZ5A5sXHKuQ07jGZRl6J0FCAaf2k4PtVQ= +github.com/expr-lang/expr v1.16.9 h1:WUAzmR0JNI9JCiF0/ewwHB1gmcGw5wW7nWt8gc6PpCI= +github.com/expr-lang/expr v1.16.9/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -33,8 +35,12 @@ github.com/pion/datachannel v1.5.6/go.mod h1:1eKT6Q85pRnr2mHiWHxJwO50SfZRtWHTsNI github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= github.com/pion/dtls/v2 v2.2.10 h1:u2Axk+FyIR1VFTPurktB+1zoEPGIW3bmyj3LEFrXjAA= github.com/pion/dtls/v2 v2.2.10/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= +github.com/pion/dtls/v2 v2.2.11 h1:9U/dpCYl1ySttROPWJgqWKEylUdT0fXp/xst6JwY5Ks= +github.com/pion/dtls/v2 v2.2.11/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= github.com/pion/ice/v2 v2.3.19 h1:1GoMRTMnB6bCP4aGy2MjxK3w4laDkk+m7svJb/eqybc= github.com/pion/ice/v2 v2.3.19/go.mod h1:KXJJcZK7E8WzrBEYnV4UtqEZsGeWfHxsNqhVcVvgjxw= +github.com/pion/ice/v2 v2.3.24 h1:RYgzhH/u5lH0XO+ABatVKCtRd+4U1GEaCXSMjNr13tI= +github.com/pion/ice/v2 v2.3.24/go.mod h1:KXJJcZK7E8WzrBEYnV4UtqEZsGeWfHxsNqhVcVvgjxw= github.com/pion/interceptor v0.1.29 h1:39fsnlP1U8gw2JzOFWdfCU82vHvhW9o0rZnZF56wF+M= github.com/pion/interceptor v0.1.29/go.mod h1:ri+LGNjRUc5xUNtDEPzfdkmSqISixVTBF/z/Zms/6T4= github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= @@ -72,6 +78,8 @@ github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc= github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY= github.com/pion/webrtc/v3 v3.2.39 h1:Lf2SIMGdE3M9VNm48KpoX5pR8SJ6TsMnktzOkc/oB0o= github.com/pion/webrtc/v3 v3.2.39/go.mod h1:AQ8p56OLbm3MjhYovYdgPuyX6oc+JcKx/HFoCGFcYzA= +github.com/pion/webrtc/v3 v3.2.40 h1:Wtfi6AZMQg+624cvCXUuSmrKWepSB7zfgYDOYqsSOVU= +github.com/pion/webrtc/v3 v3.2.40/go.mod h1:M1RAe3TNTD1tzyvqHrbVODfwdPGSXOUo/OgpoGGJqFY= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -79,6 +87,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0= github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 h1:NVK+OqnavpyFmUiKfUMHrpvbCi2VFoWTrcpI7aDaJ2I= github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA= github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f h1:1R9KdKjCNSd7F8iGTxIpoID9prlYH8nuNYKt0XvweHA= @@ -107,10 +117,16 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= +golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -124,6 +140,10 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -148,6 +168,10 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -172,6 +196,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY= golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg= +golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= diff --git a/hardware.Dockerfile b/hardware.Dockerfile index 0aa85374..2254f9be 100644 --- a/hardware.Dockerfile +++ b/hardware.Dockerfile @@ -4,7 +4,7 @@ # only debian 13 (trixie) has latest ffmpeg # https://packages.debian.org/trixie/ffmpeg ARG DEBIAN_VERSION="trixie-slim" -ARG GO_VERSION="1.21-bookworm" +ARG GO_VERSION="1.22-bookworm" ARG NGROK_VERSION="3" FROM debian:${DEBIAN_VERSION} AS base diff --git a/internal/api/ws/ws.go b/internal/api/ws/ws.go index c8187dec..800a377d 100644 --- a/internal/api/ws/ws.go +++ b/internal/api/ws/ws.go @@ -83,7 +83,7 @@ func initWS(origin string) { if o.Host == r.Host { return true } - log.Trace().Msgf("[api.ws] origin=%s, host=%s", o.Host, r.Host) + log.Trace().Msgf("[api] ws origin=%s, host=%s", o.Host, r.Host) // https://github.com/AlexxIT/go2rtc/issues/118 if i := strings.IndexByte(o.Host, ':'); i > 0 { return o.Host[:i] == r.Host @@ -127,7 +127,7 @@ func apiWS(w http.ResponseWriter, r *http.Request) { break } - log.Trace().Str("type", msg.Type).Msg("[api.ws] msg") + log.Trace().Str("type", msg.Type).Msg("[api] ws msg") if handler := wsHandlers[msg.Type]; handler != nil { go func() { diff --git a/internal/app/README.md b/internal/app/README.md new file mode 100644 index 00000000..2460daa2 --- /dev/null +++ b/internal/app/README.md @@ -0,0 +1,70 @@ +- By default go2rtc will search config file `go2rtc.yaml` in current work directory +- go2rtc support multiple config files: + - `go2rtc -c config1.yaml -c config2.yaml -c config3.yaml` +- go2rtc support inline config as multiple formats from command line: + - **YAML**: `go2rtc -c '{log: {format: text}}'` + - **JSON**: `go2rtc -c '{"log":{"format":"text"}}'` + - **key=value**: `go2rtc -c log.format=text` +- Every next config will overwrite previous (but only defined params) + +``` +go2rtc -config "{log: {format: text}}" -config /config/go2rtc.yaml -config "{rtsp: {listen: ''}}" -config /usr/local/go2rtc/go2rtc.yaml +``` + +or simple version + +``` +go2rtc -c log.format=text -c /config/go2rtc.yaml -c rtsp.listen='' -c /usr/local/go2rtc/go2rtc.yaml +``` + +## Environment variables + +Also go2rtc support templates for using environment variables in any part of config: + +```yaml +streams: + camera1: rtsp://rtsp:${CAMERA_PASSWORD}@192.168.1.123/av_stream/ch0 + +rtsp: + username: ${RTSP_USER:admin} # "admin" if env "RTSP_USER" not set + password: ${RTSP_PASS:secret} # "secret" if env "RTSP_PASS" not set +``` + +## JSON Schema + +Editors like [GoLand](https://www.jetbrains.com/go/) and [VS Code](https://code.visualstudio.com/) supports autocomplete and syntax validation. + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/AlexxIT/go2rtc/master/website/schema.json +``` + +## Defaults + +- Default values may change in updates +- FFmpeg module has many presets, they are not listed here because they may also change in updates + +```yaml +api: + listen: ":1984" + +ffmpeg: + bin: "ffmpeg" + +log: + format: "color" + level: "info" + output: "stdout" + time: "UNIXMS" + +rtsp: + listen: ":8554" + default_query: "video&audio" + +srtp: + listen: ":8443" + +webrtc: + listen: ":8555/tcp" + ice_servers: + - urls: [ "stun:stun.l.google.com:19302" ] +``` diff --git a/internal/app/app.go b/internal/app/app.go index 8c5ba79c..eb803584 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,144 +1,101 @@ package app import ( - "errors" "flag" "fmt" "os" "os/exec" - "path/filepath" "runtime" - "strings" - - "github.com/AlexxIT/go2rtc/pkg/shell" - "github.com/AlexxIT/go2rtc/pkg/yaml" - "github.com/rs/zerolog/log" + "runtime/debug" ) -var Version = "1.9.0" -var UserAgent = "go2rtc/" + Version +var ( + Version string + UserAgent string + ConfigPath string + Info = make(map[string]any) +) -var ConfigPath string -var Info = map[string]any{ - "version": Version, -} +const usage = `Usage of go2rtc: + + -c, --config Path to config file or config string as YAML or JSON, support multiple + -d, --daemon Run in background + -v, --version Print version and exit +` func Init() { - var confs Config + var config flagConfig var daemon bool var version bool - flag.Var(&confs, "config", "go2rtc config (path to file or raw text), support multiple") - if runtime.GOOS != "windows" { - flag.BoolVar(&daemon, "daemon", false, "Run program in background") - } - flag.BoolVar(&version, "version", false, "Print the version of the application and exit") + flag.Var(&config, "config", "") + flag.Var(&config, "c", "") + flag.BoolVar(&daemon, "daemon", false, "") + flag.BoolVar(&daemon, "d", false, "") + flag.BoolVar(&version, "version", false, "") + flag.BoolVar(&version, "v", false, "") + + flag.Usage = func() { fmt.Print(usage) } flag.Parse() + revision, vcsTime := readRevisionTime() + if version { - fmt.Println("Current version:", Version) + fmt.Printf("go2rtc version %s (%s) %s/%s\n", Version, revision, runtime.GOOS, runtime.GOARCH) os.Exit(0) } - if daemon { - args := os.Args[1:] - for i, arg := range args { - if arg == "-daemon" { - args[i] = "" - } + if daemon && os.Getppid() != 1 { + if runtime.GOOS == "windows" { + fmt.Println("Daemon mode is not supported on Windows") + os.Exit(1) } + // Re-run the program in background and exit - cmd := exec.Command(os.Args[0], args...) + cmd := exec.Command(os.Args[0], os.Args[1:]...) if err := cmd.Start(); err != nil { - log.Fatal().Err(err).Send() + fmt.Println("Failed to start daemon:", err) + os.Exit(1) } fmt.Println("Running in daemon mode with PID:", cmd.Process.Pid) os.Exit(0) } - if confs == nil { - confs = []string{"go2rtc.yaml"} - } + UserAgent = "go2rtc/" + Version - for _, conf := range confs { - if conf[0] != '{' { - // config as file - if ConfigPath == "" { - ConfigPath = conf - } + Info["version"] = Version + Info["revision"] = revision - data, _ := os.ReadFile(conf) - if data == nil { - continue - } + initConfig(config) + initLogger() - data = []byte(shell.ReplaceEnvVars(string(data))) - configs = append(configs, data) - } else { - // config as raw YAML - configs = append(configs, []byte(conf)) - } - } + platform := fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH) + Logger.Info().Str("version", Version).Str("platform", platform).Str("revision", revision).Msg("go2rtc") + Logger.Debug().Str("version", runtime.Version()).Str("vcs.time", vcsTime).Msg("build") if ConfigPath != "" { - if !filepath.IsAbs(ConfigPath) { - if cwd, err := os.Getwd(); err == nil { - ConfigPath = filepath.Join(cwd, ConfigPath) + Logger.Info().Str("path", ConfigPath).Msg("config") + } +} + +func readRevisionTime() (revision, vcsTime string) { + if info, ok := debug.ReadBuildInfo(); ok { + for _, setting := range info.Settings { + switch setting.Key { + case "vcs.revision": + if len(setting.Value) > 7 { + revision = setting.Value[:7] + } else { + revision = setting.Value + } + case "vcs.time": + vcsTime = setting.Value + case "vcs.modified": + if setting.Value == "true" { + revision = "mod." + revision + } } } - Info["config_path"] = ConfigPath } - - var cfg struct { - Mod map[string]string `yaml:"log"` - } - - LoadConfig(&cfg) - - log.Logger = NewLogger(cfg.Mod["format"], cfg.Mod["level"]) - - modules = cfg.Mod - - log.Info().Msgf("go2rtc version %s %s/%s", Version, runtime.GOOS, runtime.GOARCH) - - migrateStore() + return } - -func LoadConfig(v any) { - for _, data := range configs { - if err := yaml.Unmarshal(data, v); err != nil { - log.Warn().Err(err).Msg("[app] read config") - } - } -} - -func PatchConfig(key string, value any, path ...string) error { - if ConfigPath == "" { - return errors.New("config file disabled") - } - - // empty config is OK - b, _ := os.ReadFile(ConfigPath) - - b, err := yaml.Patch(b, key, value, path...) - if err != nil { - return err - } - - return os.WriteFile(ConfigPath, b, 0644) -} - -// internal - -type Config []string - -func (c *Config) String() string { - return strings.Join(*c, " ") -} - -func (c *Config) Set(value string) error { - *c = append(*c, value) - return nil -} - -var configs [][]byte diff --git a/internal/app/config.go b/internal/app/config.go new file mode 100644 index 00000000..8ae6d460 --- /dev/null +++ b/internal/app/config.go @@ -0,0 +1,109 @@ +package app + +import ( + "errors" + "os" + "path/filepath" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/shell" + "github.com/AlexxIT/go2rtc/pkg/yaml" +) + +func LoadConfig(v any) { + for _, data := range configs { + if err := yaml.Unmarshal(data, v); err != nil { + Logger.Warn().Err(err).Send() + } + } +} + +func PatchConfig(key string, value any, path ...string) error { + if ConfigPath == "" { + return errors.New("config file disabled") + } + + // empty config is OK + b, _ := os.ReadFile(ConfigPath) + + b, err := yaml.Patch(b, key, value, path...) + if err != nil { + return err + } + + return os.WriteFile(ConfigPath, b, 0644) +} + +type flagConfig []string + +func (c *flagConfig) String() string { + return strings.Join(*c, " ") +} + +func (c *flagConfig) Set(value string) error { + *c = append(*c, value) + return nil +} + +var configs [][]byte + +func initConfig(confs flagConfig) { + if confs == nil { + confs = []string{"go2rtc.yaml"} + } + + for _, conf := range confs { + if len(conf) == 0 { + continue + } + if conf[0] == '{' { + // config as raw YAML or JSON + configs = append(configs, []byte(conf)) + } else if data := parseConfString(conf); data != nil { + configs = append(configs, data) + } else { + // config as file + if ConfigPath == "" { + ConfigPath = conf + } + + if data, _ = os.ReadFile(conf); data == nil { + continue + } + + data = []byte(shell.ReplaceEnvVars(string(data))) + configs = append(configs, data) + } + } + + if ConfigPath != "" { + if !filepath.IsAbs(ConfigPath) { + if cwd, err := os.Getwd(); err == nil { + ConfigPath = filepath.Join(cwd, ConfigPath) + } + } + Info["config_path"] = ConfigPath + } +} + +func parseConfString(s string) []byte { + i := strings.IndexByte(s, '=') + if i < 0 { + return nil + } + + items := strings.Split(s[:i], ".") + if len(items) < 2 { + return nil + } + + // `log.level=trace` => `{log: {level: trace}}` + var pre string + var suf = s[i+1:] + for _, item := range items { + pre += "{" + item + ": " + suf += "}" + } + + return []byte(pre + suf) +} diff --git a/internal/app/log.go b/internal/app/log.go index e8d4bc88..094dfbbf 100644 --- a/internal/app/log.go +++ b/internal/app/log.go @@ -4,49 +4,100 @@ import ( "io" "os" + "github.com/mattn/go-isatty" "github.com/rs/zerolog" - "github.com/rs/zerolog/log" ) -var MemoryLog *circularBuffer - -func NewLogger(format string, level string) zerolog.Logger { - var writer io.Writer = os.Stdout - - if format != "json" { - writer = zerolog.ConsoleWriter{ - Out: writer, TimeFormat: "15:04:05.000", NoColor: format == "text", - } - } - - MemoryLog = newBuffer(16) - - writer = zerolog.MultiLevelWriter(writer, MemoryLog) - - zerolog.TimeFieldFormat = zerolog.TimeFormatUnixMs - - lvl, err := zerolog.ParseLevel(level) - if err != nil || lvl == zerolog.NoLevel { - lvl = zerolog.InfoLevel - } - - return zerolog.New(writer).With().Timestamp().Logger().Level(lvl) -} +var MemoryLog = newBuffer(16) func GetLogger(module string) zerolog.Logger { if s, ok := modules[module]; ok { lvl, err := zerolog.ParseLevel(s) if err == nil { - return log.Level(lvl) + return Logger.Level(lvl) } - log.Warn().Err(err).Caller().Send() + Logger.Warn().Err(err).Caller().Send() } - return log.Logger + return Logger } +// initLogger support: +// - output: empty (only to memory), stderr, stdout +// - format: empty (autodetect color support), color, json, text +// - time: empty (disable timestamp), UNIXMS, UNIXMICRO, UNIXNANO +// - level: disabled, trace, debug, info, warn, error... +func initLogger() { + var cfg struct { + Mod map[string]string `yaml:"log"` + } + + cfg.Mod = modules // defaults + + LoadConfig(&cfg) + + var writer io.Writer + + switch modules["output"] { + case "stderr": + writer = os.Stderr + case "stdout": + writer = os.Stdout + } + + timeFormat := modules["time"] + + if writer != nil { + if format := modules["format"]; format != "json" { + console := &zerolog.ConsoleWriter{Out: writer} + + switch format { + case "text": + console.NoColor = true + case "color": + console.NoColor = false // useless, but anyway + default: + // autodetection if output support color + // go-isatty - dependency for go-colorable - dependency for ConsoleWriter + console.NoColor = !isatty.IsTerminal(writer.(*os.File).Fd()) + } + + if timeFormat != "" { + console.TimeFormat = "15:04:05.000" + } else { + console.PartsOrder = []string{ + zerolog.LevelFieldName, + zerolog.CallerFieldName, + zerolog.MessageFieldName, + } + } + + writer = console + } + + writer = zerolog.MultiLevelWriter(writer, MemoryLog) + } else { + writer = MemoryLog + } + + lvl, _ := zerolog.ParseLevel(modules["level"]) + Logger = zerolog.New(writer).Level(lvl) + + if timeFormat != "" { + zerolog.TimeFieldFormat = timeFormat + Logger = Logger.With().Timestamp().Logger() + } +} + +var Logger zerolog.Logger + // modules log levels -var modules map[string]string +var modules = map[string]string{ + "format": "", // useless, but anyway + "level": "info", + "output": "stdout", // TODO: change to stderr someday + "time": zerolog.TimeFormatUnixMs, +} const chunkSize = 1 << 16 diff --git a/internal/app/migrate.go b/internal/app/migrate.go deleted file mode 100644 index 95c51c51..00000000 --- a/internal/app/migrate.go +++ /dev/null @@ -1,35 +0,0 @@ -package app - -import ( - "encoding/json" - "os" - - "github.com/rs/zerolog/log" -) - -func migrateStore() { - const name = "go2rtc.json" - - data, _ := os.ReadFile(name) - if data == nil { - return - } - - var store struct { - Streams map[string]string `json:"streams"` - } - - if err := json.Unmarshal(data, &store); err != nil { - log.Warn().Err(err).Caller().Send() - return - } - - for id, url := range store.Streams { - if err := PatchConfig(id, url, "streams"); err != nil { - log.Warn().Err(err).Caller().Send() - return - } - } - - _ = os.Remove(name) -} diff --git a/internal/bubble/bubble.go b/internal/bubble/bubble.go index 65d0237e..6c526fc5 100644 --- a/internal/bubble/bubble.go +++ b/internal/bubble/bubble.go @@ -7,13 +7,7 @@ import ( ) func Init() { - streams.HandleFunc("bubble", handle) -} - -func handle(url string) (core.Producer, error) { - conn := bubble.NewClient(url) - if err := conn.Dial(); err != nil { - return nil, err - } - return conn, nil + streams.HandleFunc("bubble", func(source string) (core.Producer, error) { + return bubble.Dial(source) + }) } diff --git a/internal/debug/debug.go b/internal/debug/debug.go index 3d40d1f1..fc7d2453 100644 --- a/internal/debug/debug.go +++ b/internal/debug/debug.go @@ -2,16 +2,8 @@ package debug import ( "github.com/AlexxIT/go2rtc/internal/api" - "github.com/AlexxIT/go2rtc/internal/streams" - "github.com/AlexxIT/go2rtc/pkg/core" ) func Init() { api.HandleFunc("api/stack", stackHandler) - - streams.HandleFunc("null", nullHandler) -} - -func nullHandler(string) (core.Producer, error) { - return nil, nil } diff --git a/internal/dvrip/dvrip.go b/internal/dvrip/dvrip.go index 470e8afd..db1c60db 100644 --- a/internal/dvrip/dvrip.go +++ b/internal/dvrip/dvrip.go @@ -10,26 +10,16 @@ import ( "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/streams" - "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/dvrip" - "github.com/rs/zerolog/log" ) func Init() { - streams.HandleFunc("dvrip", handle) + streams.HandleFunc("dvrip", dvrip.Dial) // DVRIP client autodiscovery api.HandleFunc("api/dvrip", apiDvrip) } -func handle(url string) (core.Producer, error) { - client, err := dvrip.Dial(url) - if err != nil { - return nil, err - } - return client, nil -} - const Port = 34569 // UDP port number for dvrip discovery func apiDvrip(w http.ResponseWriter, r *http.Request) { @@ -92,10 +82,7 @@ func sendBroadcasts(conn *net.UDPConn) { for i := 0; i < 3; i++ { time.Sleep(100 * time.Millisecond) - - if _, err = conn.WriteToUDP(data, addr); err != nil { - log.Err(err).Caller().Send() - } + _, _ = conn.WriteToUDP(data, addr) } } diff --git a/internal/exec/closer.go b/internal/exec/closer.go new file mode 100644 index 00000000..66d0e3ac --- /dev/null +++ b/internal/exec/closer.go @@ -0,0 +1,39 @@ +package exec + +import ( + "errors" + "net/url" + "os" + "os/exec" + "syscall" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +// closer support custom killsignal with custom killtimeout +type closer struct { + cmd *exec.Cmd + query url.Values +} + +func (c *closer) Close() (err error) { + sig := os.Kill + if s := c.query.Get("killsignal"); s != "" { + sig = syscall.Signal(core.Atoi(s)) + } + + log.Trace().Msgf("[exec] kill with signal=%d", sig) + err = c.cmd.Process.Signal(sig) + + if s := c.query.Get("killtimeout"); s != "" { + timeout := time.Duration(core.Atoi(s)) * time.Second + timer := time.AfterFunc(timeout, func() { + log.Trace().Msgf("[exec] kill after timeout=%s", s) + _ = c.cmd.Process.Kill() + }) + defer timer.Stop() // stop timer if Wait ends before timeout + } + + return errors.Join(err, c.cmd.Wait()) +} diff --git a/internal/exec/exec.go b/internal/exec/exec.go index a160136c..035317d9 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -1,6 +1,7 @@ package exec import ( + "bufio" "crypto/md5" "encoding/hex" "errors" @@ -9,6 +10,7 @@ import ( "net/url" "os" "os/exec" + "slices" "strings" "sync" "time" @@ -49,8 +51,10 @@ func Init() { } func execHandle(rawURL string) (core.Producer, error) { + rawURL, rawQuery, _ := strings.Cut(rawURL, "#") + query := streams.ParseQuery(rawQuery) + var path string - var query url.Values // RTSP flow should have `{output}` inside URL // pipe flow may have `#{params}` inside URL @@ -62,60 +66,73 @@ func execHandle(rawURL string) (core.Producer, error) { sum := md5.Sum([]byte(rawURL)) path = "/" + hex.EncodeToString(sum[:]) rawURL = rawURL[:i] + "rtsp://127.0.0.1:" + rtsp.Port + path + rawURL[i+8:] - } else if i = strings.IndexByte(rawURL, '#'); i > 0 { - query = streams.ParseQuery(rawURL[i+1:]) - rawURL = rawURL[:i] } args := shell.QuoteSplit(rawURL[5:]) // remove `exec:` cmd := exec.Command(args[0], args[1:]...) - if log.Debug().Enabled() { - cmd.Stderr = os.Stderr + cmd.Stderr = &logWriter{ + buf: make([]byte, 512), + debug: log.Debug().Enabled(), } - if path == "" { - return handlePipe(rawURL, cmd, query) - } - - return handleRTSP(rawURL, cmd, path) -} - -func handlePipe(_ string, cmd *exec.Cmd, query url.Values) (core.Producer, error) { if query.Get("backchannel") == "1" { return stdin.NewClient(cmd) } - r, err := PipeCloser(cmd, query) + cl := &closer{cmd: cmd, query: query} + + if path == "" { + return handlePipe(rawURL, cmd, cl) + } + + return handleRTSP(rawURL, cmd, cl, path) +} + +func handlePipe(source string, cmd *exec.Cmd, cl io.Closer) (core.Producer, error) { + stdout, err := cmd.StdoutPipe() if err != nil { return nil, err } + rc := struct { + io.Reader + io.Closer + }{ + // add buffer for pipe reader to reduce syscall + bufio.NewReaderSize(stdout, core.BufferSize), + cl, + } + + log.Debug().Strs("args", cmd.Args).Msg("[exec] run pipe") + + ts := time.Now() + if err = cmd.Start(); err != nil { return nil, err } - prod, err := magic.Open(r) + prod, err := magic.Open(rc) if err != nil { - _ = r.Close() + _ = rc.Close() + return nil, fmt.Errorf("exec/pipe: %w\n%s", err, cmd.Stderr) } - return prod, err + if info, ok := prod.(core.Info); ok { + info.SetProtocol("pipe") + setRemoteInfo(info, source, cmd.Args) + } + + log.Debug().Stringer("launch", time.Since(ts)).Msg("[exec] run pipe") + + return prod, nil } -func handleRTSP(url string, cmd *exec.Cmd, path string) (core.Producer, error) { - stderr := limitBuffer{buf: make([]byte, 512)} - - if cmd.Stderr != nil { - cmd.Stderr = io.MultiWriter(cmd.Stderr, &stderr) - } else { - cmd.Stderr = &stderr - } - +func handleRTSP(source string, cmd *exec.Cmd, cl io.Closer, path string) (core.Producer, error) { if log.Trace().Enabled() { cmd.Stdout = os.Stdout } - waiter := make(chan core.Producer) + waiter := make(chan *pkg.Conn, 1) waitersMu.Lock() waiters[path] = waiter @@ -127,12 +144,12 @@ func handleRTSP(url string, cmd *exec.Cmd, path string) (core.Producer, error) { waitersMu.Unlock() }() - log.Debug().Str("url", url).Str("cmd", fmt.Sprintf("%s", strings.Join(cmd.Args, " "))).Msg("[exec] run") + log.Debug().Strs("args", cmd.Args).Msg("[exec] run rtsp") ts := time.Now() if err := cmd.Start(); err != nil { - log.Error().Err(err).Str("url", url).Msg("[exec]") + log.Error().Err(err).Str("source", source).Msg("[exec]") return nil, err } @@ -142,15 +159,17 @@ func handleRTSP(url string, cmd *exec.Cmd, path string) (core.Producer, error) { }() select { - case <-time.After(time.Second * 60): - _ = cmd.Process.Kill() - log.Error().Str("url", url).Msg("[exec] timeout") - return nil, errors.New("timeout") + case <-time.After(time.Minute): + log.Error().Str("source", source).Msg("[exec] timeout") + _ = cl.Close() + return nil, errors.New("exec: timeout") case <-done: // limit message size - return nil, errors.New("exec: " + stderr.String()) + return nil, fmt.Errorf("exec/rtsp\n%s", cmd.Stderr) case prod := <-waiter: - log.Debug().Stringer("launch", time.Since(ts)).Msg("[exec] run") + log.Debug().Stringer("launch", time.Since(ts)).Msg("[exec] run rtsp") + setRemoteInfo(prod, source, cmd.Args) + prod.OnClose = cl.Close return prod, nil } } @@ -159,25 +178,63 @@ func handleRTSP(url string, cmd *exec.Cmd, path string) (core.Producer, error) { var ( log zerolog.Logger - waiters = map[string]chan core.Producer{} + waiters = make(map[string]chan *pkg.Conn) waitersMu sync.Mutex ) -type limitBuffer struct { - buf []byte - n int +type logWriter struct { + buf []byte + debug bool + n int } -func (l *limitBuffer) String() string { +func (l *logWriter) String() string { if l.n == len(l.buf) { return string(l.buf) + "..." } return string(l.buf[:l.n]) } -func (l *limitBuffer) Write(p []byte) (int, error) { +func (l *logWriter) Write(p []byte) (n int, err error) { if l.n < cap(l.buf) { l.n += copy(l.buf[l.n:], p) } - return len(p), nil + n = len(p) + if l.debug { + if p = trimSpace(p); p != nil { + log.Debug().Msgf("[exec] %s", p) + } + } + return +} + +func trimSpace(b []byte) []byte { + start := 0 + stop := len(b) + for ; start < stop; start++ { + if b[start] >= ' ' { + break // trim all ASCII before 0x20 + } + } + for ; ; stop-- { + if stop == start { + return nil // skip empty output + } + if b[stop-1] > ' ' { + break // trim all ASCII before 0x21 + } + } + return b[start:stop] +} + +func setRemoteInfo(info core.Info, source string, args []string) { + info.SetSource(source) + + if i := slices.Index(args, "-i"); i > 0 && i < len(args)-1 { + rawURL := args[i+1] + if u, err := url.Parse(rawURL); err == nil && u.Host != "" { + info.SetRemoteAddr(u.Host) + info.SetURL(rawURL) + } + } } diff --git a/internal/exec/pipe.go b/internal/exec/pipe.go deleted file mode 100644 index 12ea136b..00000000 --- a/internal/exec/pipe.go +++ /dev/null @@ -1,56 +0,0 @@ -package exec - -import ( - "bufio" - "errors" - "io" - "net/url" - "os/exec" - "syscall" - "time" - - "github.com/AlexxIT/go2rtc/pkg/core" -) - -// PipeCloser - return StdoutPipe that Kill cmd on Close call -func PipeCloser(cmd *exec.Cmd, query url.Values) (io.ReadCloser, error) { - stdout, err := cmd.StdoutPipe() - if err != nil { - return nil, err - } - - // add buffer for pipe reader to reduce syscall - return &pipeCloser{bufio.NewReaderSize(stdout, core.BufferSize), stdout, cmd, query}, nil -} - -type pipeCloser struct { - io.Reader - io.Closer - cmd *exec.Cmd - query url.Values -} - -func (p *pipeCloser) Close() error { - return errors.Join(p.Closer.Close(), p.Kill(), p.Wait()) -} - -func (p *pipeCloser) Kill() error { - if s := p.query.Get("killsignal"); s != "" { - log.Trace().Msgf("[exec] kill with custom sig=%s", s) - sig := syscall.Signal(core.Atoi(s)) - return p.cmd.Process.Signal(sig) - } - return p.cmd.Process.Kill() -} - -func (p *pipeCloser) Wait() error { - if s := p.query.Get("killtimeout"); s != "" { - timeout := time.Duration(core.Atoi(s)) * time.Second - timer := time.AfterFunc(timeout, func() { - log.Trace().Msgf("[exec] kill after timeout=%s", s) - _ = p.cmd.Process.Kill() - }) - defer timer.Stop() // stop timer if Wait ends before timeout - } - return p.cmd.Wait() -} diff --git a/internal/ffmpeg/README.md b/internal/ffmpeg/README.md index 88a91d45..903aab5d 100644 --- a/internal/ffmpeg/README.md +++ b/internal/ffmpeg/README.md @@ -45,6 +45,13 @@ [video4linux2,v4l2 @ 0x7f7de7c58bc0] Compressed: mjpeg : Motion-JPEG : 640x480 160x120 176x144 320x176 320x240 352x288 432x240 544x288 640x360 752x416 800x448 800x600 864x480 960x544 960x720 1024x576 1184x656 1280x720 1280x960 ``` +## TTS + +```yaml +streams: + tts: ffmpeg:#input=-readrate 1 -readrate_initial_burst 0.001 -f lavfi -i "flite=text='1 2 3 4 5 6 7 8 9 0'"#audio=pcma +``` + ## Useful links - https://superuser.com/questions/564402/explanation-of-x264-tune diff --git a/internal/ffmpeg/api.go b/internal/ffmpeg/api.go new file mode 100644 index 00000000..d802f87c --- /dev/null +++ b/internal/ffmpeg/api.go @@ -0,0 +1,51 @@ +package ffmpeg + +import ( + "net/http" + "strings" + + "github.com/AlexxIT/go2rtc/internal/streams" +) + +func apiFFmpeg(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "", http.StatusMethodNotAllowed) + return + } + + query := r.URL.Query() + dst := query.Get("dst") + stream := streams.Get(dst) + if stream == nil { + http.Error(w, "", http.StatusNotFound) + return + } + + var src string + if s := query.Get("file"); s != "" { + if streams.Validate(s) == nil { + src = "ffmpeg:" + s + "#audio=auto#input=file" + } + } else if s = query.Get("live"); s != "" { + if streams.Validate(s) == nil { + src = "ffmpeg:" + s + "#audio=auto" + } + } else if s = query.Get("text"); s != "" { + if strings.IndexAny(s, `'"&%$`) < 0 { + src = "ffmpeg:tts?text=" + s + if s = query.Get("voice"); s != "" { + src += "&voice=" + s + } + src += "#audio=auto" + } + } + + if src == "" { + http.Error(w, "", http.StatusBadRequest) + return + } + + if err := stream.Play(src); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} diff --git a/internal/ffmpeg/device/device_freebsd.go b/internal/ffmpeg/device/device_bsd.go similarity index 97% rename from internal/ffmpeg/device/device_freebsd.go rename to internal/ffmpeg/device/device_bsd.go index f3a26a30..27d5b615 100644 --- a/internal/ffmpeg/device/device_freebsd.go +++ b/internal/ffmpeg/device/device_bsd.go @@ -1,3 +1,5 @@ +//go:build freebsd || netbsd || openbsd || dragonfly + package device import ( diff --git a/internal/ffmpeg/device/device_darwin.go b/internal/ffmpeg/device/device_darwin.go index ac9b5e43..ba97c0aa 100644 --- a/internal/ffmpeg/device/device_darwin.go +++ b/internal/ffmpeg/device/device_darwin.go @@ -1,3 +1,5 @@ +//go:build darwin || ios + package device import ( diff --git a/internal/ffmpeg/device/device_linux.go b/internal/ffmpeg/device/device_unix.go similarity index 96% rename from internal/ffmpeg/device/device_linux.go rename to internal/ffmpeg/device/device_unix.go index d1228b15..7b62187f 100644 --- a/internal/ffmpeg/device/device_linux.go +++ b/internal/ffmpeg/device/device_unix.go @@ -1,3 +1,5 @@ +//go:build unix && !darwin && !freebsd && !netbsd && !openbsd && !dragonfly + package device import ( diff --git a/internal/ffmpeg/device/device_windows.go b/internal/ffmpeg/device/device_windows.go index c14630d3..ff328311 100644 --- a/internal/ffmpeg/device/device_windows.go +++ b/internal/ffmpeg/device/device_windows.go @@ -1,3 +1,5 @@ +//go:build windows + package device import ( diff --git a/internal/ffmpeg/device/devices.go b/internal/ffmpeg/device/devices.go index a51abd64..69b13444 100644 --- a/internal/ffmpeg/device/devices.go +++ b/internal/ffmpeg/device/devices.go @@ -1,11 +1,9 @@ package device import ( - "errors" "net/http" "net/url" "strconv" - "strings" "sync" "github.com/AlexxIT/go2rtc/internal/api" @@ -17,24 +15,15 @@ func Init(bin string) { api.HandleFunc("api/ffmpeg/devices", apiDevices) } -func GetInput(src string) (string, error) { - i := strings.IndexByte(src, '?') - if i < 0 { - return "", errors.New("empty query: " + src) - } - - query, err := url.ParseQuery(src[i+1:]) +func GetInput(src string) string { + query, err := url.ParseQuery(src) if err != nil { - return "", err + return "" } runonce.Do(initDevices) - if input := queryToInput(query); input != "" { - return input, nil - } - - return "", errors.New("wrong query: " + src) + return queryToInput(query) } var Bin string diff --git a/internal/ffmpeg/ffmpeg.go b/internal/ffmpeg/ffmpeg.go index e815f39b..062e5aaf 100644 --- a/internal/ffmpeg/ffmpeg.go +++ b/internal/ffmpeg/ffmpeg.go @@ -2,35 +2,58 @@ package ffmpeg import ( "net/url" + "slices" "strings" + "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/internal/ffmpeg/device" "github.com/AlexxIT/go2rtc/internal/ffmpeg/hardware" "github.com/AlexxIT/go2rtc/internal/ffmpeg/virtual" "github.com/AlexxIT/go2rtc/internal/rtsp" "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/ffmpeg" + "github.com/rs/zerolog" ) func Init() { var cfg struct { Mod map[string]string `yaml:"ffmpeg"` + Log struct { + Level string `yaml:"ffmpeg"` + } `yaml:"log"` } cfg.Mod = defaults // will be overriden from yaml + cfg.Log.Level = "error" app.LoadConfig(&cfg) - if app.GetLogger("exec").GetLevel() >= 0 { - defaults["global"] += " -v error" + log = app.GetLogger("ffmpeg") + + // zerolog levels: trace debug info warn error fatal panic disabled + // FFmpeg levels: trace debug verbose info warning error fatal panic quiet + if cfg.Log.Level == "warn" { + cfg.Log.Level = "warning" } + defaults["global"] += " -v " + cfg.Log.Level streams.RedirectFunc("ffmpeg", func(url string) (string, error) { + if _, err := Version(); err != nil { + return "", err + } args := parseArgs(url[7:]) + if slices.Contains(args.Codecs, "auto") { + return "", nil // force call streams.HandleFunc("ffmpeg") + } return "exec:" + args.String(), nil }) + streams.HandleFunc("ffmpeg", NewProducer) + + api.HandleFunc("api/ffmpeg", apiFFmpeg) + device.Init(defaults["bin"]) hardware.Init(defaults["bin"]) } @@ -49,16 +72,25 @@ var defaults = map[string]string{ // output "output": "-user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}", "output/mjpeg": "-f mjpeg -", + "output/raw": "-f yuv4mpegpipe -", + "output/aac": "-f adts -", + "output/wav": "-f wav -", // `-preset superfast` - we can't use ultrafast because it doesn't support `-profile main -level 4.1` // `-tune zerolatency` - for minimal latency // `-profile high -level 4.1` - most used streaming profile // `-pix_fmt:v yuv420p` - important for Telegram "h264": "-c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p", - "h265": "-c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency", + "h265": "-c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p", "mjpeg": "-c:v mjpeg", //"mjpeg": "-c:v mjpeg -force_duplicated_matrix:v 1 -huffman:v 0 -pix_fmt:v yuvj420p", + "raw": "-c:v rawvideo", + "raw/gray8": "-c:v rawvideo -pix_fmt:v gray8", + "raw/yuv420p": "-c:v rawvideo -pix_fmt:v yuv420p", + "raw/yuv422p": "-c:v rawvideo -pix_fmt:v yuv422p", + "raw/yuv444p": "-c:v rawvideo -pix_fmt:v yuv444p", + // https://ffmpeg.org/ffmpeg-codecs.html#libopus-1 // https://github.com/pion/webrtc/issues/1514 // https://ffmpeg.org/ffmpeg-resampler.html @@ -116,6 +148,8 @@ var defaults = map[string]string{ "h265/videotoolbox": "-c:v hevc_videotoolbox -g 50 -bf 0 -profile:v main -level:v 5.1", } +var log zerolog.Logger + // configTemplate - return template from config (defaults) if exist or return raw template func configTemplate(template string) string { if s := defaults[template]; s != "" { @@ -140,9 +174,10 @@ func inputTemplate(name, s string, query url.Values) string { func parseArgs(s string) *ffmpeg.Args { // init FFmpeg arguments args := &ffmpeg.Args{ - Bin: defaults["bin"], - Global: defaults["global"], - Output: defaults["output"], + Bin: defaults["bin"], + Global: defaults["global"], + Output: defaults["output"], + Version: verAV, } var query url.Values @@ -188,16 +223,14 @@ func parseArgs(s string) *ffmpeg.Args { s += "?video&audio" } args.Input = inputTemplate("rtsp", s, query) - } else if strings.HasPrefix(s, "device?") { - var err error - args.Input, err = device.GetInput(s) - if err != nil { - return nil - } - } else if strings.HasPrefix(s, "virtual?") { - var err error - if args.Input, err = virtual.GetInput(s[8:]); err != nil { - return nil + } else if i = strings.Index(s, "?"); i > 0 { + switch s[:i] { + case "device": + args.Input = device.GetInput(s[i+1:]) + case "virtual": + args.Input = virtual.GetInput(s[i+1:]) + case "tts": + args.Input = virtual.GetInputTTS(s[i+1:]) } } else { args.Input = inputTemplate("file", s, query) @@ -280,6 +313,12 @@ func parseArgs(s string) *ffmpeg.Args { } } + if query["bitrate"] != nil { + // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate + b := query["bitrate"][0] + args.AddCodec("-b:v " + b + " -maxrate " + b + " -bufsize " + b) + } + // 4. Process audio codecs if args.Audio > 0 { for _, audio := range query["audio"] { @@ -309,11 +348,27 @@ func parseArgs(s string) *ffmpeg.Args { args.AddCodec("-an") } - // transcoding to only mjpeg - if (args.Video == 1 && args.Audio == 0 && query.Get("video") == "mjpeg") || - // no transcoding from mjpeg input - (args.Video == 0 && args.Audio == 0 && strings.Contains(args.Input, " mjpeg ")) { - args.Output = defaults["output/mjpeg"] + // change otput from RTSP to some other pipe format + switch { + case args.Video == 0 && args.Audio == 0: + // no transcoding from mjpeg input (ffmpeg device with support output as raw MJPEG) + if strings.Contains(args.Input, " mjpeg ") { + args.Output = defaults["output/mjpeg"] + } + case args.Video == 1 && args.Audio == 0: + switch core.Before(query.Get("video"), "/") { + case "mjpeg": + args.Output = defaults["output/mjpeg"] + case "raw": + args.Output = defaults["output/raw"] + } + case args.Video == 0 && args.Audio == 1: + switch core.Before(query.Get("audio"), "/") { + case "aac": + args.Output = defaults["output/aac"] + case "pcma", "pcmu", "pcml": + args.Output = defaults["output/wav"] + } } return args diff --git a/internal/ffmpeg/ffmpeg_test.go b/internal/ffmpeg/ffmpeg_test.go index d5a39284..3fc5d208 100644 --- a/internal/ffmpeg/ffmpeg_test.go +++ b/internal/ffmpeg/ffmpeg_test.go @@ -3,6 +3,7 @@ package ffmpeg import ( "testing" + "github.com/AlexxIT/go2rtc/pkg/ffmpeg" "github.com/stretchr/testify/require" ) @@ -292,3 +293,23 @@ func TestDrawText(t *testing.T) { }) } } + +func TestVersion(t *testing.T) { + verAV = ffmpeg.Version61 + tests := []struct { + name string + source string + expect string + }{ + { + source: "/media/bbb.mp4", + expect: `ffmpeg -hide_banner -readrate_initial_burst 0.001 -re -i /media/bbb.mp4 -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + args := parseArgs(test.source) + require.Equal(t, test.expect, args.String()) + }) + } +} diff --git a/internal/ffmpeg/hardware/hardware.go b/internal/ffmpeg/hardware/hardware.go index ebbdc4fa..39ce3323 100644 --- a/internal/ffmpeg/hardware/hardware.go +++ b/internal/ffmpeg/hardware/hardware.go @@ -7,8 +7,6 @@ import ( "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/pkg/ffmpeg" - - "github.com/rs/zerolog/log" ) const ( @@ -152,7 +150,6 @@ var cache = map[string]string{} func run(bin string, args string) bool { err := exec.Command(bin, strings.Split(args, " ")...).Run() - log.Printf("%v %v", args, err) return err == nil } diff --git a/internal/ffmpeg/hardware/hardware_freebsd.go b/internal/ffmpeg/hardware/hardware_bsd.go similarity index 96% rename from internal/ffmpeg/hardware/hardware_freebsd.go rename to internal/ffmpeg/hardware/hardware_bsd.go index 6ef753ac..de24ac5c 100644 --- a/internal/ffmpeg/hardware/hardware_freebsd.go +++ b/internal/ffmpeg/hardware/hardware_bsd.go @@ -1,3 +1,5 @@ +//go:build freebsd || netbsd || openbsd || dragonfly + package hardware import ( diff --git a/internal/ffmpeg/hardware/hardware_darwin.go b/internal/ffmpeg/hardware/hardware_darwin.go index 8392d2b1..b1505512 100644 --- a/internal/ffmpeg/hardware/hardware_darwin.go +++ b/internal/ffmpeg/hardware/hardware_darwin.go @@ -1,3 +1,5 @@ +//go:build darwin || ios + package hardware import ( diff --git a/internal/ffmpeg/hardware/hardware_linux.go b/internal/ffmpeg/hardware/hardware_unix.go similarity index 97% rename from internal/ffmpeg/hardware/hardware_linux.go rename to internal/ffmpeg/hardware/hardware_unix.go index f0d4858e..4f688ce4 100644 --- a/internal/ffmpeg/hardware/hardware_linux.go +++ b/internal/ffmpeg/hardware/hardware_unix.go @@ -1,3 +1,5 @@ +//go:build unix && !darwin && !freebsd && !netbsd && !openbsd && !dragonfly + package hardware import ( diff --git a/internal/ffmpeg/hardware/hardware_windows.go b/internal/ffmpeg/hardware/hardware_windows.go index c22639f5..cdf0e12c 100644 --- a/internal/ffmpeg/hardware/hardware_windows.go +++ b/internal/ffmpeg/hardware/hardware_windows.go @@ -1,3 +1,5 @@ +//go:build windows + package hardware import "github.com/AlexxIT/go2rtc/internal/api" diff --git a/internal/ffmpeg/producer.go b/internal/ffmpeg/producer.go new file mode 100644 index 00000000..d132d253 --- /dev/null +++ b/internal/ffmpeg/producer.go @@ -0,0 +1,118 @@ +package ffmpeg + +import ( + "encoding/json" + "errors" + "net/url" + "strconv" + "strings" + + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/aac" + "github.com/AlexxIT/go2rtc/pkg/core" +) + +type Producer struct { + core.Connection + url string + query url.Values + ffmpeg core.Producer +} + +// NewProducer - FFmpeg producer with auto selection video/audio codec based on client capabilities +func NewProducer(url string) (core.Producer, error) { + p := &Producer{} + + i := strings.IndexByte(url, '#') + p.url, p.query = url[:i], streams.ParseQuery(url[i+1:]) + + // ffmpeg.NewProducer support only one audio + if len(p.query["video"]) != 0 || len(p.query["audio"]) != 1 { + return nil, errors.New("ffmpeg: unsupported params: " + url[i:]) + } + + p.ID = core.NewID() + p.FormatName = "ffmpeg" + p.Medias = []*core.Media{ + { + // we can support only audio, because don't know FmtpLine for H264 and PayloadType for MJPEG + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + // codecs in order from best to worst + Codecs: []*core.Codec{ + // OPUS will always marked as OPUS/48000/2 + {Name: core.CodecOpus, ClockRate: 48000, Channels: 2}, + {Name: core.CodecPCM, ClockRate: 16000}, + {Name: core.CodecPCMA, ClockRate: 16000}, + {Name: core.CodecPCMU, ClockRate: 16000}, + {Name: core.CodecPCM, ClockRate: 8000}, + {Name: core.CodecPCMA, ClockRate: 8000}, + {Name: core.CodecPCMU, ClockRate: 8000}, + // AAC has unknown problems on Dahua two way + {Name: core.CodecAAC, ClockRate: 16000, FmtpLine: aac.FMTP + "1408"}, + }, + }, + } + return p, nil +} + +func (p *Producer) Start() error { + var err error + if p.ffmpeg, err = streams.GetProducer(p.newURL()); err != nil { + return err + } + + for i, media := range p.ffmpeg.GetMedias() { + track, err := p.ffmpeg.GetTrack(media, media.Codecs[0]) + if err != nil { + return err + } + p.Receivers[i].Replace(track) + } + + return p.ffmpeg.Start() +} + +func (p *Producer) Stop() error { + if p.ffmpeg == nil { + return nil + } + return p.ffmpeg.Stop() +} + +func (p *Producer) MarshalJSON() ([]byte, error) { + if p.ffmpeg == nil { + return json.Marshal(p.Connection) + } + return json.Marshal(p.ffmpeg) +} + +func (p *Producer) newURL() string { + s := p.url + // rewrite codecs in url from auto to known presets from defaults + for _, receiver := range p.Receivers { + codec := receiver.Codec + switch codec.Name { + case core.CodecOpus: + s += "#audio=opus" + case core.CodecAAC: + s += "#audio=aac/16000" + case core.CodecPCM: + s += "#audio=pcm/" + strconv.Itoa(int(codec.ClockRate)) + case core.CodecPCMA: + s += "#audio=pcma/" + strconv.Itoa(int(codec.ClockRate)) + case core.CodecPCMU: + s += "#audio=pcmu/" + strconv.Itoa(int(codec.ClockRate)) + } + } + // add other params + for key, values := range p.query { + if key != "audio" { + for _, value := range values { + s += "#" + key + "=" + value + } + } + } + + return s +} diff --git a/internal/ffmpeg/version.go b/internal/ffmpeg/version.go new file mode 100644 index 00000000..717e08a4 --- /dev/null +++ b/internal/ffmpeg/version.go @@ -0,0 +1,46 @@ +package ffmpeg + +import ( + "errors" + "os/exec" + "sync" + + "github.com/AlexxIT/go2rtc/pkg/ffmpeg" +) + +var verMu sync.Mutex +var verErr error +var verFF string +var verAV string + +func Version() (string, error) { + verMu.Lock() + defer verMu.Unlock() + + if verFF != "" { + return verFF, verErr + } + + cmd := exec.Command(defaults["bin"], "-version") + b, err := cmd.Output() + if err != nil { + verFF = "-" + verErr = err + return verFF, verErr + } + + verFF, verAV = ffmpeg.ParseVersion(b) + + if verFF == "" { + verFF = "?" + } + + // better to compare libavformat, because nightly/master builds + if verAV != "" && verAV < ffmpeg.Version50 { + verErr = errors.New("ffmpeg: unsupported version: " + verFF) + } + + log.Debug().Str("version", verFF).Str("libavformat", verAV).Msgf("[ffmpeg] bin") + + return verFF, verErr +} diff --git a/internal/ffmpeg/virtual/virtual.go b/internal/ffmpeg/virtual/virtual.go index 4a791982..4dc3b025 100644 --- a/internal/ffmpeg/virtual/virtual.go +++ b/internal/ffmpeg/virtual/virtual.go @@ -4,56 +4,76 @@ import ( "net/url" ) -func GetInput(src string) (string, error) { +func GetInput(src string) string { query, err := url.ParseQuery(src) if err != nil { - return "", err + return "" } - // set defaults (using Add instead of Set) - query.Add("source", "testsrc") - query.Add("size", "1920x1080") - query.Add("decimals", "2") + input := "-re" - // https://ffmpeg.org/ffmpeg-filters.html - source := query.Get("source") - input := "-re -f lavfi -i " + source + for _, video := range query["video"] { + // https://ffmpeg.org/ffmpeg-filters.html + sep := "=" // first separator - sep := "=" // first separator - for key, values := range query { - value := values[0] - - // https://ffmpeg.org/ffmpeg-utils.html#video-size-syntax - switch key { - case "color", "rate", "duration", "sar": - case "size": - switch value { - case "720": - value = "1280x720" - case "1080": - value = "1920x1080" - case "2K": - value = "2560x1440" - case "4K": - value = "3840x2160" - case "8K": - value = "7680x4230" // https://reolink.com/blog/8k-resolution/ - } - case "decimals": - if source != "testsrc" { - continue - } - default: - continue + if video == "" { + video = "testsrc=decimals=2" // default video + sep = ":" } - input += sep + key + "=" + value - sep = ":" // next separator + input += " -f lavfi -i " + video + + // set defaults (using Add instead of Set) + query.Add("size", "1920x1080") + + for key, values := range query { + value := values[0] + + // https://ffmpeg.org/ffmpeg-utils.html#video-size-syntax + switch key { + case "color", "rate", "duration", "sar", "decimals": + case "size": + switch value { + case "720": + value = "1280x720" // crf=1 -> 12 Mbps + case "1080": + value = "1920x1080" // crf=1 -> 25 Mbps + case "2K": + value = "2560x1440" // crf=1 -> 43 Mbps + case "4K": + value = "3840x2160" // crf=1 -> 103 Mbps + case "8K": + value = "7680x4230" // https://reolink.com/blog/8k-resolution/ + } + default: + continue + } + + input += sep + key + "=" + value + sep = ":" // next separator + } + + if s := query.Get("format"); s != "" { + input += ",format=" + s + } } - if s := query.Get("format"); s != "" { - input += ",format=" + s - } - - return input, nil + return input +} + +func GetInputTTS(src string) string { + query, err := url.ParseQuery(src) + if err != nil { + return "" + } + + input := `-re -f lavfi -i "flite=text='` + query.Get("text") + `'` + + // ffmpeg -f lavfi -i flite=list_voices=1 + // awb, kal, kal16, rms, slt + if voice := query.Get("voice"); voice != "" { + input += ":voice" + voice + } + + return input + `"` } diff --git a/internal/ffmpeg/virtual/virtual_test.go b/internal/ffmpeg/virtual/virtual_test.go new file mode 100644 index 00000000..b648a9cd --- /dev/null +++ b/internal/ffmpeg/virtual/virtual_test.go @@ -0,0 +1,20 @@ +package virtual + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetInput(t *testing.T) { + s := GetInput("video") + require.Equal(t, "-re -f lavfi -i testsrc=decimals=2:size=1920x1080", s) + + s = GetInput("video=testsrc2&size=4K") + require.Equal(t, "-re -f lavfi -i testsrc2=size=3840x2160", s) +} + +func TestGetInputTTS(t *testing.T) { + s := GetInputTTS("text=hello world&voice=slt") + require.Equal(t, `-re -f lavfi -i "flite=text='hello world':voiceslt"`, s) +} diff --git a/internal/gopro/gopro.go b/internal/gopro/gopro.go index 55d2641b..ee578049 100644 --- a/internal/gopro/gopro.go +++ b/internal/gopro/gopro.go @@ -10,15 +10,13 @@ import ( ) func Init() { - streams.HandleFunc("gopro", handleGoPro) + streams.HandleFunc("gopro", func(source string) (core.Producer, error) { + return gopro.Dial(source) + }) api.HandleFunc("api/gopro", apiGoPro) } -func handleGoPro(rawURL string) (core.Producer, error) { - return gopro.Dial(rawURL) -} - func apiGoPro(w http.ResponseWriter, r *http.Request) { var items []*api.Source diff --git a/internal/hass/api.go b/internal/hass/api.go index 4628cc11..e3de23b3 100644 --- a/internal/hass/api.go +++ b/internal/hass/api.go @@ -63,7 +63,7 @@ func apiStream(w http.ResponseWriter, r *http.Request) { return } - s, err = webrtc.ExchangeSDP(stream, string(offer), "WebRTC/Hass sync", r.UserAgent()) + s, err = webrtc.ExchangeSDP(stream, string(offer), "hass/webrtc", r.UserAgent()) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/internal/hass/hass.go b/internal/hass/hass.go index 76ffc89d..ea172b02 100644 --- a/internal/hass/hass.go +++ b/internal/hass/hass.go @@ -21,7 +21,7 @@ import ( func Init() { var conf struct { API struct { - Listen string `json:"listen"` + Listen string `yaml:"listen"` } `yaml:"api"` Mod struct { Config string `yaml:"config"` @@ -45,19 +45,14 @@ func Init() { return "", nil }) - streams.HandleFunc("hass", func(url string) (core.Producer, error) { + streams.HandleFunc("hass", func(source string) (core.Producer, error) { // support hass://supervisor?entity_id=camera.driveway_doorbell - client, err := hass.NewClient(url) - if err != nil { - return nil, err - } - - return client, nil + return hass.NewClient(source) }) // load static entries from Hass config if err := importConfig(conf.Mod.Config); err != nil { - log.Debug().Msgf("[hass] can't import config: %s", err) + log.Trace().Msgf("[hass] can't import config: %s", err) api.HandleFunc("api/hass", func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "no hass config", http.StatusNotFound) diff --git a/internal/hls/hls.go b/internal/hls/hls.go index 5d3cd918..5c136450 100644 --- a/internal/hls/hls.go +++ b/internal/hls/hls.go @@ -12,7 +12,6 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/mp4" "github.com/AlexxIT/go2rtc/pkg/mpegts" - "github.com/AlexxIT/go2rtc/pkg/tcp" "github.com/rs/zerolog" ) @@ -63,15 +62,13 @@ func handlerStream(w http.ResponseWriter, r *http.Request) { medias := mp4.ParseQuery(r.URL.Query()) if medias != nil { c := mp4.NewConsumer(medias) - c.Type = "HLS/fMP4 consumer" - c.RemoteAddr = tcp.RemoteAddr(r) - c.UserAgent = r.UserAgent() + c.FormatName = "hls/fmp4" + c.WithRequest(r) cons = c } else { c := mpegts.NewConsumer() - c.Type = "HLS/TS consumer" - c.RemoteAddr = tcp.RemoteAddr(r) - c.UserAgent = r.UserAgent() + c.FormatName = "hls/mpegts" + c.WithRequest(r) cons = c } diff --git a/internal/hls/ws.go b/internal/hls/ws.go index ea1f5a3a..608f515f 100644 --- a/internal/hls/ws.go +++ b/internal/hls/ws.go @@ -8,7 +8,6 @@ import ( "github.com/AlexxIT/go2rtc/internal/api/ws" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/mp4" - "github.com/AlexxIT/go2rtc/pkg/tcp" ) func handlerWSHLS(tr *ws.Transport, msg *ws.Message) error { @@ -20,9 +19,8 @@ func handlerWSHLS(tr *ws.Transport, msg *ws.Message) error { codecs := msg.String() medias := mp4.ParseCodecs(codecs, true) cons := mp4.NewConsumer(medias) - cons.Type = "HLS/fMP4 consumer" - cons.RemoteAddr = tcp.RemoteAddr(tr.Request) - cons.UserAgent = tr.Request.UserAgent() + cons.FormatName = "hls/fmp4" + cons.WithRequest(tr.Request) log.Trace().Msgf("[hls] new ws consumer codecs=%s", codecs) diff --git a/internal/homekit/homekit.go b/internal/homekit/homekit.go index 868b51e3..743aeab9 100644 --- a/internal/homekit/homekit.go +++ b/internal/homekit/homekit.go @@ -22,12 +22,11 @@ import ( func Init() { var cfg struct { Mod map[string]struct { - Pin string `json:"pin"` - Name string `json:"name"` - DeviceID string `json:"device_id"` - DevicePrivate string `json:"device_private"` - Pairings []string `json:"pairings"` - //Listen string `json:"listen"` + Pin string `yaml:"pin"` + Name string `yaml:"name"` + DeviceID string `yaml:"device_id"` + DevicePrivate string `yaml:"device_private"` + Pairings []string `yaml:"pairings"` } `yaml:"homekit"` } app.LoadConfig(&cfg) @@ -134,12 +133,19 @@ func Init() { var log zerolog.Logger var servers map[string]*server -func streamHandler(url string) (core.Producer, error) { +func streamHandler(rawURL string) (core.Producer, error) { if srtp.Server == nil { return nil, errors.New("homekit: can't work without SRTP server") } - return homekit.Dial(url, srtp.Server) + rawURL, rawQuery, _ := strings.Cut(rawURL, "#") + client, err := homekit.Dial(rawURL, srtp.Server) + if client != nil && rawQuery != "" { + query := streams.ParseQuery(rawQuery) + client.Bitrate = parseBitrate(query.Get("bitrate")) + } + + return client, err } func hapPairSetup(w http.ResponseWriter, r *http.Request) { @@ -200,3 +206,24 @@ func findHomeKitURL(stream *streams.Stream) string { return "" } + +func parseBitrate(s string) int { + n := len(s) + if n == 0 { + return 0 + } + + var k int + switch n--; s[n] { + case 'K': + k = 1024 + s = s[:n] + case 'M': + k = 1024 * 1024 + s = s[:n] + default: + k = 1 + } + + return k * core.Atoi(s) +} diff --git a/internal/http/http.go b/internal/http/http.go index 8b1903f3..a35439d5 100644 --- a/internal/http/http.go +++ b/internal/http/http.go @@ -11,9 +11,9 @@ import ( "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/hls" + "github.com/AlexxIT/go2rtc/pkg/image" "github.com/AlexxIT/go2rtc/pkg/magic" - "github.com/AlexxIT/go2rtc/pkg/mjpeg" - "github.com/AlexxIT/go2rtc/pkg/multipart" + "github.com/AlexxIT/go2rtc/pkg/mpjpeg" "github.com/AlexxIT/go2rtc/pkg/tcp" ) @@ -45,6 +45,21 @@ func handleHTTP(rawURL string) (core.Producer, error) { } } + prod, err := do(req) + if err != nil { + return nil, err + } + + if info, ok := prod.(core.Info); ok { + info.SetProtocol("http") + info.SetRemoteAddr(req.URL.Host) // TODO: rewrite to net.Conn + info.SetURL(rawURL) + } + + return prod, nil +} + +func do(req *http.Request) (core.Producer, error) { res, err := tcp.Do(req) if err != nil { return nil, err @@ -66,14 +81,12 @@ func handleHTTP(rawURL string) (core.Producer, error) { } switch { - case ct == "image/jpeg": - return mjpeg.NewClient(res), nil - - case ct == "multipart/x-mixed-replace": - return multipart.Open(res.Body) - case ct == "application/vnd.apple.mpegurl" || ext == "m3u8": return hls.OpenURL(req.URL, res.Body) + case ct == "image/jpeg": + return image.Open(res) + case ct == "multipart/x-mixed-replace": + return mpjpeg.Open(res.Body) } return magic.Open(res.Body) diff --git a/internal/isapi/init.go b/internal/isapi/init.go index a37afa23..887a6748 100644 --- a/internal/isapi/init.go +++ b/internal/isapi/init.go @@ -7,16 +7,7 @@ import ( ) func Init() { - streams.HandleFunc("isapi", handle) -} - -func handle(url string) (core.Producer, error) { - conn, err := isapi.NewClient(url) - if err != nil { - return nil, err - } - if err = conn.Dial(); err != nil { - return nil, err - } - return conn, nil + streams.HandleFunc("isapi", func(source string) (core.Producer, error) { + return isapi.Dial(source) + }) } diff --git a/internal/ivideon/ivideon.go b/internal/ivideon/ivideon.go index 0ae5dc9f..03feb742 100644 --- a/internal/ivideon/ivideon.go +++ b/internal/ivideon/ivideon.go @@ -4,16 +4,10 @@ import ( "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/ivideon" - "strings" ) func Init() { - streams.HandleFunc("ivideon", func(url string) (core.Producer, error) { - id := strings.Replace(url[8:], "/", ":", 1) - prod := ivideon.NewClient(id) - if err := prod.Dial(); err != nil { - return nil, err - } - return prod, nil + streams.HandleFunc("ivideon", func(source string) (core.Producer, error) { + return ivideon.Dial(source) }) } diff --git a/internal/mjpeg/README.md b/internal/mjpeg/README.md new file mode 100644 index 00000000..a09e59c4 --- /dev/null +++ b/internal/mjpeg/README.md @@ -0,0 +1,38 @@ +## Stream as ASCII to Terminal + +[![](https://img.youtube.com/vi/sHj_3h_sX7M/mqdefault.jpg)](https://www.youtube.com/watch?v=sHj_3h_sX7M) + +**Tips** + +- this feature works only with MJPEG codec (use transcoding) +- choose a low frame rate (FPS) +- choose the width and height to fit in your terminal +- different terminals support different numbers of colours (8, 256, rgb) +- escape text param with urlencode +- you can stream any camera or file from a disc + +**go2rtc.yaml** - transcoding to MJPEG, terminal size - 210x59 (16/9), fps - 10 + +```yaml +streams: + gamazda: ffmpeg:gamazda.mp4#video=mjpeg#hardware#width=210#height=59#raw=-r 10 +``` + +**API params** + +- `color` - foreground color, values: empty, `8`, `256`, `rgb`, [SGR](https://en.wikipedia.org/wiki/ANSI_escape_code) + - example: `30` (black), `37` (white), `38;5;226` (yellow) +- `back` - background color, values: empty, `8`, `256`, `rgb`, [SGR](https://en.wikipedia.org/wiki/ANSI_escape_code) + - example: `40` (black), `47` (white), `48;5;226` (yellow) +- `text` - character set, values: empty, one char, `block`, list of chars (in order of brightness) + - example: `%20` (space), `block` (keyword for block elements), `ox` (two chars) + +**Examples** + +```bash +% curl "http://192.168.1.123:1984/api/stream.ascii?src=gamazda" +% curl "http://192.168.1.123:1984/api/stream.ascii?src=gamazda&color=256" +% curl "http://192.168.1.123:1984/api/stream.ascii?src=gamazda&back=256&text=%20" +% curl "http://192.168.1.123:1984/api/stream.ascii?src=gamazda&back=8&text=%20%20" +% curl "http://192.168.1.123:1984/api/stream.ascii?src=gamazda&text=helloworld" +``` diff --git a/internal/mjpeg/init.go b/internal/mjpeg/init.go index f2519a61..2bb7093a 100644 --- a/internal/mjpeg/init.go +++ b/internal/mjpeg/init.go @@ -5,26 +5,36 @@ import ( "io" "net/http" "strconv" + "strings" "time" "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/api/ws" + "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/internal/ffmpeg" "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/ascii" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/magic" "github.com/AlexxIT/go2rtc/pkg/mjpeg" - "github.com/AlexxIT/go2rtc/pkg/tcp" - "github.com/rs/zerolog/log" + "github.com/AlexxIT/go2rtc/pkg/mpjpeg" + "github.com/AlexxIT/go2rtc/pkg/y4m" + "github.com/rs/zerolog" ) func Init() { api.HandleFunc("api/frame.jpeg", handlerKeyframe) api.HandleFunc("api/stream.mjpeg", handlerStream) + api.HandleFunc("api/stream.ascii", handlerStream) + api.HandleFunc("api/stream.y4m", apiStreamY4M) ws.HandleFunc("mjpeg", handlerWS) + + log = app.GetLogger("mjpeg") } +var log zerolog.Logger + func handlerKeyframe(w http.ResponseWriter, r *http.Request) { src := r.URL.Query().Get("src") stream := streams.Get(src) @@ -34,8 +44,7 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) { } cons := magic.NewKeyframe() - cons.RemoteAddr = tcp.RemoteAddr(r) - cons.UserAgent = r.UserAgent() + cons.WithRequest(r) if err := stream.AddConsumer(cons); err != nil { log.Error().Err(err).Caller().Send() @@ -90,8 +99,7 @@ func outputMjpeg(w http.ResponseWriter, r *http.Request) { } cons := mjpeg.NewConsumer() - cons.RemoteAddr = tcp.RemoteAddr(r) - cons.UserAgent = r.UserAgent() + cons.WithRequest(r) if err := stream.AddConsumer(cons); err != nil { log.Error().Err(err).Msg("[api.mjpeg] add consumer") @@ -99,38 +107,22 @@ func outputMjpeg(w http.ResponseWriter, r *http.Request) { } h := w.Header() - h.Set("Content-Type", "multipart/x-mixed-replace; boundary=frame") h.Set("Cache-Control", "no-cache") h.Set("Connection", "close") h.Set("Pragma", "no-cache") - wr := &writer{wr: w, buf: []byte(header)} - _, _ = cons.WriteTo(wr) + if strings.HasSuffix(r.URL.Path, "mjpeg") { + wr := mjpeg.NewWriter(w) + _, _ = cons.WriteTo(wr) + } else { + cons.FormatName = "ascii" - stream.RemoveConsumer(cons) -} - -const header = "--frame\r\nContent-Type: image/jpeg\r\nContent-Length: " - -type writer struct { - wr io.Writer - buf []byte -} - -func (w *writer) Write(p []byte) (n int, err error) { - w.buf = w.buf[:len(header)] - w.buf = append(w.buf, strconv.Itoa(len(p))...) - w.buf = append(w.buf, "\r\n\r\n"...) - w.buf = append(w.buf, p...) - w.buf = append(w.buf, "\r\n"...) - - // Chrome bug: mjpeg image always shows the second to last image - // https://bugs.chromium.org/p/chromium/issues/detail?id=527446 - if n, err = w.wr.Write(w.buf); err == nil { - w.wr.(http.Flusher).Flush() + query := r.URL.Query() + wr := ascii.NewWriter(w, query.Get("color"), query.Get("back"), query.Get("text")) + _, _ = cons.WriteTo(wr) } - return + stream.RemoveConsumer(cons) } func inputMjpeg(w http.ResponseWriter, r *http.Request) { @@ -141,17 +133,16 @@ func inputMjpeg(w http.ResponseWriter, r *http.Request) { return } - res := &http.Response{Body: r.Body, Header: r.Header, Request: r} - res.Header.Set("Content-Type", "multipart/mixed;boundary=") + prod, _ := mpjpeg.Open(r.Body) + prod.WithRequest(r) - client := mjpeg.NewClient(res) - stream.AddProducer(client) + stream.AddProducer(prod) - if err := client.Start(); err != nil && err != io.EOF { + if err := prod.Start(); err != nil && err != io.EOF { log.Warn().Err(err).Caller().Send() } - stream.RemoveProducer(client) + stream.RemoveProducer(prod) } func handlerWS(tr *ws.Transport, _ *ws.Message) error { @@ -161,8 +152,7 @@ func handlerWS(tr *ws.Transport, _ *ws.Message) error { } cons := mjpeg.NewConsumer() - cons.RemoteAddr = tcp.RemoteAddr(tr.Request) - cons.UserAgent = tr.Request.UserAgent() + cons.WithRequest(tr.Request) if err := stream.AddConsumer(cons); err != nil { log.Debug().Err(err).Msg("[mjpeg] add consumer") @@ -179,3 +169,24 @@ func handlerWS(tr *ws.Transport, _ *ws.Message) error { return nil } + +func apiStreamY4M(w http.ResponseWriter, r *http.Request) { + src := r.URL.Query().Get("src") + stream := streams.Get(src) + if stream == nil { + http.Error(w, api.StreamNotFound, http.StatusNotFound) + return + } + + cons := y4m.NewConsumer() + cons.WithRequest(r) + + if err := stream.AddConsumer(cons); err != nil { + log.Error().Err(err).Caller().Send() + return + } + + _, _ = cons.WriteTo(w) + + stream.RemoveConsumer(cons) +} diff --git a/internal/mp4/mp4.go b/internal/mp4/mp4.go index 78708a35..cca5220c 100644 --- a/internal/mp4/mp4.go +++ b/internal/mp4/mp4.go @@ -1,6 +1,7 @@ package mp4 import ( + "context" "net/http" "strconv" "strings" @@ -12,7 +13,6 @@ import ( "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/mp4" - "github.com/AlexxIT/go2rtc/pkg/tcp" "github.com/rs/zerolog" ) @@ -99,9 +99,9 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) { medias := mp4.ParseQuery(r.URL.Query()) cons := mp4.NewConsumer(medias) - cons.Type = "MP4/HTTP active consumer" - cons.RemoteAddr = tcp.RemoteAddr(r) - cons.UserAgent = r.UserAgent() + cons.FormatName = "mp4" + cons.Protocol = "http" + cons.WithRequest(r) if err := stream.AddConsumer(cons); err != nil { log.Error().Err(err).Caller().Send() @@ -127,20 +127,20 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) { header.Set("Content-Disposition", `attachment; filename="`+filename+`"`) } - var duration *time.Timer - if s := query.Get("duration"); s != "" { - if i, _ := strconv.Atoi(s); i > 0 { - duration = time.AfterFunc(time.Second*time.Duration(i), func() { - _ = cons.Stop() - }) - } + ctx := r.Context() // handle when the client drops the connection + + if i := core.Atoi(query.Get("duration")); i > 0 { + timeout := time.Second * time.Duration(i) + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() } + go func() { + <-ctx.Done() + _ = cons.Stop() + stream.RemoveConsumer(cons) + }() + _, _ = cons.WriteTo(w) - - stream.RemoveConsumer(cons) - - if duration != nil { - duration.Stop() - } } diff --git a/internal/mp4/ws.go b/internal/mp4/ws.go index 060ff5f6..c880fb58 100644 --- a/internal/mp4/ws.go +++ b/internal/mp4/ws.go @@ -8,7 +8,6 @@ import ( "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/mp4" - "github.com/AlexxIT/go2rtc/pkg/tcp" ) func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error { @@ -24,9 +23,8 @@ func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error { } cons := mp4.NewConsumer(medias) - cons.Type = "MSE/WebSocket active consumer" - cons.RemoteAddr = tcp.RemoteAddr(tr.Request) - cons.UserAgent = tr.Request.UserAgent() + cons.FormatName = "mse/fmp4" + cons.WithRequest(tr.Request) if err := stream.AddConsumer(cons); err != nil { log.Debug().Err(err).Msg("[mp4] add consumer") @@ -57,9 +55,7 @@ func handlerWSMP4(tr *ws.Transport, msg *ws.Message) error { } cons := mp4.NewKeyframe(medias) - cons.Type = "MP4/WebSocket active consumer" - cons.RemoteAddr = tcp.RemoteAddr(tr.Request) - cons.UserAgent = tr.Request.UserAgent() + cons.WithRequest(tr.Request) if err := stream.AddConsumer(cons); err != nil { log.Error().Err(err).Caller().Send() diff --git a/internal/mpegts/aac.go b/internal/mpegts/aac.go index 3008a658..3b1522fe 100644 --- a/internal/mpegts/aac.go +++ b/internal/mpegts/aac.go @@ -6,8 +6,6 @@ import ( "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/aac" - "github.com/AlexxIT/go2rtc/pkg/tcp" - "github.com/rs/zerolog/log" ) func apiStreamAAC(w http.ResponseWriter, r *http.Request) { @@ -19,11 +17,9 @@ func apiStreamAAC(w http.ResponseWriter, r *http.Request) { } cons := aac.NewConsumer() - cons.RemoteAddr = tcp.RemoteAddr(r) - cons.UserAgent = r.UserAgent() + cons.WithRequest(r) if err := stream.AddConsumer(cons); err != nil { - log.Error().Err(err).Caller().Send() http.Error(w, err.Error(), http.StatusInternalServerError) return } diff --git a/internal/mpegts/mpegts.go b/internal/mpegts/mpegts.go index 6f4f6ab2..d5f7752b 100644 --- a/internal/mpegts/mpegts.go +++ b/internal/mpegts/mpegts.go @@ -6,8 +6,6 @@ import ( "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/mpegts" - "github.com/AlexxIT/go2rtc/pkg/tcp" - "github.com/rs/zerolog/log" ) func Init() { @@ -32,11 +30,9 @@ func outputMpegTS(w http.ResponseWriter, r *http.Request) { } cons := mpegts.NewConsumer() - cons.RemoteAddr = tcp.RemoteAddr(r) - cons.UserAgent = r.UserAgent() + cons.WithRequest(r) if err := stream.AddConsumer(cons); err != nil { - log.Error().Err(err).Caller().Send() http.Error(w, err.Error(), http.StatusInternalServerError) return } diff --git a/internal/nest/init.go b/internal/nest/init.go index 1281ccdc..01682414 100644 --- a/internal/nest/init.go +++ b/internal/nest/init.go @@ -10,19 +10,13 @@ import ( ) func Init() { - streams.HandleFunc("nest", streamNest) + streams.HandleFunc("nest", func(source string) (core.Producer, error) { + return nest.Dial(source) + }) api.HandleFunc("api/nest", apiNest) } -func streamNest(url string) (core.Producer, error) { - client, err := nest.NewClient(url) - if err != nil { - return nil, err - } - return client, nil -} - func apiNest(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() cliendID := query.Get("client_id") diff --git a/internal/ngrok/ngrok.go b/internal/ngrok/ngrok.go index 6b945668..28b5564d 100644 --- a/internal/ngrok/ngrok.go +++ b/internal/ngrok/ngrok.go @@ -50,7 +50,7 @@ func Init() { log.Info().Str("addr", address).Msg("[ngrok] add external candidate for WebRTC") - webrtc.AddCandidate(address, "tcp") + webrtc.AddCandidate("tcp", address) } } }) diff --git a/internal/roborock/roborock.go b/internal/roborock/roborock.go index 27e29bb5..32a436d8 100644 --- a/internal/roborock/roborock.go +++ b/internal/roborock/roborock.go @@ -11,22 +11,13 @@ import ( ) func Init() { - streams.HandleFunc("roborock", handle) + streams.HandleFunc("roborock", func(source string) (core.Producer, error) { + return roborock.Dial(source) + }) api.HandleFunc("api/roborock", apiHandle) } -func handle(url string) (core.Producer, error) { - conn := roborock.NewClient(url) - if err := conn.Dial(); err != nil { - return nil, err - } - if err := conn.Connect(); err != nil { - return nil, err - } - return conn, nil -} - var Auth struct { UserData *roborock.UserInfo `json:"user_data"` BaseURL string `json:"base_url"` diff --git a/internal/rtmp/rtmp.go b/internal/rtmp/rtmp.go index 07aa5f71..afc363a9 100644 --- a/internal/rtmp/rtmp.go +++ b/internal/rtmp/rtmp.go @@ -12,7 +12,6 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/flv" "github.com/AlexxIT/go2rtc/pkg/rtmp" - "github.com/AlexxIT/go2rtc/pkg/tcp" "github.com/rs/zerolog" ) @@ -128,11 +127,7 @@ func tcpHandle(netConn net.Conn) error { var log zerolog.Logger func streamsHandle(url string) (core.Producer, error) { - client, err := rtmp.DialPlay(url) - if err != nil { - return nil, err - } - return client, nil + return rtmp.DialPlay(url) } func streamsConsumerHandle(url string) (core.Consumer, func(), error) { @@ -165,9 +160,7 @@ func outputFLV(w http.ResponseWriter, r *http.Request) { } cons := flv.NewConsumer() - cons.Type = "HTTP-FLV consumer" - cons.RemoteAddr = tcp.RemoteAddr(r) - cons.UserAgent = r.UserAgent() + cons.WithRequest(r) if err := stream.AddConsumer(cons); err != nil { log.Error().Err(err).Caller().Send() diff --git a/internal/rtsp/rtsp.go b/internal/rtsp/rtsp.go index bb274d20..230bdece 100644 --- a/internal/rtsp/rtsp.go +++ b/internal/rtsp/rtsp.go @@ -21,7 +21,7 @@ func Init() { Username string `yaml:"username" json:"-"` Password string `yaml:"password" json:"-"` DefaultQuery string `yaml:"default_query" json:"default_query"` - PacketSize uint16 `yaml:"pkt_size"` + PacketSize uint16 `yaml:"pkt_size" json:"pkt_size,omitempty"` } `yaml:"rtsp"` } @@ -210,6 +210,11 @@ func tcpHandler(conn *rtsp.Conn) { return } + query := conn.URL.Query() + if s := query.Get("timeout"); s != "" { + conn.Timeout = core.Atoi(s) + } + log.Debug().Str("stream", name).Msg("[rtsp] new producer") stream.AddProducer(conn) @@ -239,7 +244,7 @@ func tcpHandler(conn *rtsp.Conn) { if closer != nil { if err := conn.Handle(); err != nil { - log.Debug().Msgf("[rtsp] handle=%s", err) + log.Debug().Err(err).Msg("[rtsp] handle") } closer() diff --git a/internal/streams/README.md b/internal/streams/README.md new file mode 100644 index 00000000..6bbc268a --- /dev/null +++ b/internal/streams/README.md @@ -0,0 +1,8 @@ +## Testing notes + +```yaml +streams: + test1-basic: ffmpeg:virtual?video#video=h264 + test2-reconnect: ffmpeg:virtual?video&duration=10#video=h264 + test3-execkill: exec:./examples/rtsp_client/rtsp_client/rtsp_client {output} +``` diff --git a/internal/streams/api.go b/internal/streams/api.go new file mode 100644 index 00000000..d64c4846 --- /dev/null +++ b/internal/streams/api.go @@ -0,0 +1,124 @@ +package streams + +import ( + "net/http" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/pkg/probe" +) + +func apiStreams(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + src := query.Get("src") + + // without source - return all streams list + if src == "" && r.Method != "POST" { + api.ResponseJSON(w, streams) + return + } + + // Not sure about all this API. Should be rewrited... + switch r.Method { + case "GET": + stream := Get(src) + if stream == nil { + http.Error(w, "", http.StatusNotFound) + return + } + + cons := probe.NewProbe(query) + if len(cons.Medias) != 0 { + cons.WithRequest(r) + if err := stream.AddConsumer(cons); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + api.ResponsePrettyJSON(w, stream) + + stream.RemoveConsumer(cons) + } else { + api.ResponsePrettyJSON(w, streams[src]) + } + + case "PUT": + name := query.Get("name") + if name == "" { + name = src + } + + if New(name, src) == nil { + http.Error(w, "", http.StatusBadRequest) + return + } + + if err := app.PatchConfig(name, src, "streams"); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } + + case "PATCH": + name := query.Get("name") + if name == "" { + http.Error(w, "", http.StatusBadRequest) + return + } + + // support {input} templates: https://github.com/AlexxIT/go2rtc#module-hass + if Patch(name, src) == nil { + http.Error(w, "", http.StatusBadRequest) + } + + case "POST": + // with dst - redirect source to dst + if dst := query.Get("dst"); dst != "" { + if stream := Get(dst); stream != nil { + if err := Validate(src); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } else if err = stream.Play(src); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } else { + api.ResponseJSON(w, stream) + } + } else if stream = Get(src); stream != nil { + if err := Validate(dst); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } else if err = stream.Publish(dst); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } else { + http.Error(w, "", http.StatusNotFound) + } + } else { + http.Error(w, "", http.StatusBadRequest) + } + + case "DELETE": + delete(streams, src) + + if err := app.PatchConfig(src, nil, "streams"); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } +} + +func apiStreamsDOT(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + + dot := make([]byte, 0, 1024) + dot = append(dot, "digraph {\n"...) + if query.Has("src") { + for _, name := range query["src"] { + if stream := streams[name]; stream != nil { + dot = AppendDOT(dot, stream) + } + } + } else { + for _, stream := range streams { + dot = AppendDOT(dot, stream) + } + } + dot = append(dot, '}') + + api.Response(w, dot, "text/vnd.graphviz") +} diff --git a/internal/streams/dot.go b/internal/streams/dot.go new file mode 100644 index 00000000..c54a733a --- /dev/null +++ b/internal/streams/dot.go @@ -0,0 +1,175 @@ +package streams + +import ( + "encoding/json" + "fmt" + "strings" +) + +func AppendDOT(dot []byte, stream *Stream) []byte { + for _, prod := range stream.producers { + if prod.conn == nil { + continue + } + c, err := marshalConn(prod.conn) + if err != nil { + continue + } + dot = c.appendDOT(dot, "producer") + } + for _, cons := range stream.consumers { + c, err := marshalConn(cons) + if err != nil { + continue + } + dot = c.appendDOT(dot, "consumer") + } + return dot +} + +func marshalConn(v any) (*conn, error) { + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + var c conn + if err = json.Unmarshal(b, &c); err != nil { + return nil, err + } + return &c, nil +} + +const bytesK = "KMGTP" + +func humanBytes(i int) string { + if i < 1000 { + return fmt.Sprintf("%d B", i) + } + + f := float64(i) / 1000 + var n uint8 + for f >= 1000 && n < 5 { + f /= 1000 + n++ + } + return fmt.Sprintf("%.2f %cB", f, bytesK[n]) +} + +type node struct { + ID uint32 `json:"id"` + Codec map[string]any `json:"codec"` + Parent uint32 `json:"parent"` + Childs []uint32 `json:"childs"` + Bytes int `json:"bytes"` + //Packets uint32 `json:"packets"` + //Drops uint32 `json:"drops"` +} + +var codecKeys = []string{"codec_name", "sample_rate", "channels", "profile", "level"} + +func (n *node) name() string { + if name, ok := n.Codec["codec_name"].(string); ok { + return name + } + return "unknown" +} + +func (n *node) codec() []byte { + b := make([]byte, 0, 128) + for _, k := range codecKeys { + if v := n.Codec[k]; v != nil { + b = fmt.Appendf(b, "%s=%v\n", k, v) + } + } + if l := len(b); l > 0 { + return b[:l-1] + } + return b +} + +func (n *node) appendDOT(dot []byte, group string) []byte { + dot = fmt.Appendf(dot, "%d [group=%s, label=%q, title=%q];\n", n.ID, group, n.name(), n.codec()) + //for _, sink := range n.Childs { + // dot = fmt.Appendf(dot, "%d -> %d;\n", n.ID, sink) + //} + return dot +} + +type conn struct { + ID uint32 `json:"id"` + FormatName string `json:"format_name"` + Protocol string `json:"protocol"` + RemoteAddr string `json:"remote_addr"` + Source string `json:"source"` + URL string `json:"url"` + UserAgent string `json:"user_agent"` + Receivers []node `json:"receivers"` + Senders []node `json:"senders"` + BytesRecv int `json:"bytes_recv"` + BytesSend int `json:"bytes_send"` +} + +func (c *conn) appendDOT(dot []byte, group string) []byte { + host := c.host() + dot = fmt.Appendf(dot, "%s [group=host];\n", host) + dot = fmt.Appendf(dot, "%d [group=%s, label=%q, title=%q];\n", c.ID, group, c.FormatName, c.label()) + if group == "producer" { + dot = fmt.Appendf(dot, "%s -> %d [label=%q];\n", host, c.ID, humanBytes(c.BytesRecv)) + } else { + dot = fmt.Appendf(dot, "%d -> %s [label=%q];\n", c.ID, host, humanBytes(c.BytesSend)) + } + + for _, recv := range c.Receivers { + dot = fmt.Appendf(dot, "%d -> %d [label=%q];\n", c.ID, recv.ID, humanBytes(recv.Bytes)) + dot = recv.appendDOT(dot, "node") + } + for _, send := range c.Senders { + dot = fmt.Appendf(dot, "%d -> %d [label=%q];\n", send.Parent, c.ID, humanBytes(send.Bytes)) + //dot = fmt.Appendf(dot, "%d -> %d [label=%q];\n", send.ID, c.ID, humanBytes(send.Bytes)) + //dot = send.appendDOT(dot, "node") + } + return dot +} + +func (c *conn) host() (s string) { + if c.Protocol == "pipe" { + return "127.0.0.1" + } + + if s = c.RemoteAddr; s == "" { + return "unknown" + } + + if i := strings.Index(s, "forwarded"); i > 0 { + s = s[i+10:] + } + + if s[0] == '[' { + if i := strings.Index(s, "]"); i > 0 { + return s[1:i] + } + } + + if i := strings.IndexAny(s, " ,:"); i > 0 { + return s[:i] + } + return +} + +func (c *conn) label() string { + var sb strings.Builder + sb.WriteString("format_name=" + c.FormatName) + if c.Protocol != "" { + sb.WriteString("\nprotocol=" + c.Protocol) + } + if c.Source != "" { + sb.WriteString("\nsource=" + c.Source) + } + if c.URL != "" { + sb.WriteString("\nurl=" + c.URL) + } + if c.UserAgent != "" { + sb.WriteString("\nuser_agent=" + c.UserAgent) + } + return sb.String() +} diff --git a/internal/streams/handlers.go b/internal/streams/handlers.go index 3009dd66..3240abb5 100644 --- a/internal/streams/handlers.go +++ b/internal/streams/handlers.go @@ -7,7 +7,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" ) -type Handler func(url string) (core.Producer, error) +type Handler func(source string) (core.Producer, error) var handlers = map[string]Handler{} diff --git a/internal/streams/play.go b/internal/streams/play.go index 748130f4..7ada66e6 100644 --- a/internal/streams/play.go +++ b/internal/streams/play.go @@ -2,6 +2,8 @@ package streams import ( "errors" + "time" + "github.com/AlexxIT/go2rtc/pkg/core" ) @@ -80,18 +82,20 @@ func (s *Stream) Play(source string) error { s.AddInternalProducer(src) s.AddInternalConsumer(cons) - go func() { - _ = src.Start() - _ = dst.Stop() - s.RemoveProducer(src) - }() - go func() { _ = dst.Start() _ = src.Stop() s.RemoveInternalConsumer(cons) }() + go func() { + _ = src.Start() + // little timeout before stop dst, so the buffer can be transferred + time.Sleep(time.Second) + _ = dst.Stop() + s.RemoveProducer(src) + }() + return nil } diff --git a/internal/streams/producer.go b/internal/streams/producer.go index 5a25dba5..09e2dcc5 100644 --- a/internal/streams/producer.go +++ b/internal/streams/producer.go @@ -132,11 +132,10 @@ func (p *Producer) AddTrack(media *core.Media, codec *core.Codec, track *core.Re } func (p *Producer) MarshalJSON() ([]byte, error) { - if p.conn != nil { - return json.Marshal(p.conn) + if conn := p.conn; conn != nil { + return json.Marshal(conn) } - - info := core.Info{URL: p.url} + info := map[string]string{"url": p.url} return json.Marshal(info) } @@ -207,7 +206,7 @@ func (p *Producer) reconnect(workerID, retry int) { for _, media := range conn.GetMedias() { switch media.Direction { case core.DirectionRecvonly: - for _, receiver := range p.receivers { + for i, receiver := range p.receivers { codec := media.MatchCodec(receiver.Codec) if codec == nil { continue @@ -219,6 +218,7 @@ func (p *Producer) reconnect(workerID, retry int) { } receiver.Replace(track) + p.receivers[i] = track break } @@ -234,6 +234,9 @@ func (p *Producer) reconnect(workerID, retry int) { } } + // stop previous connection after moving tracks (fix ghost exec/ffmpeg) + _ = p.conn.Stop() + // swap connections p.conn = conn go p.worker(conn, workerID) diff --git a/internal/streams/stream.go b/internal/streams/stream.go index 49c58e77..bb832694 100644 --- a/internal/streams/stream.go +++ b/internal/streams/stream.go @@ -88,6 +88,11 @@ func (s *Stream) RemoveProducer(prod core.Producer) { } func (s *Stream) stopProducers() { + if s.pending.Load() > 0 { + log.Trace().Msg("[streams] skip stop pending producer") + return + } + s.mu.Lock() producers: for _, producer := range s.producers { @@ -107,19 +112,12 @@ producers: } func (s *Stream) MarshalJSON() ([]byte, error) { - if !s.mu.TryLock() { - log.Warn().Msgf("[streams] json locked") - return json.Marshal(nil) - } - - var info struct { + var info = struct { Producers []*Producer `json:"producers"` Consumers []core.Consumer `json:"consumers"` + }{ + Producers: s.producers, + Consumers: s.consumers, } - info.Producers = s.producers - info.Consumers = s.consumers - - s.mu.Unlock() - return json.Marshal(info) } diff --git a/internal/streams/streams.go b/internal/streams/streams.go index afc6c4d3..ff0f5654 100644 --- a/internal/streams/streams.go +++ b/internal/streams/streams.go @@ -1,7 +1,7 @@ package streams import ( - "net/http" + "errors" "net/url" "regexp" "sync" @@ -26,7 +26,8 @@ func Init() { streams[name] = NewStream(item) } - api.HandleFunc("api/streams", streamsHandler) + api.HandleFunc("api/streams", apiStreams) + api.HandleFunc("api/streams.dot", apiStreamsDOT) if cfg.Publish == nil { return @@ -47,9 +48,16 @@ func Get(name string) *Stream { var sanitize = regexp.MustCompile(`\s`) -func New(name string, source string) *Stream { - // not allow creating dynamic streams with spaces in the source +// Validate - not allow creating dynamic streams with spaces in the source +func Validate(source string) error { if sanitize.MatchString(source) { + return errors.New("streams: invalid dynamic source") + } + return nil +} + +func New(name string, source string) *Stream { + if Validate(source) != nil { return nil } @@ -135,77 +143,6 @@ func Delete(id string) { delete(streams, id) } -func streamsHandler(w http.ResponseWriter, r *http.Request) { - query := r.URL.Query() - src := query.Get("src") - - // without source - return all streams list - if src == "" && r.Method != "POST" { - api.ResponseJSON(w, streams) - return - } - - // Not sure about all this API. Should be rewrited... - switch r.Method { - case "GET": - api.ResponsePrettyJSON(w, streams[src]) - - case "PUT": - name := query.Get("name") - if name == "" { - name = src - } - - if New(name, src) == nil { - http.Error(w, "", http.StatusBadRequest) - return - } - - if err := app.PatchConfig(name, src, "streams"); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - } - - case "PATCH": - name := query.Get("name") - if name == "" { - http.Error(w, "", http.StatusBadRequest) - return - } - - // support {input} templates: https://github.com/AlexxIT/go2rtc#module-hass - if Patch(name, src) == nil { - http.Error(w, "", http.StatusBadRequest) - } - - case "POST": - // with dst - redirect source to dst - if dst := query.Get("dst"); dst != "" { - if stream := Get(dst); stream != nil { - if err := stream.Play(src); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } else { - api.ResponseJSON(w, stream) - } - } else if stream = Get(src); stream != nil { - if err := stream.Publish(dst); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - } else { - http.Error(w, "", http.StatusNotFound) - } - } else { - http.Error(w, "", http.StatusBadRequest) - } - - case "DELETE": - delete(streams, src) - - if err := app.PatchConfig(src, nil, "streams"); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - } - } -} - var log zerolog.Logger var streams = map[string]*Stream{} var streamsMu sync.Mutex diff --git a/internal/tapo/tapo.go b/internal/tapo/tapo.go index a54c8c5e..724c9e86 100644 --- a/internal/tapo/tapo.go +++ b/internal/tapo/tapo.go @@ -8,11 +8,11 @@ import ( ) func Init() { - streams.HandleFunc("kasa", func(url string) (core.Producer, error) { - return kasa.Dial(url) + streams.HandleFunc("kasa", func(source string) (core.Producer, error) { + return kasa.Dial(source) }) - streams.HandleFunc("tapo", func(url string) (core.Producer, error) { - return tapo.Dial(url) + streams.HandleFunc("tapo", func(source string) (core.Producer, error) { + return tapo.Dial(source) }) } diff --git a/internal/webrtc/README.md b/internal/webrtc/README.md index 8c26fff9..4fb0072f 100644 --- a/internal/webrtc/README.md +++ b/internal/webrtc/README.md @@ -1,13 +1,105 @@ +What you should to know about WebRTC: + +- It's almost always a **direct [peer-to-peer](https://en.wikipedia.org/wiki/Peer-to-peer) connection** from your browser to go2rtc app +- When you use Home Assistant, Frigate, Nginx, Nabu Casa, Cloudflare and other software - they are only **involved in establishing** the connection, but they are **not involved in transferring** media data +- WebRTC media cannot be transferred inside an HTTP connection +- Usually, WebRTC uses random UDP ports on client and server side to establish a connection +- Usually, WebRTC uses public [STUN](https://en.wikipedia.org/wiki/STUN) servers to establish a connection outside LAN, such servers are only needed to establish a connection and are not involved in data transfer +- Usually, WebRTC will automatically discover all of your local addresses and all of your public addresses and try to establish a connection + +If an external connection via STUN is used: + +- Uses [UDP hole punching](https://en.wikipedia.org/wiki/UDP_hole_punching) technology to bypass NAT even if you not open your server to the World +- For about 20% of users, the techology will not work because of the [Symmetric NAT](https://tomchen.github.io/symmetric-nat-test/) +- UDP is not suitable for transmitting 2K and 4K high bitrate video over open networks because of the high loss rate + +## Default config + +```yaml +webrtc: + listen: ":8555/tcp" + ice_servers: + - urls: [ "stun:stun.l.google.com:19302" ] +``` + ## Config -- supported TCP: fixed port (default), disabled -- supported UDP: random port (default), fixed port +**Important!** This example is not for copypasting! -| Config examples | TCP | UDP | -|-----------------------|-------|--------| -| `listen: ":8555/tcp"` | fixed | random | -| `listen: ":8555"` | fixed | fixed | -| `listen: ""` | no | random | +```yaml +webrtc: + # fix local TCP or UDP or both ports for WebRTC media + listen: ":8555/tcp" # address of your local server + + # add additional host candidates manually + # order is important, the first will have a higher priority + candidates: + - 216.58.210.174:8555 # if you have static public IP-address + - stun:8555 # if you have dynamic public IP-address + - home.duckdns.org:8555 # if you have domain + + # add custom STUN and TURN servers + # use `ice_servers: []` for remove defaults and leave empty + ice_servers: + - urls: [ stun:stun1.l.google.com:19302 ] + - urls: [ turn:123.123.123.123:3478 ] + username: your_user + credential: your_pass + + # optional filter list for auto discovery logic + # some settings only make sense if you don't specify a fixed UDP port + filters: + # list of host candidates from auto discovery to be sent + # including candidates from the `listen` option + # use `candidates: []` to remove all auto discovery candidates + candidates: [ 192.168.1.123 ] + + # list of network types to be used for connection + # including candidates from the `listen` option + networks: [ udp4, udp6, tcp4, tcp6 ] + + # list of interfaces to be used for connection + # not related to the `listen` option + interfaces: [ eno1 ] + + # list of host IP-addresses to be used for connection + # not related to the `listen` option + ips: [ 192.168.1.123 ] + + # range for random UDP ports [min, max] to be used for connection + # not related to the `listen` option + udp_ports: [ 50000, 50100 ] +``` + +By default go2rtc uses **fixed TCP** port and multiple **random UDP** ports for each WebRTC connection - `listen: ":8555/tcp"`. + +You can set **fixed TCP** and **fixed UDP** port for all connections - `listen: ":8555"`. This may has lower performance, but it's your choice. + +Don't know why, but you can disable TCP port and leave only random UDP ports - `listen: ""`. + +## Config filters + +Filters allow you to exclude unnecessary candidates. Extra candidates don't make your connection worse or better. But the wrong filter settings can break everything. Skip this setting if you don't understand it. + +For example, go2rtc is installed on the host system. And there are unnecessary interfaces. You can keep only the relevant via `interfaces` or `ips` options. You can also exclude IPv6 candidates if your server supports them but your home network does not. + +```yaml +webrtc: + listen: ":8555/tcp" # use fixed TCP port and random UDP ports + filters: + ips: [ 192.168.1.2 ] # IP-address of your server + networks: [ udp4, tcp4 ] # skip IPv6, if it's not supported for you +``` + +For example, go2rtc inside closed docker container (ex. [Frigate](https://frigate.video/)). You shouldn't filter docker interfaces, otherwise go2rtc will not be able to connect anywhere. But you can filter the docker candidates because no one can connect to them. + +```yaml +webrtc: + listen: ":8555" # use fixed TCP and UDP ports + candidates: [ 192.168.1.2:8555 ] # add manual host candidate (use docker port forwarding) + filters: + candidates: [] # skip all internal docker candidates +``` ## Userful links diff --git a/internal/webrtc/candidates.go b/internal/webrtc/candidates.go index 65f2e213..b92c4656 100644 --- a/internal/webrtc/candidates.go +++ b/internal/webrtc/candidates.go @@ -2,57 +2,60 @@ package webrtc import ( "net" + "slices" + "strings" "github.com/AlexxIT/go2rtc/internal/api/ws" "github.com/AlexxIT/go2rtc/pkg/webrtc" - "github.com/pion/sdp/v3" + pion "github.com/pion/webrtc/v3" ) type Address struct { - Host string - Port string - Network string - Offset int + host string + Port string + Network string + Priority uint32 } -func (a *Address) Marshal() string { - host := a.Host - if host == "stun" { +func (a *Address) Host() string { + if a.host == "stun" { ip, err := webrtc.GetCachedPublicIP() if err != nil { return "" } - host = ip.String() + return ip.String() } + return a.host +} - switch a.Network { - case "udp": - return webrtc.CandidateManualHostUDP(host, a.Port, a.Offset) - case "tcp": - return webrtc.CandidateManualHostTCPPassive(host, a.Port, a.Offset) +func (a *Address) Marshal() string { + if host := a.Host(); host != "" { + return webrtc.CandidateICE(a.Network, host, a.Port, a.Priority) } - return "" } var addresses []*Address +var filters webrtc.Filters + +func AddCandidate(network, address string) { + if network == "" { + AddCandidate("tcp", address) + AddCandidate("udp", address) + return + } -func AddCandidate(address, network string) { host, port, err := net.SplitHostPort(address) if err != nil { return } - offset := -1 - len(addresses) // every next candidate will have a lower priority + // start from 1, so manual candidates will be lower than built-in + // and every next candidate will have a lower priority + candidateIndex := 1 + len(addresses) - switch network { - case "tcp", "udp": - addresses = append(addresses, &Address{host, port, network, offset}) - default: - addresses = append( - addresses, &Address{host, port, "udp", offset}, &Address{host, port, "tcp", offset}, - ) - } + priority := webrtc.CandidateHostPriority(network, candidateIndex) + addresses = append(addresses, &Address{host, port, network, priority}) } func GetCandidates() (candidates []string) { @@ -64,6 +67,38 @@ func GetCandidates() (candidates []string) { return } +// FilterCandidate return true if candidate passed the check +func FilterCandidate(candidate *pion.ICECandidate) bool { + if candidate == nil { + return false + } + + // host candidate should be in the hosts list + if candidate.Typ == pion.ICECandidateTypeHost && filters.Candidates != nil { + if !slices.Contains(filters.Candidates, candidate.Address) { + return false + } + } + + if filters.Networks != nil { + networkType := NetworkType(candidate.Protocol.String(), candidate.Address) + if !slices.Contains(filters.Networks, networkType) { + return false + } + } + + return true +} + +// NetworkType convert tcp/udp network to tcp4/tcp6/udp4/udp6 +func NetworkType(network, host string) string { + if strings.IndexByte(host, ':') >= 0 { + return network + "6" + } else { + return network + "4" + } +} + func asyncCandidates(tr *ws.Transport, cons *webrtc.Conn) { tr.WithContext(func(ctx map[any]any) { if candidates, ok := ctx["candidate"].([]string); ok { @@ -86,30 +121,6 @@ func asyncCandidates(tr *ws.Transport, cons *webrtc.Conn) { } } -func syncCanditates(answer string) (string, error) { - if len(addresses) == 0 { - return answer, nil - } - - sd := &sdp.SessionDescription{} - if err := sd.Unmarshal([]byte(answer)); err != nil { - return "", err - } - - md := sd.MediaDescriptions[0] - - for _, candidate := range GetCandidates() { - md.WithPropertyAttribute(candidate) - } - - data, err := sd.Marshal() - if err != nil { - return "", err - } - - return string(data), nil -} - func candidateHandler(tr *ws.Transport, msg *ws.Message) error { // process incoming candidate in sync function tr.WithContext(func(ctx map[any]any) { diff --git a/internal/webrtc/client.go b/internal/webrtc/client.go index ae1a455b..d42c51dd 100644 --- a/internal/webrtc/client.go +++ b/internal/webrtc/client.go @@ -41,7 +41,7 @@ func streamsHandler(rawURL string) (core.Producer, error) { // https://aws.amazon.com/kinesis/video-streams/ // https://docs.aws.amazon.com/kinesisvideostreams-webrtc-dg/latest/devguide/what-is-kvswebrtc.html // https://github.com/orgs/awslabs/repositories?q=kinesis+webrtc - return kinesisClient(rawURL, query, "WebRTC/Kinesis") + return kinesisClient(rawURL, query, "webrtc/kinesis") } else if format == "openipc" { return openIPCClient(rawURL, query) } else { @@ -77,17 +77,23 @@ func go2rtcClient(url string) (core.Producer, error) { // 2. Create PeerConnection pc, err := PeerConnection(true) if err != nil { - log.Error().Err(err).Caller().Send() return nil, err } + defer func() { + if err != nil { + _ = pc.Close() + } + }() + // waiter will wait PC error or WS error or nil (connection OK) var connState core.Waiter var connMu sync.Mutex prod := webrtc.NewConn(pc) - prod.Desc = "WebRTC/WebSocket async" prod.Mode = core.ModeActiveProducer + prod.Protocol = "ws" + prod.URL = url prod.Listen(func(msg any) { switch msg := msg.(type) { case *pion.ICECandidate: @@ -132,7 +138,8 @@ func go2rtcClient(url string) (core.Producer, error) { } if msg.Type != "webrtc/answer" { - return nil, errors.New("wrong answer: " + msg.Type) + err = errors.New("wrong answer: " + msg.String()) + return nil, err } answer := msg.String() @@ -180,8 +187,9 @@ func whepClient(url string) (core.Producer, error) { } prod := webrtc.NewConn(pc) - prod.Desc = "WebRTC/WHEP sync" prod.Mode = core.ModeActiveProducer + prod.Protocol = "http" + prod.URL = url medias := []*core.Media{ {Kind: core.KindVideo, Direction: core.DirectionRecvonly}, diff --git a/internal/webrtc/kinesis.go b/internal/webrtc/kinesis.go index 7ef9d9bb..2ea1cf7a 100644 --- a/internal/webrtc/kinesis.go +++ b/internal/webrtc/kinesis.go @@ -34,7 +34,7 @@ func (k kinesisResponse) String() string { return fmt.Sprintf("type=%s, payload=%s", k.Type, k.Payload) } -func kinesisClient(rawURL string, query url.Values, desc string) (core.Producer, error) { +func kinesisClient(rawURL string, query url.Values, format string) (core.Producer, error) { // 1. Connect to signalign server conn, _, err := websocket.DefaultDialer.Dial(rawURL, nil) if err != nil { @@ -79,8 +79,10 @@ func kinesisClient(rawURL string, query url.Values, desc string) (core.Producer, } prod := webrtc.NewConn(pc) - prod.Desc = desc + prod.FormatName = format prod.Mode = core.ModeActiveProducer + prod.Protocol = "ws" + prod.URL = rawURL prod.Listen(func(msg any) { switch msg := msg.(type) { case *pion.ICECandidate: @@ -216,5 +218,5 @@ func wyzeClient(rawURL string) (core.Producer, error) { "ice_servers": []string{string(kvs.Servers)}, } - return kinesisClient(kvs.URL, query, "WebRTC/Wyze") + return kinesisClient(kvs.URL, query, "webrtc/wyze") } diff --git a/internal/webrtc/milestone.go b/internal/webrtc/milestone.go index b4e695c9..6a696cb0 100644 --- a/internal/webrtc/milestone.go +++ b/internal/webrtc/milestone.go @@ -193,8 +193,10 @@ func milestoneClient(rawURL string, query url.Values) (core.Producer, error) { } prod := webrtc.NewConn(pc) - prod.Desc = "WebRTC/Milestone" + prod.FormatName = "webrtc/milestone" prod.Mode = core.ModeActiveProducer + prod.Protocol = "http" + prod.URL = rawURL offer, err := mc.GetOffer() if err != nil { diff --git a/internal/webrtc/openipc.go b/internal/webrtc/openipc.go index 8055ea91..8a951d04 100644 --- a/internal/webrtc/openipc.go +++ b/internal/webrtc/openipc.go @@ -53,8 +53,10 @@ func openIPCClient(rawURL string, query url.Values) (core.Producer, error) { var connState core.Waiter prod := webrtc.NewConn(pc) - prod.Desc = "WebRTC/OpenIPC" + prod.FormatName = "webrtc/openipc" prod.Mode = core.ModeActiveProducer + prod.Protocol = "ws" + prod.URL = rawURL prod.Listen(func(msg any) { switch msg := msg.(type) { case *pion.ICECandidate: diff --git a/internal/webrtc/server.go b/internal/webrtc/server.go index c3eb1387..f7365afa 100644 --- a/internal/webrtc/server.go +++ b/internal/webrtc/server.go @@ -65,6 +65,7 @@ func outputWebRTC(w http.ResponseWriter, r *http.Request) { url := r.URL.Query().Get("src") stream := streams.Get(url) if stream == nil { + http.Error(w, api.StreamNotFound, http.StatusNotFound) return } @@ -100,11 +101,11 @@ func outputWebRTC(w http.ResponseWriter, r *http.Request) { switch mediaType { case "application/json": - desc = "WebRTC/JSON sync" + desc = "webrtc/json" case MimeSDP: - desc = "WebRTC/WHEP sync" + desc = "webrtc/whep" default: - desc = "WebRTC/HTTP sync" + desc = "webrtc/post" } answer, err := ExchangeSDP(stream, offer, desc, r.UserAgent()) @@ -168,8 +169,8 @@ func inputWebRTC(w http.ResponseWriter, r *http.Request) { // create new webrtc instance prod := webrtc.NewConn(pc) - prod.Desc = "WebRTC/WHIP sync" prod.Mode = core.ModePassiveProducer + prod.Protocol = "http" prod.UserAgent = r.UserAgent() if err = prod.SetOffer(string(offer)); err != nil { @@ -178,10 +179,7 @@ func inputWebRTC(w http.ResponseWriter, r *http.Request) { return } - answer, err := prod.GetCompleteAnswer() - if err == nil { - answer, err = syncCanditates(answer) - } + answer, err := prod.GetCompleteAnswer(GetCandidates(), FilterCandidate) if err != nil { log.Warn().Err(err).Caller().Send() http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/internal/webrtc/webrtc.go b/internal/webrtc/webrtc.go index 621eccd4..8b4943c3 100644 --- a/internal/webrtc/webrtc.go +++ b/internal/webrtc/webrtc.go @@ -20,6 +20,7 @@ func Init() { Listen string `yaml:"listen"` Candidates []string `yaml:"candidates"` IceServers []pion.ICEServer `yaml:"ice_servers"` + Filters webrtc.Filters `yaml:"filters"` } `yaml:"webrtc"` } @@ -32,20 +33,15 @@ func Init() { log = app.GetLogger("webrtc") + filters = cfg.Mod.Filters + address, network, _ := strings.Cut(cfg.Mod.Listen, "/") - - var candidateHost []string for _, candidate := range cfg.Mod.Candidates { - if strings.HasPrefix(candidate, "host:") { - candidateHost = append(candidateHost, candidate[5:]) - continue - } - - AddCandidate(candidate, network) + AddCandidate(network, candidate) } // create pionAPI with custom codecs list and custom network settings - serverAPI, err := webrtc.NewServerAPI(address, network, candidateHost) + serverAPI, err := webrtc.NewServerAPI(network, address, &filters) if err != nil { log.Error().Err(err).Caller().Send() return @@ -55,8 +51,7 @@ func Init() { clientAPI := serverAPI if address != "" { - log.Info().Str("addr", address).Msg("[webrtc] listen") - + log.Info().Str("addr", cfg.Mod.Listen).Msg("[webrtc] listen") clientAPI, _ = webrtc.NewAPI() } @@ -122,8 +117,8 @@ func asyncHandler(tr *ws.Transport, msg *ws.Message) error { defer sendAnswer.Done(nil) conn := webrtc.NewConn(pc) - conn.Desc = "WebRTC/WebSocket async" conn.Mode = mode + conn.Protocol = "ws" conn.UserAgent = tr.Request.UserAgent() conn.Listen(func(msg any) { switch msg := msg.(type) { @@ -139,6 +134,9 @@ func asyncHandler(tr *ws.Transport, msg *ws.Message) error { } case *pion.ICECandidate: + if !FilterCandidate(msg) { + return + } _ = sendAnswer.Wait() s := msg.ToJSON().Candidate @@ -209,8 +207,9 @@ func ExchangeSDP(stream *streams.Stream, offer, desc, userAgent string) (answer // create new webrtc instance conn := webrtc.NewConn(pc) - conn.Desc = desc + conn.FormatName = desc conn.UserAgent = userAgent + conn.Protocol = "http" conn.Listen(func(msg any) { switch msg := msg.(type) { case pion.PeerConnectionState: @@ -248,10 +247,7 @@ func ExchangeSDP(stream *streams.Stream, offer, desc, userAgent string) (answer stream.AddProducer(conn) } - answer, err = conn.GetCompleteAnswer() - if err == nil { - answer, err = syncCanditates(answer) - } + answer, err = conn.GetCompleteAnswer(GetCandidates(), FilterCandidate) log.Trace().Msgf("[webrtc] answer\n%s", answer) if err != nil { diff --git a/internal/webtorrent/init.go b/internal/webtorrent/init.go index 25b7ef9b..b1c25c76 100644 --- a/internal/webtorrent/init.go +++ b/internal/webtorrent/init.go @@ -47,7 +47,7 @@ func Init() { if stream == nil { return "", errors.New(api.StreamNotFound) } - return webrtc.ExchangeSDP(stream, offer, "WebRTC/WebTorrent sync", "") + return webrtc.ExchangeSDP(stream, offer, "webtorrent", "") }, } diff --git a/main.go b/main.go index 91bc9938..98bd79e3 100644 --- a/main.go +++ b/main.go @@ -36,6 +36,8 @@ import ( ) func main() { + app.Version = "1.9.4" + // 1. Core modules: app, api/ws, streams app.Init() // init config and logs diff --git a/pkg/README.md b/pkg/README.md index c875dc35..b12f0a70 100644 --- a/pkg/README.md +++ b/pkg/README.md @@ -1,3 +1,85 @@ +# Notes + +go2rtc tries to name formats, protocols and codecs the same way they are named in FFmpeg. +Some formats and protocols go2rtc supports exclusively. They have no equivalent in FFmpeg. + +## Producers (input) + +- The initiator of the connection can be go2rtc - **Source protocols** +- The initiator of the connection can be an external program - **Ingress protocols** +- Codecs can be incoming - **Recevers codecs** +- Codecs can be outgoing (two way audio) - **Senders codecs** + +| Format | Source protocols | Ingress protocols | Recevers codecs | Senders codecs | Example | +|--------------|------------------|-------------------|------------------------------|--------------------|---------------| +| adts | http,tcp,pipe | http | aac | | `http:` | +| bubble | http | | h264,hevc,pcm_alaw | | `bubble:` | +| dvrip | tcp | | h264,hevc,pcm_alaw,pcm_mulaw | pcm_alaw | `dvrip:` | +| flv | http,tcp,pipe | http | h264,aac | | `http:` | +| gopro | http+udp | | TODO | | `gopro:` | +| hass/webrtc | ws+udp,tcp | | TODO | | `hass:` | +| hls/mpegts | http | | h264,h265,aac,opus | | `http:` | +| homekit | homekit+udp | | h264,eld* | | `homekit:` | +| isapi | http | | | pcm_alaw,pcm_mulaw | `isapi:` | +| ivideon | ws | | h264 | | `ivideon:` | +| kasa | http | | h264,pcm_mulaw | | `kasa:` | +| h264 | http,tcp,pipe | http | h264 | | `http:` | +| hevc | http,tcp,pipe | http | hevc | | `http:` | +| mjpeg | http,tcp,pipe | http | mjpeg | | `http:` | +| mpjpeg | http,tcp,pipe | http | mjpeg | | `http:` | +| mpegts | http,tcp,pipe | http | h264,hevc,aac,opus | | `http:` | +| nest/webrtc | http+udp | | TODO | | `nest:` | +| roborock | mqtt+udp | | h264,opus | opus | `roborock:` | +| rtmp | rtmp | rtmp | h264,aac | | `rtmp:` | +| rtsp | rtsp+tcp,ws | rtsp+tcp | h264,hevc,aac,pcm*,opus | pcm*,opus | `rtsp:` | +| stdin | pipe | | | pcm_alaw,pcm_mulaw | `stdin:` | +| tapo | http | | h264,pcma | pcm_alaw | `tapo:` | +| wav | http,tcp,pipe | http | pcm_alaw,pcm_mulaw | | `http:` | +| webrtc* | TODO | TODO | h264,pcm_alaw,pcm_mulaw,opus | pcm_alaw,pcm_mulaw | `webrtc:` | +| webtorrent | TODO | TODO | TODO | TODO | `webtorrent:` | +| yuv4mpegpipe | http,tcp,pipe | http | rawvideo | | `http:` | + +- **eld** - rare variant of aac codec +- **pcm** - pcm_alaw pcm_mulaw pcm_s16be pcm_s16le +- **webrtc** - webrtc/kinesis, webrtc/openipc, webrtc/milestone, webrtc/wyze, webrtc/whep + +## Consumers (output) + +| Format | Protocol | Send codecs | Recv codecs | Example | +|--------------|-------------|------------------------------|-------------------------|---------------------------------------| +| adts | http | aac | | `GET /api/stream.adts` | +| ascii | http | mjpeg | | `GET /api/stream.ascii` | +| flv | http | h264,aac | | `GET /api/stream.flv` | +| hls/mpegts | http | h264,hevc,aac | | `GET /api/stream.m3u8` | +| hls/fmp4 | http | h264,hevc,aac,pcm*,opus | | `GET /api/stream.m3u8?mp4` | +| homekit | homekit+udp | h264,opus | | Apple HomeKit app | +| mjpeg | ws | mjpeg | | `{"type":"mjpeg"}` -> `/api/ws` | +| mpjpeg | http | mjpeg | | `GET /api/stream.mjpeg` | +| mp4 | http | h264,hevc,aac,pcm*,opus | | `GET /api/stream.mp4` | +| mse/fmp4 | ws | h264,hevc,aac,pcm*,opus | | `{"type":"mse"}` -> `/api/ws` | +| mpegts | http | h264,hevc,aac | | `GET /api/stream.ts` | +| rtmp | rtmp | h264,aac | | `rtmp://localhost:1935/{stream_name}` | +| rtsp | rtsp+tcp | h264,hevc,aac,pcm*,opus | | `rtsp://localhost:8554/{stream_name}` | +| webrtc | TODO | h264,pcm_alaw,pcm_mulaw,opus | pcm_alaw,pcm_mulaw,opus | `{"type":"webrtc"}` -> `/api/ws` | +| yuv4mpegpipe | http | rawvideo | | `GET /api/stream.y4m` | + +- **pcm** - pcm_alaw pcm_mulaw pcm_s16be pcm_s16le + +## Snapshots + +| Format | Protocol | Send codecs | Example | +|--------|----------|-------------|-----------------------| +| jpeg | http | mjpeg | `GET /api/frame.jpeg` | +| mp4 | http | h264,hevc | `GET /api/frame.mp4` | + +## Developers + +File naming: + +- `pkg/{format}/producer.go` - producer for this format (also if support backchannel) +- `pkg/{format}/consumer.go` - consumer for this format +- `pkg/{format}/backchanel.go` - producer with only backchannel func + ## Useful links - https://www.wowza.com/blog/streaming-protocols diff --git a/pkg/aac/aac.go b/pkg/aac/aac.go index c991431d..5ce4e82d 100644 --- a/pkg/aac/aac.go +++ b/pkg/aac/aac.go @@ -69,6 +69,8 @@ func DecodeConfig(b []byte) (objType, sampleFreqIdx, channels byte, sampleRate u sampleFreqIdx = rd.ReadBits8(4) if sampleFreqIdx == 0b1111 { sampleRate = rd.ReadBits(24) + } else { + sampleRate = sampleRates[sampleFreqIdx] } channels = rd.ReadBits8(4) diff --git a/pkg/aac/aac_test.go b/pkg/aac/aac_test.go index 08d9c436..d1af6e52 100644 --- a/pkg/aac/aac_test.go +++ b/pkg/aac/aac_test.go @@ -41,3 +41,12 @@ func TestADTS(t *testing.T) { require.Equal(t, src[:len(dst)], dst) } + +func TestEncodeConfig(t *testing.T) { + conf := EncodeConfig(TypeAACLC, 48000, 1, false) + require.Equal(t, "1188", hex.EncodeToString(conf)) + conf = EncodeConfig(TypeAACLC, 16000, 1, false) + require.Equal(t, "1408", hex.EncodeToString(conf)) + conf = EncodeConfig(TypeAACLC, 8000, 1, false) + require.Equal(t, "1588", hex.EncodeToString(conf)) +} diff --git a/pkg/aac/consumer.go b/pkg/aac/consumer.go index e785adc5..fc67d2a4 100644 --- a/pkg/aac/consumer.go +++ b/pkg/aac/consumer.go @@ -8,15 +8,12 @@ import ( ) type Consumer struct { - core.SuperConsumer + core.Connection wr *core.WriteBuffer } func NewConsumer() *Consumer { - cons := &Consumer{ - wr: core.NewWriteBuffer(nil), - } - cons.Medias = []*core.Media{ + medias := []*core.Media{ { Kind: core.KindAudio, Direction: core.DirectionSendonly, @@ -25,7 +22,16 @@ func NewConsumer() *Consumer { }, }, } - return cons + wr := core.NewWriteBuffer(nil) + return &Consumer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "adts", + Medias: medias, + Transport: wr, + }, + wr: wr, + } } func (c *Consumer) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { @@ -51,8 +57,3 @@ func (c *Consumer) AddTrack(media *core.Media, codec *core.Codec, track *core.Re func (c *Consumer) WriteTo(wr io.Writer) (int64, error) { return c.wr.WriteTo(wr) } - -func (c *Consumer) Stop() error { - _ = c.SuperConsumer.Close() - return c.wr.Close() -} diff --git a/pkg/aac/producer.go b/pkg/aac/producer.go index e9be71fd..efd2d175 100644 --- a/pkg/aac/producer.go +++ b/pkg/aac/producer.go @@ -10,9 +10,8 @@ import ( ) type Producer struct { - core.SuperProducer + core.Connection rd *bufio.Reader - cl io.Closer } func Open(r io.Reader) (*Producer, error) { @@ -23,18 +22,22 @@ func Open(r io.Reader) (*Producer, error) { return nil, err } - codec := ADTSToCodec(b) - - prod := &Producer{rd: rd, cl: r.(io.Closer)} - prod.Type = "ADTS producer" - prod.Medias = []*core.Media{ + medias := []*core.Media{ { Kind: core.KindAudio, Direction: core.DirectionRecvonly, - Codecs: []*core.Codec{codec}, + Codecs: []*core.Codec{ADTSToCodec(b)}, }, } - return prod, nil + return &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "adts", + Medias: medias, + Transport: r, + }, + rd: rd, + }, nil } func (c *Producer) Start() error { @@ -66,8 +69,3 @@ func (c *Producer) Start() error { c.Receivers[0].WriteRTP(pkt) } } - -func (c *Producer) Stop() error { - _ = c.SuperProducer.Close() - return c.cl.Close() -} diff --git a/pkg/ascii/README.md b/pkg/ascii/README.md new file mode 100644 index 00000000..bc9ef9dd --- /dev/null +++ b/pkg/ascii/README.md @@ -0,0 +1,6 @@ +## Useful links + +- https://en.wikipedia.org/wiki/ANSI_escape_code +- https://paulbourke.net/dataformats/asciiart/ +- https://github.com/kutuluk/xterm-color-chart +- https://github.com/hugomd/parrot.live diff --git a/pkg/ascii/ascii.go b/pkg/ascii/ascii.go new file mode 100644 index 00000000..6636e278 --- /dev/null +++ b/pkg/ascii/ascii.go @@ -0,0 +1,173 @@ +package ascii + +import ( + "bytes" + "fmt" + "image/jpeg" + "io" + "net/http" + "unicode/utf8" +) + +func NewWriter(w io.Writer, foreground, background, text string) io.Writer { + // once clear screen + _, _ = w.Write([]byte(csiClear)) + + // every frame - move to home + a := &writer{wr: w, buf: []byte(csiHome)} + + // https://en.wikipedia.org/wiki/ANSI_escape_code + switch foreground { + case "": + case "8": + a.color = func(r, g, b uint8) { + idx := xterm256color(r, g, b, 8) + a.appendEsc(fmt.Sprintf("\033[%dm", 30+idx)) + + } + case "256": + a.color = func(r, g, b uint8) { + idx := xterm256color(r, g, b, 255) + a.appendEsc(fmt.Sprintf("\033[38;5;%dm", idx)) + } + case "rgb": + a.color = func(r, g, b uint8) { + a.appendEsc(fmt.Sprintf("\033[38;2;%d;%d;%dm", r, g, b)) + } + default: + a.buf = append(a.buf, "\033["+foreground+"m"...) + } + + switch background { + case "": + case "8": + a.color = func(r, g, b uint8) { + idx := xterm256color(r, g, b, 8) + a.appendEsc(fmt.Sprintf("\033[%dm", 40+idx)) + } + case "256": + a.color = func(r, g, b uint8) { + idx := xterm256color(r, g, b, 255) + a.appendEsc(fmt.Sprintf("\033[48;5;%dm", idx)) + } + case "rgb": + a.color = func(r, g, b uint8) { + a.appendEsc(fmt.Sprintf("\033[48;2;%d;%d;%dm", r, g, b)) + } + default: + a.buf = append(a.buf, "\033["+background+"m"...) + } + + a.pre = len(a.buf) // save prefix size + + if len(text) == 1 { + // fast 1 symbol version + a.text = func(_, _, _ uint32) { + a.buf = append(a.buf, text[0]) + } + } else { + switch text { + case "": + text = ` .::--~~==++**##%%$@` // default for empty text + case "block": + text = " â–‘â–‘â–’â–’â–“â–“â–ˆ" // https://en.wikipedia.org/wiki/Block_Elements + } + + if runes := []rune(text); len(runes) != len(text) { + k := float32(len(runes)-1) / 255 + a.text = func(r, g, b uint32) { + i := gray(r, g, b, k) + a.buf = utf8.AppendRune(a.buf, runes[i]) + } + } else { + k := float32(len(text)-1) / 255 + a.text = func(r, g, b uint32) { + i := gray(r, g, b, k) + a.buf = append(a.buf, text[i]) + } + } + } + + return a +} + +type writer struct { + wr io.Writer + buf []byte + pre int + esc string + color func(r, g, b uint8) + text func(r, g, b uint32) +} + +// https://stackoverflow.com/questions/37774983/clearing-the-screen-by-printing-a-character +const csiClear = "\033[2J" +const csiHome = "\033[H" + +func (a *writer) Write(p []byte) (n int, err error) { + img, err := jpeg.Decode(bytes.NewReader(p)) + if err != nil { + return 0, err + } + + a.buf = a.buf[:a.pre] // restore prefix + + w := img.Bounds().Dx() + h := img.Bounds().Dy() + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + r, g, b, _ := img.At(x, y).RGBA() + if a.color != nil { + a.color(uint8(r>>8), uint8(g>>8), uint8(b>>8)) + } + a.text(r, g, b) + } + a.buf = append(a.buf, '\n') + } + + a.appendEsc("\033[0m") + + if _, err = a.wr.Write(a.buf); err != nil { + return 0, err + } + + a.wr.(http.Flusher).Flush() + + return len(p), nil +} + +// appendEsc - append ESC code to buffer, and skip duplicates +func (a *writer) appendEsc(s string) { + if a.esc != s { + a.esc = s + a.buf = append(a.buf, s...) + } +} + +func gray(r, g, b uint32, k float32) uint8 { + gr := (19595*r + 38470*g + 7471*b + 1<<15) >> 24 // uint8 + return uint8(float32(gr) * k) +} + +const x256r = "\x00\x80\x00\x80\x00\x80\x00\xc0\x80\xff\x00\xff\x00\xff\x00\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x08\x12\x1c\x26\x30\x3a\x44\x4e\x58\x60\x66\x76\x80\x8a\x94\x9e\xa8\xb2\xbc\xc6\xd0\xda\xe4\xee" +const x256g = "\x00\x00\x80\x80\x00\x00\x80\xc0\x80\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x08\x12\x1c\x26\x30\x3a\x44\x4e\x58\x60\x66\x76\x80\x8a\x94\x9e\xa8\xb2\xbc\xc6\xd0\xda\xe4\xee" +const x256b = "\x00\x00\x00\x00\x80\x80\x80\xc0\x80\x00\x00\x00\xff\xff\xff\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x08\x12\x1c\x26\x30\x3a\x44\x4e\x58\x60\x66\x76\x80\x8a\x94\x9e\xa8\xb2\xbc\xc6\xd0\xda\xe4\xee" + +func xterm256color(r, g, b uint8, n int) (index uint8) { + best := uint16(0xFFFF) + for i := 0; i < n; i++ { + diff := sqDiff(r, x256r[i]) + sqDiff(g, x256g[i]) + sqDiff(b, x256b[i]) + if diff < best { + best = diff + index = uint8(i) + } + } + return +} + +// sqDiff - just like from image/color/color.go +func sqDiff(x, y uint8) uint16 { + d := uint16(x - y) + //return d + return (d * d) >> 2 +} diff --git a/pkg/bits/reader.go b/pkg/bits/reader.go index 10ea2253..31ea9ef8 100644 --- a/pkg/bits/reader.go +++ b/pkg/bits/reader.go @@ -131,3 +131,7 @@ func (r *Reader) ReadSEGolomb() int32 { func (r *Reader) Left() []byte { return r.buf[r.pos:] } + +func (r *Reader) Pos() (int, byte) { + return r.pos - 1, r.bits +} diff --git a/pkg/bubble/client.go b/pkg/bubble/client.go index b8b77ae9..5afba779 100644 --- a/pkg/bubble/client.go +++ b/pkg/bubble/client.go @@ -22,6 +22,7 @@ import ( "github.com/pion/rtp" ) +// Deprecated: should be rewritten to core.Connection type Client struct { core.Listener @@ -43,8 +44,12 @@ type Client struct { recv int } -func NewClient(url string) *Client { - return &Client{url: url} +func Dial(rawURL string) (*Client, error) { + client := &Client{url: rawURL} + if err := client.Dial(); err != nil { + return nil, err + } + return client, nil } const ( diff --git a/pkg/bubble/producer.go b/pkg/bubble/producer.go index a7aaa56e..9fa18f25 100644 --- a/pkg/bubble/producer.go +++ b/pkg/bubble/producer.go @@ -65,11 +65,16 @@ func (c *Client) Stop() error { } func (c *Client) MarshalJSON() ([]byte, error) { - info := &core.Info{ - Type: "Bubble active producer", - Medias: c.medias, - Recv: c.recv, - Receivers: c.receivers, + info := &core.Connection{ + ID: core.ID(c), + FormatName: "bubble", + Protocol: "http", + Medias: c.medias, + Recv: c.recv, + Receivers: c.receivers, + } + if c.conn != nil { + info.RemoteAddr = c.conn.RemoteAddr().String() } return json.Marshal(info) } diff --git a/pkg/core/codec.go b/pkg/core/codec.go index f38d7965..9c6c6b79 100644 --- a/pkg/core/codec.go +++ b/pkg/core/codec.go @@ -2,8 +2,8 @@ package core import ( "encoding/base64" + "encoding/json" "fmt" - "strconv" "strings" "unicode" @@ -18,34 +18,76 @@ type Codec struct { PayloadType uint8 } -func (c *Codec) String() string { - s := fmt.Sprintf("%d %s", c.PayloadType, c.Name) +// MarshalJSON - return FFprobe compatible output +func (c *Codec) MarshalJSON() ([]byte, error) { + info := map[string]any{} + if name := FFmpegCodecName(c.Name); name != "" { + info["codec_name"] = name + info["codec_type"] = c.Kind() + } + if c.Name == CodecH264 { + profile, level := DecodeH264(c.FmtpLine) + if profile != "" { + info["profile"] = profile + info["level"] = level + } + } if c.ClockRate != 0 && c.ClockRate != 90000 { - s = fmt.Sprintf("%s/%d", s, c.ClockRate) + info["sample_rate"] = c.ClockRate } if c.Channels > 0 { - s = fmt.Sprintf("%s/%d", s, c.Channels) + info["channels"] = c.Channels } - return s + return json.Marshal(info) } -func (c *Codec) Text() string { - switch c.Name { +func FFmpegCodecName(name string) string { + switch name { case CodecH264: - if profile := DecodeH264(c.FmtpLine); profile != "" { - return "H.264 " + profile - } - return c.Name + return "h264" + case CodecH265: + return "hevc" + case CodecJPEG: + return "mjpeg" + case CodecRAW: + return "rawvideo" + case CodecPCMA: + return "pcm_alaw" + case CodecPCMU: + return "pcm_mulaw" + case CodecPCM: + return "pcm_s16be" + case CodecPCML: + return "pcm_s16le" + case CodecAAC: + return "aac" + case CodecOpus: + return "opus" + case CodecVP8: + return "vp8" + case CodecVP9: + return "vp9" + case CodecAV1: + return "av1" + case CodecELD: + return "aac/eld" + case CodecFLAC: + return "flac" + case CodecMP3: + return "mp3" } + return name +} - s := c.Name +func (c *Codec) String() (s string) { + s = c.Name if c.ClockRate != 0 && c.ClockRate != 90000 { - s += "/" + strconv.Itoa(int(c.ClockRate)) + s += fmt.Sprintf("/%d", c.ClockRate) } if c.Channels > 0 { - s += "/" + strconv.Itoa(int(c.Channels)) + s += fmt.Sprintf("/%d", c.Channels) } - return s + return } func (c *Codec) IsRTP() bool { @@ -181,10 +223,9 @@ func UnmarshalCodec(md *sdp.MediaDescription, payloadType string) *Codec { return c } -func DecodeH264(fmtp string) string { +func DecodeH264(fmtp string) (profile string, level byte) { if ps := Between(fmtp, "sprop-parameter-sets=", ","); ps != "" { if sps, _ := base64.StdEncoding.DecodeString(ps); len(sps) >= 4 { - var profile string switch sps[1] { case 0x42: profile = "Baseline" @@ -198,8 +239,8 @@ func DecodeH264(fmtp string) string { profile = fmt.Sprintf("0x%02X", sps[1]) } - return fmt.Sprintf("%s %d.%d", profile, sps[3]/10, sps[3]%10) + level = sps[3] } } - return "" + return } diff --git a/pkg/core/connection.go b/pkg/core/connection.go new file mode 100644 index 00000000..2c3f2196 --- /dev/null +++ b/pkg/core/connection.go @@ -0,0 +1,139 @@ +package core + +import ( + "io" + "net/http" + "reflect" + "sync/atomic" +) + +func NewID() uint32 { + return id.Add(1) +} + +// Deprecated: use NewID instead +func ID(v any) uint32 { + p := uintptr(reflect.ValueOf(v).UnsafePointer()) + return 0x8000_0000 | uint32(p) +} + +var id atomic.Uint32 + +type Info interface { + SetProtocol(string) + SetRemoteAddr(string) + SetSource(string) + SetURL(string) + WithRequest(*http.Request) +} + +// Connection just like webrtc.PeerConnection +// - ID and RemoteAddr used for building Connection(s) graph +// - FormatName, Protocol, RemoteAddr, Source, URL, SDP, UserAgent used for info about Connection +// - FormatName and Protocol has FFmpeg compatible names +// - Transport used for auto closing on Stop +type Connection struct { + ID uint32 `json:"id,omitempty"` + FormatName string `json:"format_name,omitempty"` // rtsp, webrtc, mp4, mjpeg, mpjpeg... + Protocol string `json:"protocol,omitempty"` // tcp, udp, http, ws, pipe... + RemoteAddr string `json:"remote_addr,omitempty"` // host:port other info + Source string `json:"source,omitempty"` + URL string `json:"url,omitempty"` + SDP string `json:"sdp,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + + Medias []*Media `json:"medias,omitempty"` + Receivers []*Receiver `json:"receivers,omitempty"` + Senders []*Sender `json:"senders,omitempty"` + Recv int `json:"bytes_recv,omitempty"` + Send int `json:"bytes_send,omitempty"` + + Transport any `json:"-"` +} + +func (c *Connection) GetMedias() []*Media { + return c.Medias +} + +func (c *Connection) GetTrack(media *Media, codec *Codec) (*Receiver, error) { + for _, receiver := range c.Receivers { + if receiver.Codec == codec { + return receiver, nil + } + } + receiver := NewReceiver(media, codec) + c.Receivers = append(c.Receivers, receiver) + return receiver, nil +} + +func (c *Connection) Stop() error { + for _, receiver := range c.Receivers { + receiver.Close() + } + for _, sender := range c.Senders { + sender.Close() + } + if closer, ok := c.Transport.(io.Closer); ok { + return closer.Close() + } + return nil +} + +// Deprecated: +func (c *Connection) Codecs() []*Codec { + codecs := make([]*Codec, len(c.Senders)) + for i, sender := range c.Senders { + codecs[i] = sender.Codec + } + return codecs +} + +func (c *Connection) SetProtocol(s string) { + c.Protocol = s +} + +func (c *Connection) SetRemoteAddr(s string) { + if c.RemoteAddr == "" { + c.RemoteAddr = s + } else { + c.RemoteAddr += " forwarded " + s + } +} + +func (c *Connection) SetSource(s string) { + c.Source = s +} + +func (c *Connection) SetURL(s string) { + c.URL = s +} + +func (c *Connection) WithRequest(r *http.Request) { + if r.Header.Get("Upgrade") == "websocket" { + c.Protocol = "ws" + } else { + c.Protocol = "http" + } + + c.RemoteAddr = r.RemoteAddr + if remote := r.Header.Get("X-Forwarded-For"); remote != "" { + c.RemoteAddr += " forwarded " + remote + } + + c.UserAgent = r.UserAgent() +} + +// Create like os.Create, init Consumer with existing Transport +func Create(w io.Writer) (*Connection, error) { + return &Connection{Transport: w}, nil +} + +// Open like os.Open, init Producer from existing Transport +func Open(r io.Reader) (*Connection, error) { + return &Connection{Transport: r}, nil +} + +// Dial like net.Dial, init Producer via Dialing +func Dial(rawURL string) (*Connection, error) { + return &Connection{}, nil +} diff --git a/pkg/core/core.go b/pkg/core/core.go index 146533e3..9555ecfa 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -1,5 +1,7 @@ package core +import "encoding/json" + const ( DirectionRecvonly = "recvonly" DirectionSendonly = "sendonly" @@ -18,6 +20,7 @@ const ( CodecVP9 = "VP9" CodecAV1 = "AV1" CodecJPEG = "JPEG" // payloadType: 26 + CodecRAW = "RAW" CodecPCMU = "PCMU" // payloadType: 0 CodecPCMA = "PCMA" // payloadType: 8 @@ -89,89 +92,6 @@ func (m Mode) String() string { return "unknown" } -type Info struct { - Type string `json:"type,omitempty"` - URL string `json:"url,omitempty"` - RemoteAddr string `json:"remote_addr,omitempty"` - UserAgent string `json:"user_agent,omitempty"` - SDP string `json:"sdp,omitempty"` - Medias []*Media `json:"medias,omitempty"` - Receivers []*Receiver `json:"receivers,omitempty"` - Senders []*Sender `json:"senders,omitempty"` - Recv int `json:"recv,omitempty"` - Send int `json:"send,omitempty"` -} - -const ( - UnsupportedCodec = "unsupported codec" - WrongMediaDirection = "wrong media direction" -) - -type SuperProducer struct { - Type string `json:"type,omitempty"` - URL string `json:"url,omitempty"` - SDP string `json:"sdp,omitempty"` - Medias []*Media `json:"medias,omitempty"` - Receivers []*Receiver `json:"receivers,omitempty"` - Recv int `json:"recv,omitempty"` -} - -func (s *SuperProducer) GetMedias() []*Media { - return s.Medias -} - -func (s *SuperProducer) GetTrack(media *Media, codec *Codec) (*Receiver, error) { - for _, receiver := range s.Receivers { - if receiver.Codec == codec { - return receiver, nil - } - } - receiver := NewReceiver(media, codec) - s.Receivers = append(s.Receivers, receiver) - return receiver, nil -} - -func (s *SuperProducer) Close() error { - for _, receiver := range s.Receivers { - receiver.Close() - } - return nil -} - -type SuperConsumer struct { - Type string `json:"type,omitempty"` - URL string `json:"url,omitempty"` - RemoteAddr string `json:"remote_addr,omitempty"` - UserAgent string `json:"user_agent,omitempty"` - SDP string `json:"sdp,omitempty"` - Medias []*Media `json:"medias,omitempty"` - Senders []*Sender `json:"senders,omitempty"` - Send int `json:"send,omitempty"` -} - -func (s *SuperConsumer) GetMedias() []*Media { - return s.Medias -} - -func (s *SuperConsumer) AddTrack(media *Media, codec *Codec, track *Receiver) error { - return nil -} - -//func (b *SuperConsumer) WriteTo(w io.Writer) (n int64, err error) { -// return 0, nil -//} - -func (s *SuperConsumer) Close() error { - for _, sender := range s.Senders { - sender.Close() - } - return nil -} - -func (s *SuperConsumer) Codecs() []*Codec { - codecs := make([]*Codec, len(s.Senders)) - for i, sender := range s.Senders { - codecs[i] = sender.Codec - } - return codecs +func (m Mode) MarshalJSON() ([]byte, error) { + return json.Marshal(m.String()) } diff --git a/pkg/core/core_test.go b/pkg/core/core_test.go new file mode 100644 index 00000000..4a05380a --- /dev/null +++ b/pkg/core/core_test.go @@ -0,0 +1,120 @@ +package core + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +type producer struct { + Medias []*Media + Receivers []*Receiver + + id byte +} + +func (p *producer) GetMedias() []*Media { + return p.Medias +} + +func (p *producer) GetTrack(_ *Media, codec *Codec) (*Receiver, error) { + for _, receiver := range p.Receivers { + if receiver.Codec == codec { + return receiver, nil + } + } + receiver := NewReceiver(nil, codec) + p.Receivers = append(p.Receivers, receiver) + return receiver, nil +} + +func (p *producer) Start() error { + pkt := &Packet{Payload: []byte{p.id}} + p.Receivers[0].Input(pkt) + return nil +} + +func (p *producer) Stop() error { + for _, receiver := range p.Receivers { + receiver.Close() + } + return nil +} + +type consumer struct { + Medias []*Media + Senders []*Sender + + cache chan byte +} + +func (c *consumer) GetMedias() []*Media { + return c.Medias +} + +func (c *consumer) AddTrack(_ *Media, _ *Codec, track *Receiver) error { + c.cache = make(chan byte, 1) + sender := NewSender(nil, track.Codec) + sender.Output = func(packet *Packet) { + c.cache <- packet.Payload[0] + } + sender.HandleRTP(track) + c.Senders = append(c.Senders, sender) + return nil +} + +func (c *consumer) Stop() error { + for _, sender := range c.Senders { + sender.Close() + } + return nil +} + +func (c *consumer) read() byte { + return <-c.cache +} + +func TestName(t *testing.T) { + GetProducer := func(b byte) Producer { + return &producer{ + Medias: []*Media{ + { + Kind: KindVideo, + Direction: DirectionRecvonly, + Codecs: []*Codec{ + {Name: CodecH264}, + }, + }, + }, + id: b, + } + } + + // stage1 + prod1 := GetProducer(1) + cons2 := &consumer{} + + media1 := prod1.GetMedias()[0] + track1, _ := prod1.GetTrack(media1, media1.Codecs[0]) + + _ = cons2.AddTrack(nil, nil, track1) + + _ = prod1.Start() + require.Equal(t, byte(1), cons2.read()) + + // stage2 + prod2 := GetProducer(2) + media2 := prod2.GetMedias()[0] + require.NotEqual(t, fmt.Sprintf("%p", media1), fmt.Sprintf("%p", media2)) + track2, _ := prod2.GetTrack(media2, media2.Codecs[0]) + track1.Replace(track2) + + _ = prod1.Stop() + + _ = prod2.Start() + require.Equal(t, byte(2), cons2.read()) + + // stage3 + _ = prod2.Stop() +} diff --git a/pkg/core/helpers.go b/pkg/core/helpers.go index 0c367e2c..72afe897 100644 --- a/pkg/core/helpers.go +++ b/pkg/core/helpers.go @@ -38,6 +38,13 @@ func RandString(size, base byte) string { return string(b) } +func Before(s, sep string) string { + if i := strings.Index(s, sep); i > 0 { + return s[:i] + } + return s +} + func Between(s, sub1, sub2 string) string { i := strings.Index(s, sub1) if i < 0 { diff --git a/pkg/core/media.go b/pkg/core/media.go index fe58cfd6..2284d0cd 100644 --- a/pkg/core/media.go +++ b/pkg/core/media.go @@ -22,7 +22,7 @@ type Media struct { func (m *Media) String() string { s := fmt.Sprintf("%s, %s", m.Kind, m.Direction) for _, codec := range m.Codecs { - name := codec.Text() + name := codec.String() if strings.Contains(s, name) { continue @@ -92,7 +92,7 @@ func (m *Media) Equal(media *Media) bool { func GetKind(name string) string { switch name { - case CodecH264, CodecH265, CodecVP8, CodecVP9, CodecAV1, CodecJPEG: + case CodecH264, CodecH265, CodecVP8, CodecVP9, CodecAV1, CodecJPEG, CodecRAW: return KindVideo case CodecPCMU, CodecPCMA, CodecAAC, CodecOpus, CodecG722, CodecMP3, CodecPCM, CodecPCML, CodecELD, CodecFLAC: return KindAudio diff --git a/pkg/core/node.go b/pkg/core/node.go new file mode 100644 index 00000000..a9959c3d --- /dev/null +++ b/pkg/core/node.go @@ -0,0 +1,88 @@ +package core + +import ( + "sync" + + "github.com/pion/rtp" +) + +//type Packet struct { +// Payload []byte +// Timestamp uint32 // PTS if DTS == 0 else DTS +// Composition uint32 // CTS = PTS-DTS (for support B-frames) +// Sequence uint16 +//} + +type Packet = rtp.Packet + +// HandlerFunc - process input packets (just like http.HandlerFunc) +type HandlerFunc func(packet *Packet) + +// Filter - a decorator for any HandlerFunc +type Filter func(handler HandlerFunc) HandlerFunc + +// Node - Receiver or Sender or Filter (transform) +type Node struct { + Codec *Codec + Input HandlerFunc + Output HandlerFunc + + id uint32 + childs []*Node + parent *Node + + mu sync.Mutex +} + +func (n *Node) WithParent(parent *Node) *Node { + parent.AppendChild(n) + return n +} + +func (n *Node) AppendChild(child *Node) { + n.mu.Lock() + n.childs = append(n.childs, child) + n.mu.Unlock() + + child.parent = n +} + +func (n *Node) RemoveChild(child *Node) { + n.mu.Lock() + for i, ch := range n.childs { + if ch == child { + n.childs = append(n.childs[:i], n.childs[i+1:]...) + break + } + } + n.mu.Unlock() +} + +func (n *Node) Close() { + if parent := n.parent; parent != nil { + parent.RemoveChild(n) + + if len(parent.childs) == 0 { + parent.Close() + } + } else { + for _, childs := range n.childs { + childs.Close() + } + } +} + +func MoveNode(dst, src *Node) { + src.mu.Lock() + childs := src.childs + src.childs = nil + src.mu.Unlock() + + dst.mu.Lock() + dst.childs = childs + dst.mu.Unlock() + + for _, child := range childs { + child.parent = dst + } +} diff --git a/pkg/core/track.go b/pkg/core/track.go index 1faae309..8bc65374 100644 --- a/pkg/core/track.go +++ b/pkg/core/track.go @@ -3,201 +3,212 @@ package core import ( "encoding/json" "errors" - "fmt" - "strconv" - "sync" "github.com/pion/rtp" ) -type Packet struct { - PayloadType uint8 - Sequence uint16 - Timestamp uint32 // PTS if DTS == 0 else DTS - Composition uint32 // CTS = PTS-DTS (for support B-frames) - Payload []byte -} - var ErrCantGetTrack = errors.New("can't get track") type Receiver struct { - Codec *Codec - Media *Media + Node - ID byte // Channel for RTSP, PayloadType for MPEG-TS + // Deprecated: should be removed + Media *Media `json:"-"` + // Deprecated: should be removed + ID byte `json:"-"` // Channel for RTSP, PayloadType for MPEG-TS - senders map[*Sender]chan *rtp.Packet - mu sync.RWMutex - bytes int + Bytes int `json:"bytes,omitempty"` + Packets int `json:"packets,omitempty"` } func NewReceiver(media *Media, codec *Codec) *Receiver { - Assert(codec != nil) - return &Receiver{Codec: codec, Media: media} -} - -// WriteRTP - fast and non blocking write to all readers buffers -func (t *Receiver) WriteRTP(packet *rtp.Packet) { - t.mu.Lock() - t.bytes += len(packet.Payload) - for sender, buffer := range t.senders { - select { - case buffer <- packet: - default: - sender.overflow++ + r := &Receiver{ + Node: Node{id: NewID(), Codec: codec}, + Media: media, + } + r.Input = func(packet *Packet) { + r.Bytes += len(packet.Payload) + r.Packets++ + for _, child := range r.childs { + child.Input(packet) } } - t.mu.Unlock() + return r } -func (t *Receiver) Senders() (senders []*Sender) { - t.mu.RLock() - for sender := range t.senders { - senders = append(senders, sender) +// Deprecated: should be removed +func (r *Receiver) WriteRTP(packet *rtp.Packet) { + r.Input(packet) +} + +// Deprecated: should be removed +func (r *Receiver) Senders() []*Sender { + if len(r.childs) > 0 { + return []*Sender{{}} + } else { + return nil } - t.mu.RUnlock() - return } -func (t *Receiver) Close() { - t.mu.Lock() - // close all sender channel buffers and erase senders list - for _, buffer := range t.senders { - close(buffer) - } - t.senders = nil - t.mu.Unlock() +// Deprecated: should be removed +func (r *Receiver) Replace(target *Receiver) { + MoveNode(&target.Node, &r.Node) } -func (t *Receiver) Replace(target *Receiver) { - // move this receiver senders to new receiver - t.mu.Lock() - senders := t.senders - t.mu.Unlock() - - target.mu.Lock() - target.senders = senders - target.mu.Unlock() -} - -func (t *Receiver) String() string { - s := t.Codec.String() + ", bytes=" + strconv.Itoa(t.bytes) - t.mu.RLock() - s += fmt.Sprintf(", senders=%d", len(t.senders)) - t.mu.RUnlock() - return s -} - -func (t *Receiver) MarshalJSON() ([]byte, error) { - return json.Marshal(t.String()) +func (r *Receiver) Close() { + r.Node.Close() } type Sender struct { - Codec *Codec - Media *Media + Node - Handler HandlerFunc + // Deprecated: + Media *Media `json:"-"` + // Deprecated: + Handler HandlerFunc `json:"-"` - receivers []*Receiver - mu sync.RWMutex - bytes int + Bytes int `json:"bytes,omitempty"` + Packets int `json:"packets,omitempty"` + Drops int `json:"drops,omitempty"` - overflow int + buf chan *Packet + done chan struct{} } func NewSender(media *Media, codec *Codec) *Sender { - return &Sender{Codec: codec, Media: media} -} + var bufSize uint16 -// HandlerFunc like http.HandlerFunc -type HandlerFunc func(packet *rtp.Packet) - -func (s *Sender) HandleRTP(track *Receiver) { - bufferSize := 100 - - if GetKind(track.Codec.Name) == KindVideo { - if track.Codec.IsRTP() { + if GetKind(codec.Name) == KindVideo { + if codec.IsRTP() { // in my tests 40Mbit/s 4K-video can generate up to 1500 items // for the h264.RTPDepay => RTPPay queue - bufferSize = 5000 + bufSize = 4096 } else { - bufferSize = 50 + bufSize = 64 } + } else { + bufSize = 128 } - buffer := make(chan *rtp.Packet, bufferSize) - - track.mu.Lock() - if track.senders == nil { - track.senders = map[*Sender]chan *rtp.Packet{} + buf := make(chan *Packet, bufSize) + s := &Sender{ + Node: Node{id: NewID(), Codec: codec}, + Media: media, + buf: buf, } - track.senders[s] = buffer - track.mu.Unlock() - s.mu.Lock() - s.receivers = append(s.receivers, track) - s.mu.Unlock() - - go func() { - // read packets from buffer channel until it will be closed - for packet := range buffer { - s.bytes += len(packet.Payload) - s.Handler(packet) - } - - // remove current receiver from list - // it can only happen when receiver close buffer channel + s.Input = func(packet *Packet) { + // writing to nil chan - OK, writing to closed chan - panic s.mu.Lock() - for i, receiver := range s.receivers { - if receiver == track { - s.receivers = append(s.receivers[:i], s.receivers[i+1:]...) - break - } + select { + case s.buf <- packet: + s.Bytes += len(packet.Payload) + s.Packets++ + default: + s.Drops++ } s.mu.Unlock() + } + s.Output = func(packet *Packet) { + s.Handler(packet) + } + return s +} + +// Deprecated: should be removed +func (s *Sender) HandleRTP(parent *Receiver) { + s.WithParent(parent) + s.Start() +} + +// Deprecated: should be removed +func (s *Sender) Bind(parent *Receiver) { + s.WithParent(parent) +} + +func (s *Sender) WithParent(parent *Receiver) *Sender { + s.Node.WithParent(&parent.Node) + return s +} + +func (s *Sender) Start() { + s.mu.Lock() + defer s.mu.Unlock() + + if s.buf == nil || s.done != nil { + return + } + s.done = make(chan struct{}) + + go func() { + for packet := range s.buf { + s.Output(packet) + } + close(s.done) }() } -func (s *Sender) Close() { - s.mu.Lock() - // remove this sender from all receivers list - for _, receiver := range s.receivers { - receiver.mu.Lock() - if buffer := receiver.senders[s]; buffer != nil { - // remove channel from list - delete(receiver.senders, s) - // close channel - close(buffer) - } - receiver.mu.Unlock() +func (s *Sender) Wait() { + if done := s.done; s.done != nil { + <-done } - s.receivers = nil - s.mu.Unlock() } -func (s *Sender) String() string { - info := s.Codec.String() + ", bytes=" + strconv.Itoa(s.bytes) - s.mu.RLock() - info += ", receivers=" + strconv.Itoa(len(s.receivers)) - s.mu.RUnlock() - if s.overflow > 0 { - info += ", overflow=" + strconv.Itoa(s.overflow) +func (s *Sender) State() string { + if s.buf == nil { + return "closed" } - return info + if s.done == nil { + return "new" + } + return "connected" +} + +func (s *Sender) Close() { + // close buffer if exists + if buf := s.buf; buf != nil { + s.buf = nil + defer close(buf) + } + + s.Node.Close() +} + +func (r *Receiver) MarshalJSON() ([]byte, error) { + v := struct { + ID uint32 `json:"id"` + Codec *Codec `json:"codec"` + Childs []uint32 `json:"childs,omitempty"` + Bytes int `json:"bytes,omitempty"` + Packets int `json:"packets,omitempty"` + }{ + ID: r.Node.id, + Codec: r.Node.Codec, + Bytes: r.Bytes, + Packets: r.Packets, + } + for _, child := range r.childs { + v.Childs = append(v.Childs, child.id) + } + return json.Marshal(v) } func (s *Sender) MarshalJSON() ([]byte, error) { - return json.Marshal(s.String()) -} - -// VA - helper, for extract video and audio receivers from list -func VA(receivers []*Receiver) (video, audio *Receiver) { - for _, receiver := range receivers { - switch GetKind(receiver.Codec.Name) { - case KindVideo: - video = receiver - case KindAudio: - audio = receiver - } + v := struct { + ID uint32 `json:"id"` + Codec *Codec `json:"codec"` + Parent uint32 `json:"parent,omitempty"` + Bytes int `json:"bytes,omitempty"` + Packets int `json:"packets,omitempty"` + Drops int `json:"drops,omitempty"` + }{ + ID: s.Node.id, + Codec: s.Node.Codec, + Bytes: s.Bytes, + Packets: s.Packets, + Drops: s.Drops, } - return + if s.parent != nil { + v.Parent = s.parent.id + } + return json.Marshal(v) } diff --git a/pkg/debug/debug.go b/pkg/debug/debug.go index 918cddfb..ff6ccce2 100644 --- a/pkg/debug/debug.go +++ b/pkg/debug/debug.go @@ -24,7 +24,7 @@ func Logger(include func(packet *rtp.Packet) bool) func(packet *rtp.Packet) { now := time.Now() fmt.Printf( - "%s: size:%6d, ts:%10d, type:%2d, ssrc:%d, seq:%5d, mark:%t, dts:%4d, dtime:%3d\n", + "%s: size=%6d ts=%10d type=%2d ssrc=%d seq=%5d mark=%t dts=%4d dtime=%3dms\n", now.Format("15:04:05.000"), len(packet.Payload), packet.Timestamp, packet.PayloadType, packet.SSRC, packet.SequenceNumber, packet.Marker, packet.Timestamp-lastTS, now.Sub(lastTime).Milliseconds(), @@ -41,7 +41,7 @@ func Logger(include func(packet *rtp.Packet) bool) func(packet *rtp.Packet) { if dt := now.Sub(secTime); dt > time.Second { fmt.Printf( - "%s: size:%6d, cnt:%d, dts: %d, dtime:%d\n", + "%s: size=%6d cnt=%d dts=%d dtime=%3dms\n", now.Format("15:04:05.000"), secSize, secCnt, lastTS-secTS, dt.Milliseconds(), ) diff --git a/pkg/dvrip/consumer.go b/pkg/dvrip/backchannel.go similarity index 78% rename from pkg/dvrip/consumer.go rename to pkg/dvrip/backchannel.go index 7652c079..0424e965 100644 --- a/pkg/dvrip/consumer.go +++ b/pkg/dvrip/backchannel.go @@ -8,16 +8,16 @@ import ( "github.com/pion/rtp" ) -type Consumer struct { - core.SuperConsumer +type Backchannel struct { + core.Connection client *Client } -func (c *Consumer) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { +func (c *Backchannel) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { return nil, core.ErrCantGetTrack } -func (c *Consumer) Start() error { +func (c *Backchannel) Start() error { if err := c.client.conn.SetReadDeadline(time.Time{}); err != nil { return err } @@ -30,12 +30,7 @@ func (c *Consumer) Start() error { } } -func (c *Consumer) Stop() error { - _ = c.SuperConsumer.Close() - return c.client.Close() -} - -func (c *Consumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { +func (c *Backchannel) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { if err := c.client.Talk(); err != nil { return err } diff --git a/pkg/dvrip/client.go b/pkg/dvrip/client.go index 345f291c..0b485450 100644 --- a/pkg/dvrip/client.go +++ b/pkg/dvrip/client.go @@ -114,7 +114,7 @@ func (c *Client) Play() error { } func (c *Client) Talk() error { - format := `{"Name":"OPTalk","SessionID":"0x%08X","OPTalk":{"Action":"%s"}}` + "\x0A\x00" + format := `{"Name":"OPTalk","SessionID":"0x%08X","OPTalk":{"Action":"%s","AudioFormat":{"EncodeType":"G711_ALAW"}}}` + "\x0A\x00" data := fmt.Sprintf(format, c.session, "Claim") if _, err := c.WriteCmd(OPTalkClaim, []byte(data)); err != nil { diff --git a/pkg/dvrip/dvrip.go b/pkg/dvrip/dvrip.go index 35823ccf..c4980a80 100644 --- a/pkg/dvrip/dvrip.go +++ b/pkg/dvrip/dvrip.go @@ -8,26 +8,32 @@ func Dial(url string) (core.Producer, error) { return nil, err } + conn := core.Connection{ + ID: core.NewID(), + FormatName: "dvrip", + Protocol: "tcp", + RemoteAddr: client.conn.RemoteAddr().String(), + Transport: client.conn, + } + if client.stream != "" { - prod := &Producer{client: client} - prod.Type = "DVRIP active producer" + prod := &Producer{Connection: conn, client: client} if err := prod.probe(); err != nil { return nil, err } return prod, nil } else { - cons := &Consumer{client: client} - cons.Type = "DVRIP active consumer" - cons.Medias = []*core.Media{ + conn.Medias = []*core.Media{ { Kind: core.KindAudio, Direction: core.DirectionSendonly, Codecs: []*core.Codec{ + // leave only one codec here for better compatibility with cameras + // https://github.com/AlexxIT/go2rtc/issues/1111 {Name: core.CodecPCMA, ClockRate: 8000, PayloadType: 8}, - {Name: core.CodecPCMU, ClockRate: 8000, PayloadType: 0}, }, }, } - return cons, nil + return &Backchannel{Connection: conn, client: client}, nil } } diff --git a/pkg/dvrip/producer.go b/pkg/dvrip/producer.go index 412dd0a3..c87017b4 100644 --- a/pkg/dvrip/producer.go +++ b/pkg/dvrip/producer.go @@ -15,7 +15,7 @@ import ( ) type Producer struct { - core.SuperProducer + core.Connection client *Client @@ -92,10 +92,6 @@ func (c *Producer) Start() error { } } -func (c *Producer) Stop() error { - return c.client.Close() -} - func (c *Producer) probe() error { if err := c.client.Play(); err != nil { return err diff --git a/pkg/ffmpeg/ffmpeg.go b/pkg/ffmpeg/ffmpeg.go index 912e7684..a7ca71c7 100644 --- a/pkg/ffmpeg/ffmpeg.go +++ b/pkg/ffmpeg/ffmpeg.go @@ -6,6 +6,15 @@ import ( "strings" ) +// correlation of libavformat versions with ffmpeg versions +const ( + Version50 = "59. 16" + Version51 = "59. 27" + Version60 = "60. 3" + Version61 = "60. 16" + Version70 = "61. 1" +) + type Args struct { Bin string // ffmpeg Global string // -hide_banner -v error @@ -13,6 +22,7 @@ type Args struct { Codecs []string // -c:v libx264 -g:v 30 -preset:v ultrafast -tune:v zerolatency Filters []string // scale=1920:1080 Output string // -f rtsp {output} + Version string // libavformat version, it's more reliable than the ffmpeg version Video, Audio int // count of Video and Audio params } @@ -52,6 +62,11 @@ func (a *Args) String() string { } b.WriteByte(' ') + // starting from FFmpeg 6.1 readrate=1 has default initial bust 0.5 sec + // it might make us miss the first couple seconds of the file + if strings.HasPrefix(a.Input, "-re ") && a.Version >= Version61 { + b.WriteString("-readrate_initial_burst 0.001 ") + } b.WriteString(a.Input) multimode := a.Video > 1 || a.Audio > 1 @@ -91,3 +106,18 @@ func (a *Args) String() string { return b.String() } + +func ParseVersion(b []byte) (ffmpeg string, libavformat string) { + if len(b) > 100 { + // ffmpeg version n7.0-30-g8b0fe91754-20240520 Copyright (c) 2000-2024 the FFmpeg developers + if i := bytes.IndexByte(b[15:], ' '); i > 0 { + ffmpeg = string(b[15 : 15+i]) + } + + // libavformat 60. 16.100 / 60. 16.100 + if i := strings.Index(string(b), "libavformat"); i > 0 { + libavformat = string(b[i+15 : i+25]) + } + } + return +} diff --git a/pkg/flv/amf/amf_test.go b/pkg/flv/amf/amf_test.go index f22308e6..81e506d8 100644 --- a/pkg/flv/amf/amf_test.go +++ b/pkg/flv/amf/amf_test.go @@ -137,6 +137,70 @@ func TestNewReader(t *testing.T) { }, }, }, + { + name: "mediamtx", + actual: "0200075f726573756c74003ff0000000000000030006666d7356657202000d4c4e5820392c302c3132342c32000c6361706162696c697469657300403f0000000000000000090300056c6576656c0200067374617475730004636f646502001d4e6574436f6e6e656374696f6e2e436f6e6e6563742e53756363657373000b6465736372697074696f6e020015436f6e6e656374696f6e207375636365656465642e000e6f626a656374456e636f64696e67000000000000000000000009", + expect: []any{ + "_result", float64(1), map[string]any{ + "capabilities": float64(31), + "fmsVer": "LNX 9,0,124,2", + }, map[string]any{ + "code": "NetConnection.Connect.Success", + "description": "Connection succeeded.", + "level": "status", + "objectEncoding": float64(0), + }, + }, + }, + { + name: "mediamtx", + actual: "0200075f726573756c7400401000000000000005003ff0000000000000", + expect: []any{"_result", float64(4), any(nil), float64(1)}, + }, + { + name: "mediamtx", + actual: "0200086f6e537461747573004014000000000000050300056c6576656c0200067374617475730004636f64650200144e657453747265616d2e506c61792e5265736574000b6465736372697074696f6e02000a706c6179207265736574000009", + expect: []any{ + "onStatus", float64(5), any(nil), map[string]any{ + "code": "NetStream.Play.Reset", + "description": "play reset", + "level": "status", + }, + }, + }, + { + name: "mediamtx", + actual: "0200086f6e537461747573004014000000000000050300056c6576656c0200067374617475730004636f64650200144e657453747265616d2e506c61792e5374617274000b6465736372697074696f6e02000a706c6179207374617274000009", + expect: []any{ + "onStatus", float64(5), any(nil), map[string]any{ + "code": "NetStream.Play.Start", + "description": "play start", + "level": "status", + }, + }, + }, + { + name: "mediamtx", + actual: "0200086f6e537461747573004014000000000000050300056c6576656c0200067374617475730004636f64650200144e657453747265616d2e446174612e5374617274000b6465736372697074696f6e02000a64617461207374617274000009", + expect: []any{ + "onStatus", float64(5), any(nil), map[string]any{ + "code": "NetStream.Data.Start", + "description": "data start", + "level": "status", + }, + }, + }, + { + name: "mediamtx", + actual: "0200086f6e537461747573004014000000000000050300056c6576656c0200067374617475730004636f646502001c4e657453747265616d2e506c61792e5075626c6973684e6f74696679000b6465736372697074696f6e02000e7075626c697368206e6f74696679000009", + expect: []any{ + "onStatus", float64(5), any(nil), map[string]any{ + "code": "NetStream.Play.PublishNotify", + "description": "publish notify", + "level": "status", + }, + }, + }, { name: "obs-connect", actual: "020007636f6e6e656374003ff000000000000003000361707002000c617070312f73747265616d3100047479706502000a6e6f6e70726976617465000e737570706f727473476f4177617901010008666c61736856657202001f464d4c452f332e302028636f6d70617469626c653b20464d53632f312e3029000673776655726c02002272746d703a2f2f3139322e3136382e31302e3130312f617070312f73747265616d310005746355726c02002272746d703a2f2f3139322e3136382e31302e3130312f617070312f73747265616d31000009", diff --git a/pkg/flv/consumer.go b/pkg/flv/consumer.go index 59e65d9c..fe966bfc 100644 --- a/pkg/flv/consumer.go +++ b/pkg/flv/consumer.go @@ -10,17 +10,13 @@ import ( ) type Consumer struct { - core.SuperConsumer + core.Connection wr *core.WriteBuffer muxer *Muxer } func NewConsumer() *Consumer { - c := &Consumer{ - wr: core.NewWriteBuffer(nil), - muxer: &Muxer{}, - } - c.Medias = []*core.Media{ + medias := []*core.Media{ { Kind: core.KindVideo, Direction: core.DirectionSendonly, @@ -36,7 +32,17 @@ func NewConsumer() *Consumer { }, }, } - return c + wr := core.NewWriteBuffer(nil) + return &Consumer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "flv", + Medias: medias, + Transport: wr, + }, + wr: wr, + muxer: &Muxer{}, + } } func (c *Consumer) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { @@ -86,8 +92,3 @@ func (c *Consumer) WriteTo(wr io.Writer) (int64, error) { } return c.wr.WriteTo(wr) } - -func (c *Consumer) Stop() error { - _ = c.SuperConsumer.Close() - return c.wr.Close() -} diff --git a/pkg/flv/muxer.go b/pkg/flv/muxer.go index 499f3aa0..98794265 100644 --- a/pkg/flv/muxer.go +++ b/pkg/flv/muxer.go @@ -54,6 +54,8 @@ func (m *Muxer) GetInit() []byte { sps, pps := h264.GetParameterSet(codec.FmtpLine) if len(sps) == 0 { sps = []byte{0x67, 0x42, 0x00, 0x0a, 0xf8, 0x41, 0xa2} + } else { + h264.FixPixFmt(sps) } if len(pps) == 0 { pps = []byte{0x68, 0xce, 0x38, 0x80} diff --git a/pkg/flv/producer.go b/pkg/flv/producer.go index 3972e666..66755217 100644 --- a/pkg/flv/producer.go +++ b/pkg/flv/producer.go @@ -15,18 +15,24 @@ import ( ) type Producer struct { - core.SuperProducer + core.Connection rd *core.ReadBuffer video, audio *core.Receiver } func Open(rd io.Reader) (*Producer, error) { - prod := &Producer{rd: core.NewReadBuffer(rd)} + prod := &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "flv", + Transport: rd, + }, + rd: core.NewReadBuffer(rd), + } if err := prod.probe(); err != nil { return nil, err } - prod.Type = "FLV producer" return prod, nil } @@ -57,7 +63,7 @@ const ( ) func (c *Producer) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { - receiver, _ := c.SuperProducer.GetTrack(media, codec) + receiver, _ := c.Connection.GetTrack(media, codec) if media.Kind == core.KindVideo { c.video = receiver } else { @@ -117,11 +123,6 @@ func (c *Producer) Start() error { } } -func (c *Producer) Stop() error { - _ = c.SuperProducer.Close() - return c.rd.Close() -} - func (c *Producer) probe() error { if err := c.readHeader(); err != nil { return err diff --git a/pkg/gopro/gopro.go b/pkg/gopro/producer.go similarity index 90% rename from pkg/gopro/gopro.go rename to pkg/gopro/producer.go index 2d6a098b..1873159f 100644 --- a/pkg/gopro/gopro.go +++ b/pkg/gopro/producer.go @@ -8,11 +8,10 @@ import ( "net/url" "time" - "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/mpegts" ) -func Dial(rawURL string) (core.Producer, error) { +func Dial(rawURL string) (*mpegts.Producer, error) { u, err := url.Parse(rawURL) if err != nil { return nil, err @@ -32,7 +31,15 @@ func Dial(rawURL string) (core.Producer, error) { return nil, err } - return mpegts.Open(r) + prod, err := mpegts.Open(r) + if err != nil { + return nil, err + } + + prod.FormatName = "gopro" + prod.RemoteAddr = u.Host + + return prod, nil } type listener struct { diff --git a/pkg/h264/sps.go b/pkg/h264/sps.go index 71ab5b45..6bcca669 100644 --- a/pkg/h264/sps.go +++ b/pkg/h264/sps.go @@ -1,6 +1,10 @@ package h264 -import "github.com/AlexxIT/go2rtc/pkg/bits" +import ( + "fmt" + + "github.com/AlexxIT/go2rtc/pkg/bits" +) // http://www.itu.int/rec/T-REC-H.264 // https://webrtc.googlesource.com/src/+/refs/heads/main/common_video/h264/sps_parser.cc @@ -229,3 +233,132 @@ func (s *SPS) scaling_list(r *bits.Reader, sizeOfScalingList int) { } } } + +func (s *SPS) Profile() string { + switch s.profile_idc { + case 0x42: + return "Baseline" + case 0x4D: + return "Main" + case 0x58: + return "Extended" + case 0x64: + return "High" + } + return fmt.Sprintf("0x%02X", s.profile_idc) +} + +func (s *SPS) PixFmt() string { + if s.bit_depth_luma_minus8 == 0 { + switch s.chroma_format_idc { + case 1: + if s.video_full_range_flag == 1 { + return "yuvj420p" + } + return "yuv420p" + case 2: + return "yuv422p" + case 3: + return "yuv444p" + } + } + return "" +} + +func (s *SPS) String() string { + return fmt.Sprintf( + "%s %d.%d, %s, %dx%d", + s.Profile(), s.level_idc/10, s.level_idc%10, s.PixFmt(), s.Width(), s.Height(), + ) +} + +// FixPixFmt - change yuvj420p to yuv420p in SPS +// same as "-c:v copy -bsf:v h264_metadata=video_full_range_flag=0" +func FixPixFmt(sps []byte) { + r := bits.NewReader(sps) + + _ = r.ReadByte() + + profile := r.ReadByte() + _ = r.ReadByte() + _ = r.ReadByte() + _ = r.ReadUEGolomb() + + switch profile { + case 100, 110, 122, 244, 44, 83, 86, 118, 128, 138, 139, 134, 135: + n := byte(8) + + if r.ReadUEGolomb() == 3 { + _ = r.ReadBit() + n = 12 + } + + _ = r.ReadUEGolomb() + _ = r.ReadUEGolomb() + _ = r.ReadBit() + + if r.ReadBit() != 0 { + for i := byte(0); i < n; i++ { + if r.ReadBit() != 0 { + return // skip + } + } + } + } + + _ = r.ReadUEGolomb() + + switch r.ReadUEGolomb() { + case 0: + _ = r.ReadUEGolomb() + case 1: + _ = r.ReadBit() + _ = r.ReadSEGolomb() + _ = r.ReadSEGolomb() + + n := r.ReadUEGolomb() + for i := uint32(0); i < n; i++ { + _ = r.ReadSEGolomb() + } + } + + _ = r.ReadUEGolomb() + _ = r.ReadBit() + + _ = r.ReadUEGolomb() + _ = r.ReadUEGolomb() + + if r.ReadBit() == 0 { + _ = r.ReadBit() + } + + _ = r.ReadBit() + + if r.ReadBit() != 0 { + _ = r.ReadUEGolomb() + _ = r.ReadUEGolomb() + _ = r.ReadUEGolomb() + _ = r.ReadUEGolomb() + } + + if r.ReadBit() != 0 { + if r.ReadBit() != 0 { + if r.ReadByte() == 255 { + _ = r.ReadUint16() + _ = r.ReadUint16() + } + } + + if r.ReadBit() != 0 { + _ = r.ReadBit() + } + + if r.ReadBit() != 0 { + _ = r.ReadBits8(3) + if r.ReadBit() == 1 { + pos, bit := r.Pos() + sps[pos] &= ^byte(1 << bit) + } + } + } +} diff --git a/pkg/hap/camera/stream.go b/pkg/hap/camera/stream.go index b2ef0d9f..23d53c39 100644 --- a/pkg/hap/camera/stream.go +++ b/pkg/hap/camera/stream.go @@ -15,7 +15,8 @@ type Stream struct { } func NewStream( - client *hap.Client, videoCodec *VideoCodec, audioCodec *AudioCodec, videoSession, audioSession *srtp.Session, + client *hap.Client, videoCodec *VideoCodec, audioCodec *AudioCodec, + videoSession, audioSession *srtp.Session, bitrate int, ) (*Stream, error) { stream := &Stream{ id: core.RandString(16, 0), @@ -30,11 +31,17 @@ func NewStream( return nil, err } + if bitrate != 0 { + bitrate /= 1024 // convert bps to kbps + } else { + bitrate = 4096 // default kbps for general FullHD camera + } + videoCodec.RTPParams = []RTPParams{ { PayloadType: 99, SSRC: videoSession.Local.SSRC, - MaxBitrate: 299, + MaxBitrate: uint16(bitrate), // iPhone query 299Kbps, iPad/AppleTV query 802Kbps RTCPInterval: 0.5, MaxMTU: []uint16{1378}, }, @@ -43,7 +50,7 @@ func NewStream( { PayloadType: 110, SSRC: audioSession.Local.SSRC, - MaxBitrate: 24, + MaxBitrate: 24, // any iDevice query 24Kbps (this is OK for 16KHz and 1 channel) RTCPInterval: 5, ComfortNoisePayloadType: []uint8{13}, diff --git a/pkg/hass/client.go b/pkg/hass/client.go index c1ed5b4b..5b236051 100644 --- a/pkg/hass/client.go +++ b/pkg/hass/client.go @@ -61,8 +61,10 @@ func NewClient(rawURL string) (*Client, error) { } conn := webrtc.NewConn(pc) - conn.Desc = "Hass" + conn.FormatName = "hass/webrtc" conn.Mode = core.ModeActiveProducer + conn.Protocol = "ws" + conn.URL = rawURL // https://developers.google.com/nest/device-access/traits/device/camera-live-stream#generatewebrtcstream-request-fields medias := []*core.Media{ diff --git a/pkg/hls/producer.go b/pkg/hls/producer.go index 410e771a..e1c3ed43 100644 --- a/pkg/hls/producer.go +++ b/pkg/hls/producer.go @@ -4,14 +4,19 @@ import ( "io" "net/url" - "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/mpegts" ) -func OpenURL(u *url.URL, body io.ReadCloser) (core.Producer, error) { +func OpenURL(u *url.URL, body io.ReadCloser) (*mpegts.Producer, error) { rd, err := NewReader(u, body) if err != nil { return nil, err } - return mpegts.Open(rd) + prod, err := mpegts.Open(rd) + if err != nil { + return nil, err + } + prod.FormatName = "hls/mpegts" + prod.RemoteAddr = u.Host + return prod, nil } diff --git a/pkg/hls/reader.go b/pkg/hls/reader.go index a691943b..37554e3c 100644 --- a/pkg/hls/reader.go +++ b/pkg/hls/reader.go @@ -88,7 +88,7 @@ func (r *reader) RoundTrip(_ *http.Request) (*http.Response, error) { } func (r *reader) getSegment() ([]byte, error) { - for i := 0; i < 5; i++ { + for i := 0; i < 10; i++ { if r.playlist == nil { if wait := time.Second - time.Since(r.lastTime); wait > 0 { time.Sleep(wait) diff --git a/pkg/homekit/consumer.go b/pkg/homekit/consumer.go index 1e04fedf..1c665233 100644 --- a/pkg/homekit/consumer.go +++ b/pkg/homekit/consumer.go @@ -3,7 +3,7 @@ package homekit import ( "fmt" "io" - "math/rand" + "math/rand/v2" "net" "time" @@ -16,7 +16,7 @@ import ( ) type Consumer struct { - core.SuperConsumer + core.Connection conn net.Conn srtp *srtp.Server @@ -29,28 +29,31 @@ type Consumer struct { } func NewConsumer(conn net.Conn, server *srtp.Server) *Consumer { - return &Consumer{ - SuperConsumer: core.SuperConsumer{ - Type: "HomeKit passive consumer", - RemoteAddr: conn.RemoteAddr().String(), - Medias: []*core.Media{ - { - Kind: core.KindVideo, - Direction: core.DirectionSendonly, - Codecs: []*core.Codec{ - {Name: core.CodecH264}, - }, - }, - { - Kind: core.KindAudio, - Direction: core.DirectionSendonly, - Codecs: []*core.Codec{ - {Name: core.CodecOpus}, - }, - }, + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecH264}, }, }, - + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecOpus}, + }, + }, + } + return &Consumer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "homekit", + Protocol: "udp", + RemoteAddr: conn.RemoteAddr().String(), + Medias: medias, + Transport: conn, + }, conn: conn, srtp: server, } @@ -175,11 +178,10 @@ func (c *Consumer) WriteTo(io.Writer) (int64, error) { } func (c *Consumer) Stop() error { - _ = c.SuperConsumer.Close() if c.deadline != nil { c.deadline.Reset(0) } - return c.SuperConsumer.Close() + return c.Connection.Stop() } func (c *Consumer) srtpEndpoint() *srtp.Endpoint { diff --git a/pkg/homekit/client.go b/pkg/homekit/producer.go similarity index 93% rename from pkg/homekit/client.go rename to pkg/homekit/producer.go index c61acea6..451b9882 100644 --- a/pkg/homekit/client.go +++ b/pkg/homekit/producer.go @@ -15,8 +15,9 @@ import ( "github.com/pion/rtp" ) +// Deprecated: rename to Producer type Client struct { - core.SuperProducer + core.Connection hap *hap.Client srtp *srtp.Server @@ -28,6 +29,8 @@ type Client struct { audioSession *srtp.Session stream *camera.Stream + + Bitrate int // in bits/s } func Dial(rawURL string, server *srtp.Server) (*Client, error) { @@ -50,9 +53,13 @@ func Dial(rawURL string, server *srtp.Server) (*Client, error) { } client := &Client{ - SuperProducer: core.SuperProducer{ - Type: "HomeKit active producer", - URL: conn.URL(), + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "homekit", + Protocol: "udp", + RemoteAddr: conn.Conn.RemoteAddr().String(), + Source: rawURL, + Transport: conn, }, hap: conn, srtp: server, @@ -91,7 +98,6 @@ func (c *Client) GetMedias() []*core.Media { return nil } - c.URL = c.hap.URL() c.SDP = fmt.Sprintf("%+v\n%+v", c.videoConfig, c.audioConfig) c.Medias = []*core.Media{ @@ -132,7 +138,7 @@ func (c *Client) Start() error { c.audioSession = &srtp.Session{Local: c.srtpEndpoint()} var err error - c.stream, err = camera.NewStream(c.hap, videoCodec, audioCodec, c.videoSession, c.audioSession) + c.stream, err = camera.NewStream(c.hap, videoCodec, audioCodec, c.videoSession, c.audioSession, c.Bitrate) if err != nil { return err } @@ -173,8 +179,6 @@ func (c *Client) Start() error { } func (c *Client) Stop() error { - _ = c.SuperProducer.Close() - if c.videoSession != nil && c.videoSession.Remote != nil { c.srtp.DelSession(c.videoSession) } @@ -182,7 +186,7 @@ func (c *Client) Stop() error { c.srtp.DelSession(c.audioSession) } - return c.hap.Close() + return c.Connection.Stop() } func (c *Client) trackByKind(kind string) *core.Receiver { diff --git a/pkg/image/producer.go b/pkg/image/producer.go new file mode 100644 index 00000000..2081c048 --- /dev/null +++ b/pkg/image/producer.go @@ -0,0 +1,92 @@ +package image + +import ( + "errors" + "io" + "net/http" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/tcp" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + + closed bool + res *http.Response +} + +func Open(res *http.Response) (*Producer, error) { + return &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "image", + Protocol: "http", + RemoteAddr: res.Request.URL.Host, + Transport: res.Body, + Medias: []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: core.CodecJPEG, + ClockRate: 90000, + PayloadType: core.PayloadTypeRAW, + }, + }, + }, + }, + }, + res: res, + }, nil +} + +func (c *Producer) Start() error { + body, err := io.ReadAll(c.res.Body) + if err != nil { + return err + } + + pkt := &rtp.Packet{ + Header: rtp.Header{Timestamp: core.Now90000()}, + Payload: body, + } + c.Receivers[0].WriteRTP(pkt) + + c.Recv += len(body) + + req := c.res.Request + + for !c.closed { + res, err := tcp.Do(req) + if err != nil { + return err + } + + if res.StatusCode != http.StatusOK { + return errors.New("wrong status: " + res.Status) + } + + body, err = io.ReadAll(res.Body) + if err != nil { + return err + } + + c.Recv += len(body) + + pkt = &rtp.Packet{ + Header: rtp.Header{Timestamp: core.Now90000()}, + Payload: body, + } + c.Receivers[0].WriteRTP(pkt) + } + + return nil +} + +func (c *Producer) Stop() error { + c.closed = true + return c.Connection.Stop() +} diff --git a/pkg/isapi/consumer.go b/pkg/isapi/backchannel.go similarity index 83% rename from pkg/isapi/consumer.go rename to pkg/isapi/backchannel.go index c7b51c9d..ade16255 100644 --- a/pkg/isapi/consumer.go +++ b/pkg/isapi/backchannel.go @@ -2,6 +2,7 @@ package isapi import ( "encoding/json" + "github.com/AlexxIT/go2rtc/pkg/core" "github.com/pion/rtp" ) @@ -51,10 +52,15 @@ func (c *Client) Stop() (err error) { } func (c *Client) MarshalJSON() ([]byte, error) { - info := &core.Info{ - Type: "ISAPI active consumer", - Medias: c.medias, - Send: c.send, + info := &core.Connection{ + ID: core.ID(c), + FormatName: "isapi", + Protocol: "http", + Medias: c.medias, + Send: c.send, + } + if c.conn != nil { + info.RemoteAddr = c.conn.RemoteAddr().String() } if c.sender != nil { info.Senders = []*core.Sender{c.sender} diff --git a/pkg/isapi/client.go b/pkg/isapi/client.go index e5dfafd4..ba3e6887 100644 --- a/pkg/isapi/client.go +++ b/pkg/isapi/client.go @@ -11,6 +11,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/tcp" ) +// Deprecated: should be rewritten to core.Connection type Client struct { core.Listener @@ -23,7 +24,7 @@ type Client struct { send int } -func NewClient(rawURL string) (*Client, error) { +func Dial(rawURL string) (*Client, error) { // check if url is valid url u, err := url.Parse(rawURL) if err != nil { @@ -33,7 +34,11 @@ func NewClient(rawURL string) (*Client, error) { u.Scheme = "http" u.Path = "" - return &Client{url: u.String()}, nil + client := &Client{url: u.String()} + if err = client.Dial(); err != nil { + return nil, err + } + return client, err } func (c *Client) Dial() (err error) { diff --git a/pkg/ivideon/client.go b/pkg/ivideon/client.go index c1b055b8..ef79010e 100644 --- a/pkg/ivideon/client.go +++ b/pkg/ivideon/client.go @@ -26,6 +26,7 @@ const ( StateHandle ) +// Deprecated: should be rewritten to core.Connection type Client struct { core.Listener @@ -46,8 +47,13 @@ type Client struct { recv int } -func NewClient(id string) *Client { - return &Client{ID: id} +func Dial(source string) (*Client, error) { + id := strings.Replace(source[8:], "/", ":", 1) + client := &Client{ID: id} + if err := client.Dial(); err != nil { + return nil, err + } + return client, nil } func (c *Client) Dial() (err error) { diff --git a/pkg/ivideon/producer.go b/pkg/ivideon/producer.go index d0a8fcba..78084123 100644 --- a/pkg/ivideon/producer.go +++ b/pkg/ivideon/producer.go @@ -2,6 +2,7 @@ package ivideon import ( "encoding/json" + "github.com/AlexxIT/go2rtc/pkg/core" ) @@ -32,11 +33,16 @@ func (c *Client) Stop() error { } func (c *Client) MarshalJSON() ([]byte, error) { - info := &core.Info{ - Type: "Ivideon active producer", - URL: c.ID, - Medias: c.medias, - Recv: c.recv, + info := &core.Connection{ + ID: core.ID(c), + FormatName: "ivideon", + Protocol: "ws", + URL: c.ID, + Medias: c.medias, + Recv: c.recv, + } + if c.conn != nil { + info.RemoteAddr = c.conn.RemoteAddr().String() } if c.receiver != nil { info.Receivers = []*core.Receiver{c.receiver} diff --git a/pkg/kasa/producer.go b/pkg/kasa/producer.go index bcca1678..22d10216 100644 --- a/pkg/kasa/producer.go +++ b/pkg/kasa/producer.go @@ -12,13 +12,13 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/h264" "github.com/AlexxIT/go2rtc/pkg/h264/annexb" - "github.com/AlexxIT/go2rtc/pkg/multipart" + "github.com/AlexxIT/go2rtc/pkg/mpjpeg" "github.com/AlexxIT/go2rtc/pkg/tcp" "github.com/pion/rtp" ) type Producer struct { - core.SuperProducer + core.Connection rd *core.ReadBuffer reader *bufio.Reader @@ -37,19 +37,46 @@ func Dial(url string) (*Producer, error) { return nil, err } + // KC200 + // HTTP/1.0 200 OK + // Content-Type: multipart/x-mixed-replace;boundary=data-boundary-- + // KD110, KC401, KC420WS: + // HTTP/1.0 200 OK + // Content-Type: multipart/x-mixed-replace;boundary=data-boundary-- + // Transfer-Encoding: chunked + // HTTP/1.0 + chunked = out of standard, so golang remove this header + // and we need to check first two bytes + buf := bufio.NewReader(res.Body) + + b, err := buf.Peek(2) + if err != nil { + return nil, err + } + rd := struct { io.Reader io.Closer }{ - httputil.NewChunkedReader(res.Body), + buf, res.Body, } - prod := &Producer{rd: core.NewReadBuffer(rd)} + if string(b) != "--" { + rd.Reader = httputil.NewChunkedReader(buf) + } + + prod := &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "kasa", + Protocol: "http", + Transport: rd, + }, + rd: core.NewReadBuffer(rd), + } if err = prod.probe(); err != nil { return nil, err } - prod.Type = "Kasa producer" return prod, nil } @@ -70,7 +97,7 @@ func (c *Producer) Start() error { } for { - header, body, err := multipart.Next(c.reader) + header, body, err := mpjpeg.Next(c.reader) if err != nil { return err } @@ -108,11 +135,6 @@ func (c *Producer) Start() error { } } -func (c *Producer) Stop() error { - _ = c.SuperProducer.Close() - return c.rd.Close() -} - const ( MimeVideo = "video/x-h264" MimeG711U = "audio/g711u" @@ -131,7 +153,7 @@ func (c *Producer) probe() error { timeout := time.Now().Add(core.ProbeTimeout) for (waitVideo || waitAudio) && time.Now().Before(timeout) { - header, body, err := multipart.Next(c.reader) + header, body, err := mpjpeg.Next(c.reader) if err != nil { return err } diff --git a/pkg/magic/bitstream/producer.go b/pkg/magic/bitstream/producer.go index 2ffa964e..b84f049b 100644 --- a/pkg/magic/bitstream/producer.go +++ b/pkg/magic/bitstream/producer.go @@ -13,7 +13,7 @@ import ( ) type Producer struct { - core.SuperProducer + core.Connection rd *core.ReadBuffer } @@ -28,26 +28,35 @@ func Open(r io.Reader) (*Producer, error) { buf = annexb.EncodeToAVCC(buf, false) // won't break original buffer var codec *core.Codec + var format string switch { case h264.NALUType(buf) == h264.NALUTypeSPS: codec = h264.AVCCToCodec(buf) + format = "h264" case h265.NALUType(buf) == h265.NALUTypeVPS: codec = h265.AVCCToCodec(buf) + format = "hevc" default: return nil, errors.New("bitstream: unsupported header: " + hex.EncodeToString(buf[:8])) } - prod := &Producer{rd: rd} - prod.Type = "Bitstream producer" - prod.Medias = []*core.Media{ + medias := []*core.Media{ { Kind: core.KindVideo, Direction: core.DirectionRecvonly, Codecs: []*core.Codec{codec}, }, } - return prod, nil + return &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: format, + Medias: medias, + Transport: r, + }, + rd: rd, + }, nil } func (c *Producer) Start() error { @@ -84,8 +93,3 @@ func (c *Producer) Start() error { } } } - -func (c *Producer) Stop() error { - _ = c.SuperProducer.Close() - return c.rd.Close() -} diff --git a/pkg/magic/keyframe.go b/pkg/magic/keyframe.go index d2ae80bd..8f70eec6 100644 --- a/pkg/magic/keyframe.go +++ b/pkg/magic/keyframe.go @@ -12,26 +12,32 @@ import ( ) type Keyframe struct { - core.SuperConsumer + core.Connection wr *core.WriteBuffer } +// Deprecated: should be rewritten func NewKeyframe() *Keyframe { - return &Keyframe{ - core.SuperConsumer{ - Medias: []*core.Media{ - { - Kind: core.KindVideo, - Direction: core.DirectionSendonly, - Codecs: []*core.Codec{ - {Name: core.CodecJPEG}, - {Name: core.CodecH264}, - {Name: core.CodecH265}, - }, - }, + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecJPEG}, + {Name: core.CodecH264}, + {Name: core.CodecH265}, }, }, - core.NewWriteBuffer(nil), + } + wr := core.NewWriteBuffer(nil) + return &Keyframe{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "keyframe", + Medias: medias, + Transport: wr, + }, + wr: wr, } } @@ -98,8 +104,3 @@ func (k *Keyframe) CodecName() string { func (k *Keyframe) WriteTo(wr io.Writer) (int64, error) { return k.wr.WriteTo(wr) } - -func (k *Keyframe) Stop() error { - _ = k.SuperConsumer.Close() - return k.wr.Close() -} diff --git a/pkg/magic/mjpeg/producer.go b/pkg/magic/mjpeg/producer.go index e5627fd7..e47c168d 100644 --- a/pkg/magic/mjpeg/producer.go +++ b/pkg/magic/mjpeg/producer.go @@ -9,14 +9,12 @@ import ( ) type Producer struct { - core.SuperProducer + core.Connection rd *core.ReadBuffer } func Open(rd io.Reader) (*Producer, error) { - prod := &Producer{rd: core.NewReadBuffer(rd)} - prod.Type = "MJPEG producer" - prod.Medias = []*core.Media{ + medias := []*core.Media{ { Kind: core.KindVideo, Direction: core.DirectionRecvonly, @@ -29,7 +27,15 @@ func Open(rd io.Reader) (*Producer, error) { }, }, } - return prod, nil + return &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "mjpeg", + Medias: medias, + Transport: rd, + }, + rd: core.NewReadBuffer(rd), + }, nil } func (c *Producer) Start() error { @@ -70,8 +76,3 @@ func (c *Producer) Start() error { buf = buf[i:] } } - -func (c *Producer) Stop() error { - _ = c.SuperProducer.Close() - return c.rd.Close() -} diff --git a/pkg/magic/producer.go b/pkg/magic/producer.go index fb48270e..3742ccf9 100644 --- a/pkg/magic/producer.go +++ b/pkg/magic/producer.go @@ -13,7 +13,9 @@ import ( "github.com/AlexxIT/go2rtc/pkg/magic/bitstream" "github.com/AlexxIT/go2rtc/pkg/magic/mjpeg" "github.com/AlexxIT/go2rtc/pkg/mpegts" - "github.com/AlexxIT/go2rtc/pkg/multipart" + "github.com/AlexxIT/go2rtc/pkg/mpjpeg" + "github.com/AlexxIT/go2rtc/pkg/wav" + "github.com/AlexxIT/go2rtc/pkg/y4m" ) func Open(r io.Reader) (core.Producer, error) { @@ -24,23 +26,31 @@ func Open(r io.Reader) (core.Producer, error) { return nil, err } - switch { - case bytes.HasPrefix(b, []byte(annexb.StartCode)): + switch string(b) { + case annexb.StartCode: return bitstream.Open(rd) + case wav.FourCC: + return wav.Open(rd) + case y4m.FourCC: + return y4m.Open(rd) + } - case bytes.HasPrefix(b, []byte{0xFF, 0xD8}): - return mjpeg.Open(rd) - - case bytes.HasPrefix(b, []byte(flv.Signature)): + switch string(b[:3]) { + case flv.Signature: return flv.Open(rd) + } - case bytes.HasPrefix(b, []byte{0xFF, 0xF1}): + switch string(b[:2]) { + case "\xFF\xD8": + return mjpeg.Open(rd) + case "\xFF\xF1", "\xFF\xF9": return aac.Open(rd) + case "--": + return mpjpeg.Open(rd) + } - case bytes.HasPrefix(b, []byte("--")): - return multipart.Open(rd) - - case b[0] == mpegts.SyncByte: + switch b[0] { + case mpegts.SyncByte: return mpegts.Open(rd) } diff --git a/pkg/mdns/syscall_linux.go b/pkg/mdns/syscall.go similarity index 78% rename from pkg/mdns/syscall_linux.go rename to pkg/mdns/syscall.go index fc0caeb0..0e50535a 100644 --- a/pkg/mdns/syscall_linux.go +++ b/pkg/mdns/syscall.go @@ -1,3 +1,5 @@ +//go:build !(darwin || ios || freebsd || openbsd || netbsd || dragonfly || windows) + package mdns import ( diff --git a/pkg/mdns/syscall_darwin.go b/pkg/mdns/syscall_bsd.go similarity index 90% rename from pkg/mdns/syscall_darwin.go rename to pkg/mdns/syscall_bsd.go index c1f1225b..6ebb0c93 100644 --- a/pkg/mdns/syscall_darwin.go +++ b/pkg/mdns/syscall_bsd.go @@ -1,3 +1,5 @@ +//go:build darwin || ios || freebsd || openbsd || netbsd || dragonfly + package mdns import ( diff --git a/pkg/mdns/syscall_freebsd.go b/pkg/mdns/syscall_freebsd.go deleted file mode 100644 index c1f1225b..00000000 --- a/pkg/mdns/syscall_freebsd.go +++ /dev/null @@ -1,24 +0,0 @@ -package mdns - -import ( - "syscall" -) - -func SetsockoptInt(fd uintptr, level, opt int, value int) (err error) { - // change SO_REUSEADDR and REUSEPORT flags simultaneously for BSD-like OS - // https://github.com/AlexxIT/go2rtc/issues/626 - // https://stackoverflow.com/questions/14388706/how-do-so-reuseaddr-and-so-reuseport-differ/14388707 - if opt == syscall.SO_REUSEADDR { - if err = syscall.SetsockoptInt(int(fd), level, opt, value); err != nil { - return - } - - opt = syscall.SO_REUSEPORT - } - - return syscall.SetsockoptInt(int(fd), level, opt, value) -} - -func SetsockoptIPMreq(fd uintptr, level, opt int, mreq *syscall.IPMreq) (err error) { - return syscall.SetsockoptIPMreq(int(fd), level, opt, mreq) -} diff --git a/pkg/mdns/syscall_windows.go b/pkg/mdns/syscall_windows.go index be283655..770510cf 100644 --- a/pkg/mdns/syscall_windows.go +++ b/pkg/mdns/syscall_windows.go @@ -1,3 +1,5 @@ +//go:build windows + package mdns import "syscall" diff --git a/pkg/mjpeg/client.go b/pkg/mjpeg/client.go deleted file mode 100644 index f16c42cd..00000000 --- a/pkg/mjpeg/client.go +++ /dev/null @@ -1,75 +0,0 @@ -package mjpeg - -import ( - "errors" - "io" - "net/http" - - "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/AlexxIT/go2rtc/pkg/tcp" - "github.com/pion/rtp" -) - -type Client struct { - core.Listener - - UserAgent string - RemoteAddr string - - closed bool - res *http.Response - - medias []*core.Media - receiver *core.Receiver - - recv int -} - -func NewClient(res *http.Response) *Client { - return &Client{res: res} -} - -func (c *Client) Handle() error { - body, err := io.ReadAll(c.res.Body) - if err != nil { - return err - } - - pkt := &rtp.Packet{ - Header: rtp.Header{Timestamp: core.Now90000()}, - Payload: body, - } - c.receiver.WriteRTP(pkt) - - c.recv += len(body) - - req := c.res.Request - - for !c.closed { - res, err := tcp.Do(req) - if err != nil { - return err - } - - if res.StatusCode != http.StatusOK { - return errors.New("wrong status: " + res.Status) - } - - body, err = io.ReadAll(res.Body) - if err != nil { - return err - } - - c.recv += len(body) - - if c.receiver != nil { - pkt = &rtp.Packet{ - Header: rtp.Header{Timestamp: core.Now90000()}, - Payload: body, - } - c.receiver.WriteRTP(pkt) - } - } - - return nil -} diff --git a/pkg/mjpeg/consumer.go b/pkg/mjpeg/consumer.go index 444cbdcc..16edc895 100644 --- a/pkg/mjpeg/consumer.go +++ b/pkg/mjpeg/consumer.go @@ -8,25 +8,30 @@ import ( ) type Consumer struct { - core.SuperConsumer + core.Connection wr *core.WriteBuffer } func NewConsumer() *Consumer { - return &Consumer{ - core.SuperConsumer{ - Type: "MJPEG passive consumer", - Medias: []*core.Media{ - { - Kind: core.KindVideo, - Direction: core.DirectionSendonly, - Codecs: []*core.Codec{ - {Name: core.CodecJPEG}, - }, - }, + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecJPEG}, + {Name: core.CodecRAW}, }, }, - core.NewWriteBuffer(nil), + } + wr := core.NewWriteBuffer(nil) + return &Consumer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "mjpeg", + Medias: medias, + Transport: wr, + }, + wr: wr, } } @@ -40,6 +45,8 @@ func (c *Consumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv if track.Codec.IsRTP() { sender.Handler = RTPDepay(sender.Handler) + } else if track.Codec.Name == core.CodecRAW { + sender.Handler = Encoder(track.Codec, sender.Handler) } sender.HandleRTP(track) @@ -50,8 +57,3 @@ func (c *Consumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv func (c *Consumer) WriteTo(wr io.Writer) (int64, error) { return c.wr.WriteTo(wr) } - -func (c *Consumer) Stop() error { - _ = c.SuperConsumer.Close() - return c.wr.Close() -} diff --git a/pkg/mjpeg/helpers.go b/pkg/mjpeg/helpers.go index 21000b9b..08b4408b 100644 --- a/pkg/mjpeg/helpers.go +++ b/pkg/mjpeg/helpers.go @@ -3,6 +3,10 @@ package mjpeg import ( "bytes" "image/jpeg" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/y4m" + "github.com/pion/rtp" ) // FixJPEG - reencode JPEG if it has wrong header @@ -33,3 +37,20 @@ func FixJPEG(b []byte) []byte { } return buf.Bytes() } + +func Encoder(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { + newImage := y4m.NewImage(codec.FmtpLine) + + return func(packet *rtp.Packet) { + img := newImage(packet.Payload) + + buf := bytes.NewBuffer(nil) + if err := jpeg.Encode(buf, img, nil); err != nil { + return + } + + clone := *packet + clone.Payload = buf.Bytes() + handler(&clone) + } +} diff --git a/pkg/mjpeg/producer.go b/pkg/mjpeg/producer.go deleted file mode 100644 index 5b352252..00000000 --- a/pkg/mjpeg/producer.go +++ /dev/null @@ -1,61 +0,0 @@ -package mjpeg - -import ( - "encoding/json" - - "github.com/AlexxIT/go2rtc/pkg/core" -) - -func (c *Client) GetMedias() []*core.Media { - if c.medias == nil { - c.medias = []*core.Media{{ - Kind: core.KindVideo, - Direction: core.DirectionRecvonly, - Codecs: []*core.Codec{ - { - Name: core.CodecJPEG, - ClockRate: 90000, - PayloadType: core.PayloadTypeRAW, - }, - }, - }} - } - return c.medias -} - -func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { - if c.receiver == nil { - c.receiver = core.NewReceiver(media, codec) - } - return c.receiver, nil -} - -func (c *Client) Start() error { - // https://github.com/AlexxIT/go2rtc/issues/278 - return c.Handle() -} - -func (c *Client) Stop() error { - if c.receiver != nil { - c.receiver.Close() - } - // important for close reader/writer gorutines - _ = c.res.Body.Close() - c.closed = true - return nil -} - -func (c *Client) MarshalJSON() ([]byte, error) { - info := &core.Info{ - Type: "JPEG active producer", - URL: c.res.Request.URL.String(), - RemoteAddr: c.RemoteAddr, - UserAgent: c.UserAgent, - Medias: c.medias, - Recv: c.recv, - } - if c.receiver != nil { - info.Receivers = []*core.Receiver{c.receiver} - } - return json.Marshal(info) -} diff --git a/pkg/mjpeg/writer.go b/pkg/mjpeg/writer.go new file mode 100644 index 00000000..8845bf2c --- /dev/null +++ b/pkg/mjpeg/writer.go @@ -0,0 +1,38 @@ +package mjpeg + +import ( + "io" + "net/http" + "strconv" +) + +func NewWriter(w io.Writer) io.Writer { + h := w.(http.ResponseWriter).Header() + h.Set("Content-Type", "multipart/x-mixed-replace; boundary=frame") + return &writer{wr: w, buf: []byte(header)} +} + +const header = "--frame\r\nContent-Type: image/jpeg\r\nContent-Length: " + +type writer struct { + wr io.Writer + buf []byte +} + +func (w *writer) Write(p []byte) (n int, err error) { + w.buf = w.buf[:len(header)] + w.buf = append(w.buf, strconv.Itoa(len(p))...) + w.buf = append(w.buf, "\r\n\r\n"...) + w.buf = append(w.buf, p...) + w.buf = append(w.buf, "\r\n"...) + + // Chrome bug: mjpeg image always shows the second to last image + // https://bugs.chromium.org/p/chromium/issues/detail?id=527446 + if _, err = w.wr.Write(w.buf); err != nil { + return 0, err + } + + w.wr.(http.Flusher).Flush() + + return len(p), nil +} diff --git a/pkg/mp4/consumer.go b/pkg/mp4/consumer.go index 83b2d2e3..34849863 100644 --- a/pkg/mp4/consumer.go +++ b/pkg/mp4/consumer.go @@ -14,7 +14,7 @@ import ( ) type Consumer struct { - core.SuperConsumer + core.Connection wr *core.WriteBuffer muxer *Muxer mu sync.Mutex @@ -47,12 +47,17 @@ func NewConsumer(medias []*core.Media) *Consumer { } } - cons := &Consumer{ + wr := core.NewWriteBuffer(nil) + return &Consumer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "mp4", + Medias: medias, + Transport: wr, + }, muxer: &Muxer{}, - wr: core.NewWriteBuffer(nil), + wr: wr, } - cons.Medias = medias - return cons } func (c *Consumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { @@ -182,8 +187,3 @@ func (c *Consumer) WriteTo(wr io.Writer) (int64, error) { return c.wr.WriteTo(wr) } - -func (c *Consumer) Stop() error { - _ = c.SuperConsumer.Close() - return c.wr.Close() -} diff --git a/pkg/mp4/keyframe.go b/pkg/mp4/keyframe.go index 25a6983d..399f95e7 100644 --- a/pkg/mp4/keyframe.go +++ b/pkg/mp4/keyframe.go @@ -10,11 +10,12 @@ import ( ) type Keyframe struct { - core.SuperConsumer + core.Connection wr *core.WriteBuffer muxer *Muxer } +// Deprecated: should be rewritten func NewKeyframe(medias []*core.Media) *Keyframe { if medias == nil { medias = []*core.Media{ @@ -29,9 +30,15 @@ func NewKeyframe(medias []*core.Media) *Keyframe { } } + wr := core.NewWriteBuffer(nil) cons := &Keyframe{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "mp4", + Transport: wr, + }, muxer: &Muxer{}, - wr: core.NewWriteBuffer(nil), + wr: wr, } cons.Medias = medias return cons @@ -95,8 +102,3 @@ func (c *Keyframe) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv func (c *Keyframe) WriteTo(wr io.Writer) (int64, error) { return c.wr.WriteTo(wr) } - -func (c *Keyframe) Stop() error { - _ = c.SuperConsumer.Close() - return c.wr.Close() -} diff --git a/pkg/mpegts/consumer.go b/pkg/mpegts/consumer.go index eb0902fc..fcb57c74 100644 --- a/pkg/mpegts/consumer.go +++ b/pkg/mpegts/consumer.go @@ -11,17 +11,13 @@ import ( ) type Consumer struct { - core.SuperConsumer + core.Connection muxer *Muxer wr *core.WriteBuffer } func NewConsumer() *Consumer { - c := &Consumer{ - muxer: NewMuxer(), - wr: core.NewWriteBuffer(nil), - } - c.Medias = []*core.Media{ + medias := []*core.Media{ { Kind: core.KindVideo, Direction: core.DirectionSendonly, @@ -38,7 +34,17 @@ func NewConsumer() *Consumer { }, }, } - return c + wr := core.NewWriteBuffer(nil) + return &Consumer{ + core.Connection{ + ID: core.NewID(), + FormatName: "mpegts", + Medias: medias, + Transport: wr, + }, + NewMuxer(), + wr, + } } func (c *Consumer) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { @@ -110,14 +116,9 @@ func (c *Consumer) WriteTo(wr io.Writer) (int64, error) { return c.wr.WriteTo(wr) } -func (c *Consumer) Stop() error { - _ = c.SuperConsumer.Close() - return c.wr.Close() -} - -func TimestampFromRTP(rtp *rtp.Packet, codec *core.Codec) { - if codec.ClockRate == ClockRate { - return - } - rtp.Timestamp = uint32(float64(rtp.Timestamp) / float64(codec.ClockRate) * ClockRate) -} +//func TimestampFromRTP(rtp *rtp.Packet, codec *core.Codec) { +// if codec.ClockRate == ClockRate { +// return +// } +// rtp.Timestamp = uint32(float64(rtp.Timestamp) / float64(codec.ClockRate) * ClockRate) +//} diff --git a/pkg/mpegts/producer.go b/pkg/mpegts/producer.go index 78f320a2..2c72d8aa 100644 --- a/pkg/mpegts/producer.go +++ b/pkg/mpegts/producer.go @@ -13,12 +13,19 @@ import ( ) type Producer struct { - core.SuperProducer + core.Connection rd *core.ReadBuffer } func Open(rd io.Reader) (*Producer, error) { - prod := &Producer{rd: core.NewReadBuffer(rd)} + prod := &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "mpegts", + Transport: rd, + }, + rd: core.NewReadBuffer(rd), + } if err := prod.probe(); err != nil { return nil, err } @@ -26,7 +33,7 @@ func Open(rd io.Reader) (*Producer, error) { } func (c *Producer) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { - receiver, _ := c.SuperProducer.GetTrack(media, codec) + receiver, _ := c.Connection.GetTrack(media, codec) receiver.ID = StreamType(codec) return receiver, nil } @@ -40,6 +47,8 @@ func (c *Producer) Start() error { return err } + c.Recv += len(pkt.Payload) + //log.Printf("[mpegts] size: %6d, muxer: %10d, pt: %2d", len(pkt.Payload), pkt.Timestamp, pkt.PayloadType) for _, receiver := range c.Receivers { @@ -52,11 +61,6 @@ func (c *Producer) Start() error { } } -func (c *Producer) Stop() error { - _ = c.SuperProducer.Close() - return c.rd.Close() -} - func (c *Producer) probe() error { c.rd.BufferSize = core.ProbeSize defer c.rd.Reset() diff --git a/pkg/multipart/multipart.go b/pkg/mpjpeg/multipart.go similarity index 98% rename from pkg/multipart/multipart.go rename to pkg/mpjpeg/multipart.go index aea1b828..abceea43 100644 --- a/pkg/multipart/multipart.go +++ b/pkg/mpjpeg/multipart.go @@ -1,4 +1,4 @@ -package multipart +package mpjpeg import ( "bufio" diff --git a/pkg/mpjpeg/producer.go b/pkg/mpjpeg/producer.go new file mode 100644 index 00000000..a8d5e16a --- /dev/null +++ b/pkg/mpjpeg/producer.go @@ -0,0 +1,65 @@ +package mpjpeg + +import ( + "bufio" + "errors" + "io" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + rd *bufio.Reader +} + +func Open(rd io.Reader) (*Producer, error) { + return &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "mpjpeg", // Multipart JPEG + Transport: rd, + Medias: []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: core.CodecJPEG, + ClockRate: 90000, + PayloadType: core.PayloadTypeRAW, + }, + }, + }, + }, + }, + }, nil +} + +func (c *Producer) Start() error { + if len(c.Receivers) != 1 { + return errors.New("mjpeg: no receivers") + } + + rd := bufio.NewReader(c.Transport.(io.Reader)) + + mjpeg := c.Receivers[0] + + for { + _, body, err := Next(rd) + if err != nil { + return err + } + + c.Recv += len(body) + + if mjpeg != nil { + packet := &rtp.Packet{ + Header: rtp.Header{Timestamp: core.Now90000()}, + Payload: body, + } + mjpeg.WriteRTP(packet) + } + } +} diff --git a/pkg/multipart/producer.go b/pkg/multipart/producer.go deleted file mode 100644 index 70a2c547..00000000 --- a/pkg/multipart/producer.go +++ /dev/null @@ -1,68 +0,0 @@ -package multipart - -import ( - "bufio" - "errors" - "io" - - "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/pion/rtp" -) - -type Producer struct { - core.SuperProducer - closer io.Closer - reader *bufio.Reader -} - -func Open(rd io.Reader) (*Producer, error) { - prod := &Producer{ - closer: rd.(io.Closer), - reader: bufio.NewReader(rd), - } - prod.Medias = []*core.Media{ - { - Kind: core.KindVideo, - Direction: core.DirectionRecvonly, - Codecs: []*core.Codec{ - { - Name: core.CodecJPEG, - ClockRate: 90000, - PayloadType: core.PayloadTypeRAW, - }, - }, - }, - } - prod.Type = "Multipart producer" - return prod, nil -} - -func (c *Producer) Start() error { - if len(c.Receivers) != 1 { - return errors.New("mjpeg: no receivers") - } - - mjpeg := c.Receivers[0] - - for { - _, body, err := Next(c.reader) - if err != nil { - return err - } - - c.Recv += len(body) - - if mjpeg != nil { - packet := &rtp.Packet{ - Header: rtp.Header{Timestamp: core.Now90000()}, - Payload: body, - } - mjpeg.WriteRTP(packet) - } - } -} - -func (c *Producer) Stop() error { - _ = c.SuperProducer.Close() - return c.closer.Close() -} diff --git a/pkg/nest/client.go b/pkg/nest/client.go index cb73cc98..0b243384 100644 --- a/pkg/nest/client.go +++ b/pkg/nest/client.go @@ -14,7 +14,7 @@ type Client struct { api *API } -func NewClient(rawURL string) (*Client, error) { +func Dial(rawURL string) (*Client, error) { u, err := url.Parse(rawURL) if err != nil { return nil, err @@ -48,8 +48,10 @@ func NewClient(rawURL string) (*Client, error) { } conn := webrtc.NewConn(pc) - conn.Desc = "Nest" + conn.FormatName = "nest/webrtc" conn.Mode = core.ModeActiveProducer + conn.Protocol = "http" + conn.URL = rawURL // https://developers.google.com/nest/device-access/traits/device/camera-live-stream#generatewebrtcstream-request-fields medias := []*core.Media{ diff --git a/pkg/opus/opus.go b/pkg/opus/.opus.go similarity index 97% rename from pkg/opus/opus.go rename to pkg/opus/.opus.go index 9fe1d8b6..42043977 100644 --- a/pkg/opus/opus.go +++ b/pkg/opus/.opus.go @@ -5,7 +5,6 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/pion/rtp" - "github.com/rs/zerolog/log" ) func Log(handler core.HandlerFunc) core.HandlerFunc { diff --git a/pkg/probe/producer.go b/pkg/probe/producer.go new file mode 100644 index 00000000..1fbd3efb --- /dev/null +++ b/pkg/probe/producer.go @@ -0,0 +1,70 @@ +package probe + +import ( + "net/url" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +type Probe struct { + core.Connection +} + +func NewProbe(query url.Values) *Probe { + medias := core.ParseQuery(query) + + for _, value := range query["microphone"] { + media := &core.Media{Kind: core.KindAudio, Direction: core.DirectionRecvonly} + + for _, name := range strings.Split(value, ",") { + name = strings.ToUpper(name) + switch name { + case "", "COPY": + name = core.CodecAny + } + media.Codecs = append(media.Codecs, &core.Codec{Name: name}) + } + + medias = append(medias, media) + } + + return &Probe{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "probe", + Medias: medias, + }, + } +} + +func (p *Probe) GetMedias() []*core.Media { + return p.Medias +} + +func (p *Probe) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + sender := core.NewSender(media, track.Codec) + sender.Bind(track) + p.Senders = append(p.Senders, sender) + return nil +} + +func (p *Probe) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + receiver := core.NewReceiver(media, codec) + p.Receivers = append(p.Receivers, receiver) + return receiver, nil +} + +func (p *Probe) Start() error { + return nil +} + +func (p *Probe) Stop() error { + for _, receiver := range p.Receivers { + receiver.Close() + } + for _, sender := range p.Senders { + sender.Close() + } + return nil +} diff --git a/pkg/roborock/client.go b/pkg/roborock/client.go index 39caab88..ef221e65 100644 --- a/pkg/roborock/client.go +++ b/pkg/roborock/client.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "log" "net/rpc" "net/url" "strconv" @@ -19,6 +18,7 @@ import ( pion "github.com/pion/webrtc/v3" ) +// Deprecated: should be rewritten to core.Connection type Client struct { core.Listener @@ -35,8 +35,15 @@ type Client struct { backchannel bool } -func NewClient(url string) *Client { - return &Client{url: url} +func Dial(rawURL string) (*Client, error) { + client := &Client{url: rawURL} + if err := client.Dial(); err != nil { + return nil, err + } + if err := client.Connect(); err != nil { + return nil, err + } + return client, nil } func (c *Client) Dial() error { @@ -104,8 +111,10 @@ func (c *Client) Connect() error { var sendOffer sync.WaitGroup c.conn = webrtc.NewConn(pc) - c.conn.Desc = "Roborock" + c.conn.FormatName = "roborock" c.conn.Mode = core.ModeActiveProducer + c.conn.Protocol = "mqtt" + c.conn.URL = c.url c.conn.Listen(func(msg any) { switch msg := msg.(type) { case *pion.ICECandidate: @@ -138,7 +147,7 @@ func (c *Client) Connect() error { } offer := pc.LocalDescription() - log.Printf("[roborock] offer\n%s", offer.SDP) + //log.Printf("[roborock] offer\n%s", offer.SDP) if err = c.SendSDPtoRobot(offer); err != nil { return err } @@ -151,7 +160,7 @@ func (c *Client) Connect() error { time.Sleep(time.Second) if desc, _ := c.GetDeviceSDP(); desc != nil { - log.Printf("[roborock] answer\n%s", desc.SDP) + //log.Printf("[roborock] answer\n%s", desc.SDP) if err = c.conn.SetAnswer(desc.SDP); err != nil { return err } diff --git a/pkg/roborock/iot/client.go b/pkg/roborock/iot/client.go index 8773455d..c3b2d97f 100644 --- a/pkg/roborock/iot/client.go +++ b/pkg/roborock/iot/client.go @@ -6,12 +6,12 @@ import ( "encoding/hex" "encoding/json" "fmt" - "github.com/AlexxIT/go2rtc/pkg/mqtt" - "github.com/rs/zerolog/log" "net" "net/rpc" "net/url" "time" + + "github.com/AlexxIT/go2rtc/pkg/mqtt" ) type Codec struct { @@ -56,7 +56,7 @@ func (c *Codec) WriteRequest(r *rpc.Request, v any) error { return err } - log.Printf("[roborock] send: %s", payload) + //log.Printf("[roborock] send: %s", payload) payload = c.Encrypt(payload, ts, ts, ts) @@ -86,7 +86,7 @@ func (c *Codec) ReadResponseHeader(r *rpc.Response) error { continue } - log.Printf("[roborock] recv %s", payload) + //log.Printf("[roborock] recv %s", payload) // get content from response payload: // {"t":1676871268,"dps":{"102":"{\"id\":315003,\"result\":[\"ok\"]}"}} diff --git a/pkg/rtmp/README.md b/pkg/rtmp/README.md index 11382210..4196d570 100644 --- a/pkg/rtmp/README.md +++ b/pkg/rtmp/README.md @@ -16,4 +16,5 @@ response []interface {}{"onStatus", 0, interface {}(nil), map[string]interface { - https://en.wikipedia.org/wiki/Flash_Video - https://en.wikipedia.org/wiki/Real-Time_Messaging_Protocol -- https://rtmp.veriskope.com/pdf/rtmp_specification_1.0.pdf \ No newline at end of file +- https://rtmp.veriskope.com/pdf/rtmp_specification_1.0.pdf +- https://rtmp.veriskope.com/docs/spec/ diff --git a/pkg/rtmp/client.go b/pkg/rtmp/client.go index 00544d5b..138d727d 100644 --- a/pkg/rtmp/client.go +++ b/pkg/rtmp/client.go @@ -8,10 +8,11 @@ import ( "strings" "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/flv" "github.com/AlexxIT/go2rtc/pkg/tcp" ) -func DialPlay(rawURL string) (core.Producer, error) { +func DialPlay(rawURL string) (*flv.Producer, error) { u, err := url.Parse(rawURL) if err != nil { return nil, err @@ -22,16 +23,16 @@ func DialPlay(rawURL string) (core.Producer, error) { return nil, err } - rtmpConn, err := NewClient(conn, u) + client, err := NewClient(conn, u) if err != nil { return nil, err } - if err = rtmpConn.play(); err != nil { + if err = client.play(); err != nil { return nil, err } - return rtmpConn.Producer() + return client.Producer() } func DialPublish(rawURL string) (io.Writer, error) { @@ -65,7 +66,7 @@ func NewClient(conn net.Conn, u *url.URL) (*Conn, error) { rd: bufio.NewReaderSize(conn, core.BufferSize), wr: conn, - chunks: map[uint8]*header{}, + chunks: map[uint8]*chunk{}, rdPacketSize: 128, wrPacketSize: 4096, // OBS - 4096, Reolink - 4096 diff --git a/pkg/rtmp/conn.go b/pkg/rtmp/conn.go index f81b1dec..2083a148 100644 --- a/pkg/rtmp/conn.go +++ b/pkg/rtmp/conn.go @@ -29,7 +29,7 @@ type Conn struct { rdPacketSize uint32 wrPacketSize uint32 - chunks map[byte]*header + chunks map[byte]*chunk streamID byte url string @@ -52,24 +52,73 @@ func (c *Conn) readResponse(transID float64) ([]any, error) { if err != nil { return nil, err } + //log.Printf("[rtmp] type=%d data=%s", msgType, b) switch msgType { case TypeSetPacketSize: c.rdPacketSize = binary.BigEndian.Uint32(b) case TypeCommand: items, _ := amf.NewReader(b).ReadItems() - if len(items) >= 3 && items[1] == transID { + if len(items) >= 3 && (items[1] == transID || items[1] == float64(0)) { return items, nil } } } } -type header struct { - timeMS uint32 +type chunk struct { + conn *Conn + rawTime uint32 dataSize uint32 tagType byte streamID uint32 + timeMS uint32 +} + +func (c *chunk) readHeader(typ byte) error { + switch typ { + case 0: // 12 byte header (full header) + b, err := c.conn.readSize(11) + if err != nil { + return err + } + c.rawTime = Uint24(b) + c.dataSize = Uint24(b[3:]) + c.tagType = b[6] + c.streamID = binary.LittleEndian.Uint32(b[7:]) + c.timeMS = c.readExtendedTime() + + case 1: // 8 bytes - like type b00, not including message ID (4 last bytes) + b, err := c.conn.readSize(7) + if err != nil { + return err + } + c.rawTime = Uint24(b) + c.dataSize = Uint24(b[3:]) // msgdatalen + c.tagType = b[6] // msgtypeid + c.timeMS += c.readExtendedTime() + + case 2: // 4 bytes - Basic Header and timestamp (3 bytes) are included + b, err := c.conn.readSize(3) + if err != nil { + return err + } + c.rawTime = Uint24(b) // timestamp + c.timeMS += c.readExtendedTime() + + case 3: // 1 byte - only the Basic Header is included + // use here hdr from previous msg with same session ID (sid) + } + return nil +} + +func (c *chunk) readExtendedTime() uint32 { + if c.rawTime == 0xFFFFFF { + if b, err := c.conn.readSize(4); err == nil { + return binary.BigEndian.Uint32(b) + } + } + return c.rawTime } //var ErrNotImplemented = errors.New("rtmp: not implemented") @@ -84,93 +133,57 @@ func (c *Conn) readMessage() (byte, uint32, []byte, error) { chunkID := b[0] & 0b111111 // storing header information for support header type 3 - hdr, ok := c.chunks[chunkID] + ch, ok := c.chunks[chunkID] if !ok { - hdr = &header{} - c.chunks[chunkID] = hdr + ch = &chunk{conn: c} + c.chunks[chunkID] = ch } - switch hdrType { - case 0: // 12 byte header (full header) - if b, err = c.readSize(11); err != nil { - return 0, 0, nil, err - } - _ = b[7] - hdr.timeMS = Uint24(b) - hdr.dataSize = Uint24(b[3:]) - hdr.tagType = b[6] - hdr.streamID = binary.LittleEndian.Uint32(b[7:]) - - case 1: // 8 bytes - like type b00, not including message ID (4 last bytes) - if b, err = c.readSize(7); err != nil { - return 0, 0, nil, err - } - _ = b[6] - hdr.timeMS = Uint24(b) // timestamp - hdr.dataSize = Uint24(b[3:]) // msgdatalen - hdr.tagType = b[6] // msgtypeid - - case 2: // 4 bytes - Basic Header and timestamp (3 bytes) are included - if b, err = c.readSize(3); err != nil { - return 0, 0, nil, err - } - hdr.timeMS = Uint24(b) // timestamp - - case 3: // 1 byte - only the Basic Header is included - // use here hdr from previous msg with same session ID (sid) + if err = ch.readHeader(hdrType); err != nil { + return 0, 0, nil, err } - timeMS := hdr.timeMS - if timeMS == 0xFFFFFF { - if b, err = c.readSize(4); err != nil { - return 0, 0, nil, err - } - timeMS = binary.BigEndian.Uint32(b) - } - - //log.Printf("[rtmp] hdr=%d chunkID=%d timeMS=%d size=%d tagType=%d streamID=%d", hdrType, chunkID, hdr.timeMS, hdr.dataSize, hdr.tagType, hdr.streamID) + //log.Printf("[rtmp] hdr=%d chunkID=%d timeMS=%d size=%d tagType=%d streamID=%d", hdrType, chunkID, ch.timeMS, ch.dataSize, ch.tagType, ch.streamID) // 1. Response zero size - if hdr.dataSize == 0 { - return hdr.tagType, timeMS, nil, nil + if ch.dataSize == 0 { + return ch.tagType, ch.timeMS, nil, nil } - b = make([]byte, hdr.dataSize) + data := make([]byte, ch.dataSize) // 2. Response small packet - if hdr.dataSize <= c.rdPacketSize { - if _, err = io.ReadFull(c.rd, b); err != nil { + if ch.dataSize <= c.rdPacketSize { + if _, err = io.ReadFull(c.rd, data); err != nil { return 0, 0, nil, err } - return hdr.tagType, timeMS, b, nil + return ch.tagType, ch.timeMS, data, nil } // 3. Response big packet var i0 uint32 - for i1 := c.rdPacketSize; i1 < hdr.dataSize; i1 += c.rdPacketSize { - if _, err = io.ReadFull(c.rd, b[i0:i1]); err != nil { + for i1 := c.rdPacketSize; i1 < ch.dataSize; i1 += c.rdPacketSize { + if _, err = io.ReadFull(c.rd, data[i0:i1]); err != nil { return 0, 0, nil, err } + // hopefully this will be hdrType=3 with same chunkID if _, err = c.readSize(1); err != nil { return 0, 0, nil, err } - if hdr.timeMS == 0xFFFFFF { - if _, err = c.readSize(4); err != nil { - return 0, 0, nil, err - } - } + _ = ch.readExtendedTime() i0 = i1 } - if _, err = io.ReadFull(c.rd, b[i0:]); err != nil { + if _, err = io.ReadFull(c.rd, data[i0:]); err != nil { return 0, 0, nil, err } - return hdr.tagType, timeMS, b, nil + return ch.tagType, ch.timeMS, data, nil } + func (c *Conn) writeMessage(chunkID, tagType byte, timeMS uint32, payload []byte) error { c.mu.Lock() c.resetBuffer() @@ -288,7 +301,7 @@ func (c *Conn) writePublish() error { return err } - v, err := c.readResponse(0) + v, err := c.readResponse(5) if err != nil { return nil } @@ -307,7 +320,8 @@ func (c *Conn) writePlay() error { return err } - v, err := c.readResponse(0) + // Reolink response with ID=0, other software respose with ID=5 + v, err := c.readResponse(5) if err != nil { return nil } @@ -322,7 +336,7 @@ func (c *Conn) writePlay() error { func (c *Conn) readSize(n uint32) ([]byte, error) { b := make([]byte, n) - if _, err := io.ReadAtLeast(c.rd, b, int(n)); err != nil { + if _, err := io.ReadFull(c.rd, b); err != nil { return nil, err } return b, nil diff --git a/pkg/rtmp/flv.go b/pkg/rtmp/flv.go index 87bef0a8..350f4c3c 100644 --- a/pkg/rtmp/flv.go +++ b/pkg/rtmp/flv.go @@ -1,11 +1,10 @@ package rtmp import ( - "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/flv" ) -func (c *Conn) Producer() (core.Producer, error) { +func (c *Conn) Producer() (*flv.Producer, error) { c.rdBuf = []byte{ 'F', 'L', 'V', // signature 1, // version @@ -13,7 +12,17 @@ func (c *Conn) Producer() (core.Producer, error) { 0, 0, 0, 9, // header size } - return flv.Open(c) + prod, err := flv.Open(c) + if err != nil { + return nil, err + } + + prod.FormatName = "rtmp" + prod.Protocol = "rtmp" + prod.RemoteAddr = c.conn.RemoteAddr().String() + prod.URL = c.url + + return prod, nil } // Read - convert RTMP to FLV format diff --git a/pkg/rtmp/server.go b/pkg/rtmp/server.go index f5fc96f8..ed727b98 100644 --- a/pkg/rtmp/server.go +++ b/pkg/rtmp/server.go @@ -17,7 +17,7 @@ func NewServer(conn net.Conn) (*Conn, error) { rd: bufio.NewReaderSize(conn, core.BufferSize), wr: conn, - chunks: map[uint8]*header{}, + chunks: map[uint8]*chunk{}, rdPacketSize: 128, wrPacketSize: 4096, diff --git a/pkg/rtsp/client.go b/pkg/rtsp/client.go index ca32ce32..352c00a1 100644 --- a/pkg/rtsp/client.go +++ b/pkg/rtsp/client.go @@ -20,7 +20,13 @@ import ( var Timeout = time.Second * 5 func NewClient(uri string) *Conn { - return &Conn{uri: uri} + return &Conn{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "rtsp", + }, + uri: uri, + } } func (c *Conn) Dial() (err error) { @@ -36,8 +42,10 @@ func (c *Conn) Dial() (err error) { timeout = time.Second * time.Duration(c.Timeout) } conn, err = tcp.Dial(c.URL, timeout) + c.Protocol = "rtsp+tcp" } else { conn, err = websocket.Dial(c.Transport) + c.Protocol = "ws" } if err != nil { return @@ -53,6 +61,10 @@ func (c *Conn) Dial() (err error) { c.sequence = 0 c.state = StateConn + c.Connection.RemoteAddr = conn.RemoteAddr().String() + c.Connection.Transport = conn + c.Connection.URL = c.uri + return nil } @@ -143,7 +155,7 @@ func (c *Conn) Describe() error { } } - c.sdp = string(res.Body) // for info + c.SDP = string(res.Body) // for info medias, err := UnmarshalSDP(res.Body) if err != nil { @@ -186,10 +198,20 @@ func (c *Conn) Announce() (err error) { return err } - res, err := c.Do(req) + _, err = c.Do(req) + return +} - _ = res +func (c *Conn) Record() (err error) { + req := &tcp.Request{ + Method: MethodRecord, + URL: c.URL, + Header: map[string][]string{ + "Range": {"npt=0.000-"}, + }, + } + _, err = c.Do(req) return } @@ -304,5 +326,8 @@ func (c *Conn) Close() error { if c.mode == core.ModeActiveProducer { _ = c.Teardown() } + if c.OnClose != nil { + _ = c.OnClose() + } return c.conn.Close() } diff --git a/pkg/rtsp/conn.go b/pkg/rtsp/conn.go index e801b7e4..0c2009d7 100644 --- a/pkg/rtsp/conn.go +++ b/pkg/rtsp/conn.go @@ -18,20 +18,20 @@ import ( ) type Conn struct { + core.Connection core.Listener // public Backchannel bool Media string + OnClose func() error PacketSize uint16 SessionName string Timeout int Transport string // custom transport support, ex. RTSP over WebSocket - Medias []*core.Media - UserAgent string - URL *url.URL + URL *url.URL // internal @@ -43,19 +43,10 @@ type Conn struct { reader *bufio.Reader sequence int session string - sdp string uri string state State stateMu sync.Mutex - - receivers []*core.Receiver - senders []*core.Sender - - // stats - - recv int - send int } const ( @@ -113,7 +104,7 @@ func (c *Conn) Handle() (err error) { // polling frames from remote RTSP Server (ex Camera) timeout = time.Second * 5 - if len(c.receivers) == 0 { + if len(c.Receivers) == 0 { // if we only send audio to camera // https://github.com/AlexxIT/go2rtc/issues/659 timeout += keepaliveDT @@ -124,7 +115,11 @@ func (c *Conn) Handle() (err error) { case core.ModePassiveProducer: // polling frames from remote RTSP Client (ex FFmpeg) - timeout = time.Second * 15 + if c.Timeout == 0 { + timeout = time.Second * 15 + } else { + timeout = time.Second * time.Duration(c.Timeout) + } case core.ModePassiveConsumer: // pushing frames to remote RTSP Client (ex VLC) @@ -234,7 +229,7 @@ func (c *Conn) Handle() (err error) { return } - c.recv += int(size) + c.Recv += int(size) if channelID&1 == 0 { packet := &rtp.Packet{} @@ -242,7 +237,7 @@ func (c *Conn) Handle() (err error) { return } - for _, receiver := range c.receivers { + for _, receiver := range c.Receivers { if receiver.ID == channelID { receiver.WriteRTP(packet) break diff --git a/pkg/rtsp/consumer.go b/pkg/rtsp/consumer.go index 4bddd77b..b6df188f 100644 --- a/pkg/rtsp/consumer.go +++ b/pkg/rtsp/consumer.go @@ -18,15 +18,6 @@ func (c *Conn) GetMedias() []*core.Media { } func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) (err error) { - core.Assert(media.Direction == core.DirectionSendonly) - - for _, sender := range c.senders { - if sender.Codec == codec { - sender.HandleRTP(track) - return - } - } - var channel byte switch c.mode { @@ -47,12 +38,12 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv c.state = StateSetup case core.ModePassiveConsumer: - channel = byte(len(c.senders)) * 2 + channel = byte(len(c.Senders)) * 2 // for consumer is better to use original track codec codec = track.Codec.Clone() // generate new payload type, starting from 96 - codec.PayloadType = byte(96 + len(c.senders)) + codec.PayloadType = byte(96 + len(c.Senders)) default: panic(core.Caller()) @@ -70,23 +61,42 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv sender.HandleRTP(track) - c.senders = append(c.senders, sender) + c.Senders = append(c.Senders, sender) return nil } +const ( + startVideoBuf = 32 * 1024 // 32KB + startAudioBuf = 2 * 1024 // 2KB + maxBuf = 1024 * 1024 // 1MB + rtpHdr = 12 // basic RTP header size + intHdr = 4 // interleaved header size +) + func (c *Conn) packetWriter(codec *core.Codec, channel, payloadType uint8) core.HandlerFunc { var buf []byte var n int video := codec.IsVideo() if video { - buf = make([]byte, 32*1024) // 32KB + buf = make([]byte, startVideoBuf) } else { - buf = make([]byte, 2*1024) // 2KB + buf = make([]byte, startAudioBuf) + } + + flushBuf := func() { + if err := c.conn.SetWriteDeadline(time.Now().Add(Timeout)); err != nil { + return + } + //log.Printf("[rtsp] channel:%2d write_size:%6d buffer_size:%6d", channel, n, len(buf)) + if _, err := c.conn.Write(buf[:n]); err == nil { + c.Send += n + } + n = 0 } handlerFunc := func(packet *rtp.Packet) { - if c.state == StateNone || !c.playOK { + if c.state == StateNone { return } @@ -106,16 +116,13 @@ func (c *Conn) packetWriter(codec *core.Codec, channel, payloadType uint8) core. packet.Marker = true // better to have marker on all audio packets } - size := 12 + len(packet.Payload) + size := rtpHdr + len(packet.Payload) - if n+4+size > len(buf) { - if len(buf) < 1024*1024 { - buf = append(buf, make([]byte, len(buf))...) + if l := len(buf); n+intHdr+size > l { + if l < maxBuf { + buf = append(buf, make([]byte, l)...) // double buffer size } else { - if _, err := c.conn.Write(buf[:n]); err == nil { - c.send += n - } - n = 0 + flushBuf() } } @@ -134,21 +141,14 @@ func (c *Conn) packetWriter(codec *core.Codec, channel, payloadType uint8) core. n += 4 + size - if !packet.Marker { - return // collect continious video packets to buffer - } - - if err := c.conn.SetWriteDeadline(time.Now().Add(Timeout)); err != nil { + if !packet.Marker || !c.playOK { + // collect continious video packets to buffer + // or wait OK for PLAY command for backchannel + //log.Printf("[rtsp] collecting buffer ok=%t", c.playOK) return } - //log.Printf("[rtsp] channel:%2d write_size:%6d buffer_size:%6d", channel, n, len(buf)) - - if _, err := c.conn.Write(buf[:n]); err == nil { - c.send += n - } - - n = 0 + flushBuf() } if !codec.IsRTP() { diff --git a/pkg/rtsp/producer.go b/pkg/rtsp/producer.go index d0f36a1c..de115808 100644 --- a/pkg/rtsp/producer.go +++ b/pkg/rtsp/producer.go @@ -10,7 +10,7 @@ import ( func (c *Conn) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { core.Assert(media.Direction == core.DirectionRecvonly) - for _, track := range c.receivers { + for _, track := range c.Receivers { if track.Codec == codec { return track, nil } @@ -34,7 +34,7 @@ func (c *Conn) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, e track := core.NewReceiver(media, codec) track.ID = channel - c.receivers = append(c.receivers, track) + c.Receivers = append(c.Receivers, track) return track, nil } @@ -81,10 +81,10 @@ func (c *Conn) Start() (err error) { } func (c *Conn) Stop() (err error) { - for _, receiver := range c.receivers { + for _, receiver := range c.Receivers { receiver.Close() } - for _, sender := range c.senders { + for _, sender := range c.Senders { sender.Close() } @@ -99,25 +99,7 @@ func (c *Conn) Stop() (err error) { } func (c *Conn) MarshalJSON() ([]byte, error) { - info := &core.Info{ - Type: "RTSP " + c.mode.String(), - SDP: c.sdp, - UserAgent: c.UserAgent, - Medias: c.Medias, - Receivers: c.receivers, - Senders: c.senders, - Recv: c.recv, - Send: c.send, - } - - if c.URL != nil { - info.URL = c.URL.String() - } - if c.conn != nil { - info.RemoteAddr = c.conn.RemoteAddr().String() - } - - return json.Marshal(info) + return json.Marshal(c.Connection) } func (c *Conn) Reconnect() error { @@ -135,12 +117,12 @@ func (c *Conn) Reconnect() error { } // restore previous medias - for _, receiver := range c.receivers { + for _, receiver := range c.Receivers { if _, err := c.SetupMedia(receiver.Media); err != nil { return err } } - for _, sender := range c.senders { + for _, sender := range c.Senders { if _, err := c.SetupMedia(sender.Media); err != nil { return err } diff --git a/pkg/rtsp/server.go b/pkg/rtsp/server.go index 15b3f84b..7953b0dc 100644 --- a/pkg/rtsp/server.go +++ b/pkg/rtsp/server.go @@ -14,10 +14,16 @@ import ( ) func NewServer(conn net.Conn) *Conn { - c := new(Conn) - c.conn = conn - c.reader = bufio.NewReader(conn) - return c + return &Conn{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "rtsp", + Protocol: "rtsp+tcp", + RemoteAddr: conn.RemoteAddr().String(), + }, + conn: conn, + reader: bufio.NewReader(conn), + } } func (c *Conn) Auth(username, password string) { @@ -70,7 +76,7 @@ func (c *Conn) Accept() error { return errors.New("wrong content type") } - c.sdp = string(req.Body) // for info + c.SDP = string(req.Body) // for info c.Medias, err = UnmarshalSDP(req.Body) if err != nil { @@ -81,7 +87,7 @@ func (c *Conn) Accept() error { for i, media := range c.Medias { track := core.NewReceiver(media, media.Codecs[0]) track.ID = byte(i * 2) - c.receivers = append(c.receivers, track) + c.Receivers = append(c.Receivers, track) } c.mode = core.ModePassiveProducer @@ -96,7 +102,7 @@ func (c *Conn) Accept() error { c.mode = core.ModePassiveConsumer c.Fire(MethodDescribe) - if c.senders == nil { + if c.Senders == nil { res := &tcp.Response{ Status: "404 Not Found", Request: req, @@ -113,7 +119,7 @@ func (c *Conn) Accept() error { // convert tracks to real output medias medias var medias []*core.Media - for i, track := range c.senders { + for i, track := range c.Senders { media := &core.Media{ Kind: core.GetKind(track.Codec.Name), Direction: core.DirectionRecvonly, @@ -128,7 +134,7 @@ func (c *Conn) Accept() error { return err } - c.sdp = string(res.Body) // for info + c.SDP = string(res.Body) // for info if err = c.WriteResponse(res); err != nil { return err @@ -146,7 +152,19 @@ func (c *Conn) Accept() error { if strings.HasPrefix(tr, transport) { c.session = core.RandString(8, 10) c.state = StateSetup - res.Header.Set("Transport", tr[:len(transport)+3]) + + if c.mode == core.ModePassiveConsumer { + if i := reqTrackID(req); i >= 0 && i < len(c.Senders) { + // mark sender as SETUP + c.Senders[i].Media.ID = MethodSetup + tr = fmt.Sprintf("RTP/AVP/TCP;unicast;interleaved=%d-%d", i*2, i*2+1) + res.Header.Set("Transport", tr) + } else { + res.Status = "400 Bad Request" + } + } else { + res.Header.Set("Transport", tr[:len(transport)+3]) + } } else { res.Status = "461 Unsupported transport" } @@ -156,6 +174,15 @@ func (c *Conn) Accept() error { } case MethodRecord, MethodPlay: + if c.mode == core.ModePassiveConsumer { + // stop unconfigured senders + for _, track := range c.Senders { + if track.Media.ID != MethodSetup { + track.Close() + } + } + } + res := &tcp.Response{Request: req} err = c.WriteResponse(res) c.playOK = true @@ -172,3 +199,18 @@ func (c *Conn) Accept() error { } } } + +func reqTrackID(req *tcp.Request) int { + var s string + if req.URL.RawQuery != "" { + s = req.URL.RawQuery + } else { + s = req.URL.Path + } + if i := strings.LastIndexByte(s, '='); i > 0 { + if i, err := strconv.Atoi(s[i+1:]); err == nil { + return i + } + } + return -1 +} diff --git a/pkg/stdin/consumer.go b/pkg/stdin/backchannel.go similarity index 88% rename from pkg/stdin/consumer.go rename to pkg/stdin/backchannel.go index a1284948..b9a4a6d4 100644 --- a/pkg/stdin/consumer.go +++ b/pkg/stdin/backchannel.go @@ -49,10 +49,12 @@ func (c *Client) Stop() (err error) { } func (c *Client) MarshalJSON() ([]byte, error) { - info := &core.Info{ - Type: "Exec active consumer", - Medias: c.medias, - Send: c.send, + info := &core.Connection{ + ID: core.ID(c), + FormatName: "exec", + Protocol: "pipe", + Medias: c.medias, + Send: c.send, } if c.sender != nil { info.Senders = []*core.Sender{c.sender} diff --git a/pkg/stdin/client.go b/pkg/stdin/client.go index 51db30ee..09e525ad 100644 --- a/pkg/stdin/client.go +++ b/pkg/stdin/client.go @@ -6,6 +6,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" ) +// Deprecated: should be rewritten to core.Connection type Client struct { cmd *exec.Cmd diff --git a/pkg/tapo/consumer.go b/pkg/tapo/backchannel.go similarity index 100% rename from pkg/tapo/consumer.go rename to pkg/tapo/backchannel.go diff --git a/pkg/tapo/client.go b/pkg/tapo/client.go index ed79e500..3585011c 100644 --- a/pkg/tapo/client.go +++ b/pkg/tapo/client.go @@ -23,6 +23,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/tcp" ) +// Deprecated: should be rewritten to core.Connection type Client struct { core.Listener diff --git a/pkg/tapo/producer.go b/pkg/tapo/producer.go index ac213e15..7d66d907 100644 --- a/pkg/tapo/producer.go +++ b/pkg/tapo/producer.go @@ -2,6 +2,7 @@ package tapo import ( "encoding/json" + "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/mpegts" ) @@ -74,15 +75,20 @@ func (c *Client) Stop() error { } func (c *Client) MarshalJSON() ([]byte, error) { - info := &core.Info{ - Type: "Tapo active producer", - Medias: c.medias, - Recv: c.recv, - Receivers: c.receivers, - Send: c.send, + info := &core.Connection{ + ID: core.ID(c), + FormatName: "tapo", + Protocol: "http", + Medias: c.medias, + Recv: c.recv, + Receivers: c.receivers, + Send: c.send, } if c.sender != nil { info.Senders = []*core.Sender{c.sender} } + if c.conn1 != nil { + info.RemoteAddr = c.conn1.RemoteAddr().String() + } return json.Marshal(info) } diff --git a/pkg/tcp/helpers.go b/pkg/tcp/helpers.go deleted file mode 100644 index 9db42a89..00000000 --- a/pkg/tcp/helpers.go +++ /dev/null @@ -1,12 +0,0 @@ -package tcp - -import ( - "net/http" -) - -func RemoteAddr(r *http.Request) string { - if remote := r.Header.Get("X-Forwarded-For"); remote != "" { - return remote + ", " + r.RemoteAddr - } - return r.RemoteAddr -} diff --git a/pkg/tcp/request.go b/pkg/tcp/request.go index 88da83b6..13463cd7 100644 --- a/pkg/tcp/request.go +++ b/pkg/tcp/request.go @@ -19,11 +19,11 @@ func Do(req *http.Request) (*http.Response, error) { switch req.URL.Scheme { case "httpx": - secure = &tls.Config{InsecureSkipVerify: true} + secure = insecureConfig req.URL.Scheme = "https" case "https": if hostname := req.URL.Hostname(); IsIP(hostname) { - secure = &tls.Config{InsecureSkipVerify: true} + secure = insecureConfig } } @@ -144,6 +144,22 @@ type key string var connKey = key("conn") var secureKey = key("secure") +var insecureConfig = &tls.Config{ + InsecureSkipVerify: true, + CipherSuites: []uint16{ + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + + // this cipher suites disabled starting from https://tip.golang.org/doc/go1.22 + // but cameras can't work without them https://github.com/AlexxIT/go2rtc/issues/1172 + tls.TLS_RSA_WITH_AES_128_GCM_SHA256, // insecure + tls.TLS_RSA_WITH_AES_256_GCM_SHA384, // insecure + }, +} + func WithConn() (context.Context, *net.Conn) { pconn := new(net.Conn) return context.WithValue(context.Background(), connKey, pconn), pconn diff --git a/pkg/wav/producer.go b/pkg/wav/producer.go new file mode 100644 index 00000000..63f6d01a --- /dev/null +++ b/pkg/wav/producer.go @@ -0,0 +1,127 @@ +package wav + +import ( + "bufio" + "encoding/binary" + "errors" + "io" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +const FourCC = "RIFF" + +func Open(r io.Reader) (*Producer, error) { + // https://en.wikipedia.org/wiki/WAV + // https://www.mmsp.ece.mcgill.ca/Documents/AudioFormats/WAVE/WAVE.html + rd := bufio.NewReaderSize(r, core.BufferSize) + + // skip Master RIFF chunk + if _, err := rd.Discard(12); err != nil { + return nil, err + } + + codec := &core.Codec{} + + for { + chunkID, data, err := readChunk(rd) + if err != nil { + return nil, err + } + + if chunkID == "data" { + break + } + + if chunkID == "fmt " { + // https://audiocoding.cc/articles/2008-05-22-wav-file-structure/wav_formats.txt + switch data[0] { + case 1: + codec.Name = core.CodecPCML + case 6: + codec.Name = core.CodecPCMA + case 7: + codec.Name = core.CodecPCMU + } + + codec.Channels = uint16(data[2]) + codec.ClockRate = binary.LittleEndian.Uint32(data[4:]) + } + } + + if codec.Name == "" { + return nil, errors.New("waw: unsupported codec") + } + + medias := []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{codec}, + }, + } + return &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "wav", + Medias: medias, + Transport: r, + }, + rd: rd, + }, nil +} + +type Producer struct { + core.Connection + rd *bufio.Reader +} + +func (c *Producer) Start() error { + var seq uint16 + var ts uint32 + + const PacketSize = 0.040 * 8000 // 40ms + + for { + payload := make([]byte, PacketSize) + if _, err := io.ReadFull(c.rd, payload); err != nil { + return err + } + + c.Recv += PacketSize + + if len(c.Receivers) == 0 { + continue + } + + pkt := &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + SequenceNumber: seq, + Timestamp: ts, + }, + Payload: payload, + } + c.Receivers[0].WriteRTP(pkt) + + seq++ + ts += PacketSize + } +} + +func readChunk(r io.Reader) (chunkID string, data []byte, err error) { + b := make([]byte, 8) + if _, err = io.ReadFull(r, b); err != nil { + return + } + + if chunkID = string(b[:4]); chunkID != "data" { + size := binary.LittleEndian.Uint32(b[4:]) + data = make([]byte, size) + _, err = io.ReadFull(r, data) + } + + return +} diff --git a/pkg/webrtc/api.go b/pkg/webrtc/api.go index 6c7ecada..f63cabfd 100644 --- a/pkg/webrtc/api.go +++ b/pkg/webrtc/api.go @@ -2,6 +2,7 @@ package webrtc import ( "net" + "slices" "github.com/pion/interceptor" "github.com/pion/webrtc/v3" @@ -15,7 +16,15 @@ func NewAPI() (*webrtc.API, error) { return NewServerAPI("", "", nil) } -func NewServerAPI(address, network string, candidateHost []string) (*webrtc.API, error) { +type Filters struct { + Candidates []string `yaml:"candidates"` + Interfaces []string `yaml:"interfaces"` + IPs []string `yaml:"ips"` + Networks []string `yaml:"networks"` + UDPPorts []uint16 `yaml:"udp_ports"` +} + +func NewServerAPI(network, address string, filters *Filters) (*webrtc.API, error) { // for debug logs add to env: `PION_LOG_DEBUG=all` m := &webrtc.MediaEngine{} //if err := m.RegisterDefaultCodecs(); err != nil { @@ -32,23 +41,55 @@ func NewServerAPI(address, network string, candidateHost []string) (*webrtc.API, s := webrtc.SettingEngine{} - // disable listen on Hassio docker interfaces - s.SetInterfaceFilter(func(name string) bool { - return name != "hassio" && name != "docker0" - }) - // fix https://github.com/pion/webrtc/pull/2407 s.SetDTLSInsecureSkipHelloVerify(true) - s.SetReceiveMTU(ReceiveMTU) + if filters != nil && filters.Interfaces != nil { + s.SetIncludeLoopbackCandidate(true) + s.SetInterfaceFilter(func(name string) bool { + return slices.Contains(filters.Interfaces, name) + }) + } else { + // disable listen on Hassio docker interfaces + s.SetInterfaceFilter(func(name string) bool { + return name != "hassio" && name != "docker0" + }) + } - s.SetNAT1To1IPs(candidateHost, webrtc.ICECandidateTypeHost) + if filters != nil && filters.IPs != nil { + s.SetIncludeLoopbackCandidate(true) + s.SetIPFilter(func(ip net.IP) bool { + return slices.Contains(filters.IPs, ip.String()) + }) + } - // by default enable IPv4 + IPv6 modes - s.SetNetworkTypes([]webrtc.NetworkType{ - webrtc.NetworkTypeUDP4, webrtc.NetworkTypeTCP4, - webrtc.NetworkTypeUDP6, webrtc.NetworkTypeTCP6, - }) + if filters != nil && filters.Networks != nil { + var networkTypes []webrtc.NetworkType + for _, s := range filters.Networks { + if networkType, err := webrtc.NewNetworkType(s); err == nil { + networkTypes = append(networkTypes, networkType) + } + } + s.SetNetworkTypes(networkTypes) + } else { + s.SetNetworkTypes([]webrtc.NetworkType{ + webrtc.NetworkTypeUDP4, webrtc.NetworkTypeUDP6, + webrtc.NetworkTypeTCP4, webrtc.NetworkTypeTCP6, + }) + } + + if filters != nil && len(filters.UDPPorts) == 2 { + _ = s.SetEphemeralUDPPortRange(filters.UDPPorts[0], filters.UDPPorts[1]) + } + + //if len(hosts) != 0 { + // // support only: host, srflx + // if candidateType, err := webrtc.NewICECandidateType(hosts[0]); err == nil { + // s.SetNAT1To1IPs(hosts[1:], candidateType) + // } else { + // s.SetNAT1To1IPs(hosts, 0) // 0 = host + // } + //} if address != "" { if network == "" || network == "tcp" { diff --git a/pkg/webrtc/client.go b/pkg/webrtc/client.go index 50c7773d..9a7a7b2f 100644 --- a/pkg/webrtc/client.go +++ b/pkg/webrtc/client.go @@ -71,7 +71,7 @@ func (c *Conn) SetAnswer(answer string) (err error) { return } - c.medias = UnmarshalMedias(sd.MediaDescriptions) + c.Medias = UnmarshalMedias(sd.MediaDescriptions) return nil } diff --git a/pkg/webrtc/conn.go b/pkg/webrtc/conn.go index 64835353..3e3ecc4f 100644 --- a/pkg/webrtc/conn.go +++ b/pkg/webrtc/conn.go @@ -1,6 +1,9 @@ package webrtc import ( + "encoding/json" + "fmt" + "strings" "time" "github.com/AlexxIT/go2rtc/pkg/core" @@ -10,28 +13,25 @@ import ( ) type Conn struct { + core.Connection core.Listener - UserAgent string - Desc string - Mode core.Mode + Mode core.Mode `json:"mode"` pc *webrtc.PeerConnection - medias []*core.Media - receivers []*core.Receiver - senders []*core.Sender - - recv int - send int - offer string - remote string closed core.Waiter } func NewConn(pc *webrtc.PeerConnection) *Conn { - c := &Conn{pc: pc} + c := &Conn{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "webrtc", + }, + pc: pc, + } pc.OnICECandidate(func(candidate *webrtc.ICECandidate) { // last candidate will be empty @@ -50,7 +50,15 @@ func NewConn(pc *webrtc.PeerConnection) *Conn { } pc.SCTP().Transport().ICETransport().OnSelectedCandidatePairChange( func(pair *webrtc.ICECandidatePair) { - c.remote = pair.Remote.String() + c.Protocol += "+" + pair.Remote.Protocol.String() + c.RemoteAddr = fmt.Sprintf( + "%s:%d %s", sanitizeIP6(pair.Remote.Address), pair.Remote.Port, pair.Remote.Typ, + ) + if pair.Remote.RelatedAddress != "" { + c.RemoteAddr += fmt.Sprintf( + " %s:%d", sanitizeIP6(pair.Remote.RelatedAddress), pair.Remote.RelatedPort, + ) + } }, ) }) @@ -92,7 +100,7 @@ func NewConn(pc *webrtc.PeerConnection) *Conn { return } - c.recv += n + c.Recv += n packet := &rtp.Packet{} if err := packet.Unmarshal(b[:n]); err != nil { @@ -120,6 +128,10 @@ func NewConn(pc *webrtc.PeerConnection) *Conn { c.Fire(state) switch state { + case webrtc.PeerConnectionStateConnected: + for _, sender := range c.Senders { + sender.Start() + } case webrtc.PeerConnectionStateDisconnected, webrtc.PeerConnectionStateFailed, webrtc.PeerConnectionStateClosed: // disconnect event comes earlier, than failed // but it comes only for success connections @@ -130,6 +142,10 @@ func NewConn(pc *webrtc.PeerConnection) *Conn { return c } +func (c *Conn) MarshalJSON() ([]byte, error) { + return json.Marshal(c.Connection) +} + func (c *Conn) Close() error { c.closed.Done(nil) return c.pc.Close() @@ -168,7 +184,7 @@ func (c *Conn) getMediaCodec(remote *webrtc.TrackRemote) (*core.Media, *core.Cod } // search Media for this MID - for _, media := range c.medias { + for _, media := range c.Medias { if media.ID != tr.Mid() || media.Direction != core.DirectionRecvonly { continue } @@ -190,3 +206,10 @@ func (c *Conn) getMediaCodec(remote *webrtc.TrackRemote) (*core.Media, *core.Cod return nil, nil } + +func sanitizeIP6(host string) string { + if strings.IndexByte(host, ':') > 0 { + return "[" + host + "]" + } + return host +} diff --git a/pkg/webrtc/consumer.go b/pkg/webrtc/consumer.go index 784b93fe..2dcab436 100644 --- a/pkg/webrtc/consumer.go +++ b/pkg/webrtc/consumer.go @@ -1,7 +1,6 @@ package webrtc import ( - "encoding/json" "errors" "github.com/AlexxIT/go2rtc/pkg/core" @@ -12,15 +11,15 @@ import ( ) func (c *Conn) GetMedias() []*core.Media { - return WithResampling(c.medias) + return WithResampling(c.Medias) } func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { core.Assert(media.Direction == core.DirectionSendonly) - for _, sender := range c.senders { + for _, sender := range c.Senders { if sender.Codec == codec { - sender.HandleRTP(track) + sender.Bind(track) return nil } } @@ -42,7 +41,7 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv sender := core.NewSender(media, codec) sender.Handler = func(packet *rtp.Packet) { - c.send += packet.MarshalSize() + c.Send += packet.MarshalSize() //important to send with remote PayloadType _ = localTrack.WriteRTP(payloadType, packet) } @@ -77,22 +76,14 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv sender.Handler = pcm.RepackG711(false, sender.Handler) } - sender.HandleRTP(track) + // TODO: rewrite this dirty logic + // maybe not best solution, but ActiveProducer connected before AddTrack + if c.Mode != core.ModeActiveProducer { + sender.Bind(track) + } else { + sender.HandleRTP(track) + } - c.senders = append(c.senders, sender) + c.Senders = append(c.Senders, sender) return nil } - -func (c *Conn) MarshalJSON() ([]byte, error) { - info := &core.Info{ - Type: c.Desc + " " + c.Mode.String(), - RemoteAddr: c.remote, - UserAgent: c.UserAgent, - Medias: c.medias, - Receivers: c.receivers, - Senders: c.senders, - Recv: c.recv, - Send: c.send, - } - return json.Marshal(info) -} diff --git a/pkg/webrtc/helpers.go b/pkg/webrtc/helpers.go index 90dd72a1..b6cd3ab3 100644 --- a/pkg/webrtc/helpers.go +++ b/pkg/webrtc/helpers.go @@ -273,38 +273,41 @@ func MimeType(codec *core.Codec) string { panic("not implemented") } -// 4.1.2.2. Guidelines for Choosing Type and Local Preferences -// The RECOMMENDED values are 126 for host candidates, 100 -// for server reflexive candidates, 110 for peer reflexive candidates, -// and 0 for relayed candidates. - -const PriorityTypeHostUDP = (1 << 24) * int(126) -const PriorityTypeHostTCP = (1 << 24) * int(126-27) -const PriorityLocalUDP = (1 << 8) * int(65535) -const PriorityLocalTCPPassive = (1 << 8) * int((1<<13)*4+8191) -const PriorityComponentRTP = 1 * int(256-ice.ComponentRTP) - -func CandidateManualHostUDP(host, port string, offset int) string { - foundation := crc32.ChecksumIEEE([]byte("host" + host + "udp4")) - priority := PriorityTypeHostUDP + PriorityLocalUDP + PriorityComponentRTP + offset - +func CandidateICE(network, host, port string, priority uint32) string { // 1. Foundation // 2. Component, always 1 because RTP - // 3. udp or tcp + // 3. "udp" or "tcp" // 4. Priority // 5. Host - IP4 or IP6 or domain name // 6. Port - // 7. typ host - return fmt.Sprintf("candidate:%d 1 udp %d %s %s typ host", foundation, priority, host, port) + // 7. "typ host" + foundation := crc32.ChecksumIEEE([]byte("host" + host + network + "4")) + s := fmt.Sprintf("candidate:%d 1 %s %d %s %s typ host", foundation, network, priority, host, port) + if network == "tcp" { + return s + " tcptype passive" + } + return s } -func CandidateManualHostTCPPassive(host, port string, offset int) string { - foundation := crc32.ChecksumIEEE([]byte("host" + host + "tcp4")) - priority := PriorityTypeHostTCP + PriorityLocalTCPPassive + PriorityComponentRTP + offset +// Priority = type << 24 + local << 8 + component +// https://www.rfc-editor.org/rfc/rfc8445#section-5.1.2.1 - return fmt.Sprintf( - "candidate:%d 1 tcp %d %s %s typ host tcptype passive", foundation, priority, host, port, - ) +const PriorityHostUDP uint32 = 0x001F_FFFF | + 126<<24 | // udp host + 7<<21 // udp +const PriorityHostTCPPassive uint32 = 0x001F_FFFF | + 99<<24 | // tcp host + 4<<21 // tcp passive + +// CandidateHostPriority (lower indexes has a higher priority) +func CandidateHostPriority(network string, index int) uint32 { + switch network { + case "udp": + return PriorityHostUDP - uint32(index) + case "tcp": + return PriorityHostTCPPassive - uint32(index) + } + return 0 } func UnmarshalICEServers(b []byte) ([]webrtc.ICEServer, error) { diff --git a/pkg/webrtc/producer.go b/pkg/webrtc/producer.go index d4136a5c..a0910c39 100644 --- a/pkg/webrtc/producer.go +++ b/pkg/webrtc/producer.go @@ -8,7 +8,7 @@ import ( func (c *Conn) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { core.Assert(media.Direction == core.DirectionRecvonly) - for _, track := range c.receivers { + for _, track := range c.Receivers { if track.Codec == codec { return track, nil } @@ -39,7 +39,7 @@ func (c *Conn) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, e } track := core.NewReceiver(media, codec) - c.receivers = append(c.receivers, track) + c.Receivers = append(c.Receivers, track) return track, nil } @@ -47,13 +47,3 @@ func (c *Conn) Start() error { c.closed.Wait() return nil } - -func (c *Conn) Stop() error { - for _, receiver := range c.receivers { - receiver.Close() - } - for _, sender := range c.senders { - sender.Close() - } - return c.pc.Close() -} diff --git a/pkg/webrtc/server.go b/pkg/webrtc/server.go index 57efdc7a..9cc89778 100644 --- a/pkg/webrtc/server.go +++ b/pkg/webrtc/server.go @@ -42,7 +42,7 @@ func (c *Conn) SetOffer(offer string) (err error) { } } - c.medias = UnmarshalMedias(sd.MediaDescriptions) + c.Medias = UnmarshalMedias(sd.MediaDescriptions) return } @@ -57,7 +57,7 @@ func (c *Conn) GetAnswer() (answer string, err error) { // disable transceivers if we don't have track, make direction=inactive transeivers: for _, tr := range c.pc.GetTransceivers() { - for _, sender := range c.senders { + for _, sender := range c.Senders { if sender.Media.ID == tr.Mid() { continue transeivers } @@ -81,11 +81,42 @@ transeivers: return c.pc.LocalDescription().SDP, nil } -func (c *Conn) GetCompleteAnswer() (answer string, err error) { - if _, err = c.GetAnswer(); err != nil { - return +// GetCompleteAnswer - get SDP answer with candidates inside +func (c *Conn) GetCompleteAnswer(candidates []string, filter func(*webrtc.ICECandidate) bool) (string, error) { + var done = make(chan struct{}) + + c.pc.OnICECandidate(func(candidate *webrtc.ICECandidate) { + if candidate != nil { + if filter == nil || filter(candidate) { + candidates = append(candidates, candidate.ToJSON().Candidate) + } + } else { + done <- struct{}{} + } + }) + + answer, err := c.GetAnswer() + if err != nil { + return "", err } - <-webrtc.GatheringCompletePromise(c.pc) - return c.pc.LocalDescription().SDP, nil + <-done + + sd := &sdp.SessionDescription{} + if err = sd.Unmarshal([]byte(answer)); err != nil { + return "", err + } + + md := sd.MediaDescriptions[0] + + for _, candidate := range candidates { + md.WithPropertyAttribute(candidate) + } + + b, err := sd.Marshal() + if err != nil { + return "", err + } + + return string(b), nil } diff --git a/pkg/webtorrent/client.go b/pkg/webtorrent/client.go index de6b21c7..3594679d 100644 --- a/pkg/webtorrent/client.go +++ b/pkg/webtorrent/client.go @@ -3,19 +3,21 @@ package webtorrent import ( "encoding/base64" "fmt" + "strconv" + "time" + "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/webrtc" "github.com/gorilla/websocket" pion "github.com/pion/webrtc/v3" - "strconv" - "time" ) func NewClient(tracker, share, pwd string, pc *pion.PeerConnection) (*webrtc.Conn, error) { // 1. Create WebRTC producer prod := webrtc.NewConn(pc) - prod.Desc = "WebRTC/WebTorrent sync" + prod.FormatName = "webtorrent" prod.Mode = core.ModeActiveProducer + prod.Protocol = "ws" medias := []*core.Media{ {Kind: core.KindVideo, Direction: core.DirectionRecvonly}, diff --git a/pkg/y4m/README.md b/pkg/y4m/README.md new file mode 100644 index 00000000..6f4d863e --- /dev/null +++ b/pkg/y4m/README.md @@ -0,0 +1,5 @@ +## Useful links + +- https://learn.microsoft.com/en-us/windows/win32/medfound/recommended-8-bit-yuv-formats-for-video-rendering +- https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Video_concepts +- https://fourcc.org/yuv.php#YV12 diff --git a/pkg/y4m/consumer.go b/pkg/y4m/consumer.go new file mode 100644 index 00000000..dd9b46e9 --- /dev/null +++ b/pkg/y4m/consumer.go @@ -0,0 +1,65 @@ +package y4m + +import ( + "fmt" + "io" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +type Consumer struct { + core.Connection + wr *core.WriteBuffer +} + +func NewConsumer() *Consumer { + wr := core.NewWriteBuffer(nil) + return &Consumer{ + core.Connection{ + ID: core.NewID(), + Transport: wr, + FormatName: "yuv4mpegpipe", + Medias: []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecRAW}, + }, + }, + }, + }, + wr, + } +} + +func (c *Consumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { + sender := core.NewSender(media, track.Codec) + sender.Handler = func(packet *rtp.Packet) { + if n, err := c.wr.Write([]byte(frameHdr)); err == nil { + c.Send += n + } + if n, err := c.wr.Write(packet.Payload); err == nil { + c.Send += n + } + } + + hdr := fmt.Sprintf( + "YUV4MPEG2 W%s H%s C%s\n", + core.Between(track.Codec.FmtpLine, "width=", ";"), + core.Between(track.Codec.FmtpLine, "height=", ";"), + core.Between(track.Codec.FmtpLine, "colorspace=", ";"), + ) + if _, err := c.wr.Write([]byte(hdr)); err != nil { + return err + } + + sender.HandleRTP(track) + c.Senders = append(c.Senders, sender) + return nil +} + +func (c *Consumer) WriteTo(wr io.Writer) (int64, error) { + return c.wr.WriteTo(wr) +} diff --git a/pkg/y4m/producer.go b/pkg/y4m/producer.go new file mode 100644 index 00000000..ee2dd731 --- /dev/null +++ b/pkg/y4m/producer.go @@ -0,0 +1,83 @@ +package y4m + +import ( + "bufio" + "errors" + "io" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +func Open(r io.Reader) (*Producer, error) { + rd := bufio.NewReaderSize(r, core.BufferSize) + b, err := rd.ReadBytes('\n') + if err != nil { + return nil, err + } + + b = b[:len(b)-1] // remove \n + + fmtp := ParseHeader(b) + + if GetSize(fmtp) == 0 { + return nil, errors.New("y4m: unsupported format: " + string(b)) + } + + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: core.CodecRAW, + ClockRate: 90000, + FmtpLine: fmtp, + PayloadType: core.PayloadTypeRAW, + }, + }, + }, + } + return &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "yuv4mpegpipe", + Medias: medias, + SDP: string(b), + Transport: r, + }, + rd: rd, + }, nil +} + +type Producer struct { + core.Connection + rd *bufio.Reader +} + +func (c *Producer) Start() error { + size := GetSize(c.Medias[0].Codecs[0].FmtpLine) + + for { + if _, err := c.rd.Discard(len(frameHdr)); err != nil { + return err + } + + frame := make([]byte, size) + if _, err := io.ReadFull(c.rd, frame); err != nil { + return err + } + + c.Recv += size + + if len(c.Receivers) == 0 { + continue + } + + pkt := &rtp.Packet{ + Header: rtp.Header{Timestamp: core.Now90000()}, + Payload: frame, + } + c.Receivers[0].WriteRTP(pkt) + } +} diff --git a/pkg/y4m/y4m.go b/pkg/y4m/y4m.go new file mode 100644 index 00000000..4ac54da6 --- /dev/null +++ b/pkg/y4m/y4m.go @@ -0,0 +1,125 @@ +package y4m + +import ( + "bytes" + "image" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +const FourCC = "YUV4" + +const frameHdr = "FRAME\n" + +func ParseHeader(b []byte) (fmtp string) { + for b != nil { + // YUV4MPEG2 W1280 H720 F24:1 Ip A1:1 C420mpeg2 XYSCSS=420MPEG2 + // https://manned.org/yuv4mpeg.5 + // https://github.com/FFmpeg/FFmpeg/blob/master/libavformat/yuv4mpegenc.c + key := b[0] + + var value string + if i := bytes.IndexByte(b, ' '); i > 0 { + value = string(b[1:i]) + b = b[i+1:] + } else { + value = string(b[1:]) + b = nil + } + + switch key { + case 'W': + fmtp = "width=" + value + case 'H': + fmtp += ";height=" + value + case 'C': + fmtp += ";colorspace=" + value + } + } + return +} + +func GetSize(fmtp string) int { + w := core.Atoi(core.Between(fmtp, "width=", ";")) + h := core.Atoi(core.Between(fmtp, "height=", ";")) + + switch core.Between(fmtp, "colorspace=", ";") { + case "mono": + return w * h + case "420mpeg2", "420jpeg": + return w * h * 3 / 2 + case "422": + return w * h * 2 + case "444": + return w * h * 3 + } + + return 0 +} + +func NewImage(fmtp string) func(frame []byte) image.Image { + w := core.Atoi(core.Between(fmtp, "width=", ";")) + h := core.Atoi(core.Between(fmtp, "height=", ";")) + rect := image.Rect(0, 0, w, h) + + switch core.Between(fmtp, "colorspace=", ";") { + case "mono": + return func(frame []byte) image.Image { + return &image.Gray{ + Pix: frame, + Stride: w, + Rect: rect, + } + } + case "420mpeg2", "420jpeg": + i1 := w * h + i2 := i1 + i1/4 + i3 := i2 + i1/4 + + return func(frame []byte) image.Image { + return &image.YCbCr{ + Y: frame[:i1], + Cb: frame[i1:i2], + Cr: frame[i2:i3], + YStride: w, + CStride: w / 2, + SubsampleRatio: image.YCbCrSubsampleRatio420, + Rect: rect, + } + } + case "422": + i1 := w * h + i2 := i1 + i1/2 + i3 := i2 + i1/2 + + return func(frame []byte) image.Image { + return &image.YCbCr{ + Y: frame[:i1], + Cb: frame[i1:i2], + Cr: frame[i2:i3], + YStride: w, + CStride: w / 2, + SubsampleRatio: image.YCbCrSubsampleRatio422, + Rect: rect, + } + } + case "444": + i1 := w * h + i2 := i1 + i1 + i3 := i2 + i1 + + return func(frame []byte) image.Image { + return &image.YCbCr{ + Y: frame[:i1], + Cb: frame[i1:i2], + Cr: frame[i2:i3], + YStride: w, + CStride: w, + SubsampleRatio: image.YCbCrSubsampleRatio444, + Rect: rect, + } + } + } + + return nil +} diff --git a/scripts/README.md b/scripts/README.md index b893b312..efcef154 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -14,6 +14,7 @@ go get -u go mod tidy go mod why github.com/pion/rtcp go list -deps .\cmd\go2rtc_rtsp\ +./goweight ``` ## Dependencies diff --git a/website/icons/android-chrome-192x192.png b/website/icons/android-chrome-192x192.png new file mode 100644 index 00000000..bc88ad3e Binary files /dev/null and b/website/icons/android-chrome-192x192.png differ diff --git a/website/icons/android-chrome-512x512.png b/website/icons/android-chrome-512x512.png new file mode 100644 index 00000000..6647f6be Binary files /dev/null and b/website/icons/android-chrome-512x512.png differ diff --git a/website/icons/apple-touch-icon-180x180.png b/website/icons/apple-touch-icon-180x180.png new file mode 100644 index 00000000..b3a60506 Binary files /dev/null and b/website/icons/apple-touch-icon-180x180.png differ diff --git a/website/icons/favicon.ico b/website/icons/favicon.ico new file mode 100644 index 00000000..aade0bf3 Binary files /dev/null and b/website/icons/favicon.ico differ diff --git a/website/manifest.json b/website/manifest.json new file mode 100644 index 00000000..b33a6064 --- /dev/null +++ b/website/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "go2rtc", + "icons": [ + { + "src": "https://alexxit.github.io/go2rtc/icons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "https://alexxit.github.io/go2rtc/icons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "display": "standalone", + "theme_color": "#000000", + "background_color": "#000000" +} diff --git a/website/schema.json b/website/schema.json new file mode 100644 index 00000000..d5e19436 --- /dev/null +++ b/website/schema.json @@ -0,0 +1,486 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "go2rtc", + "type": "object", + "additionalProperties": false, + "definitions": { + "listen": { + "type": "string", + "anyOf": [ + { + "type": "string", + "pattern": ":[0-9]{1,5}$" + }, + { + "type": "string", + "const": "" + } + ] + }, + "log_level": { + "type": "string", + "enum": [ + "trace", + "debug", + "info", + "warn", + "error" + ] + } + }, + "properties": { + "api": { + "type": "object", + "properties": { + "listen": { + "default": ":1984", + "examples": [ + "127.0.0.1:8080" + ], + "$ref": "#/definitions/listen" + }, + "username": { + "type": "string", + "examples": [ + "admin" + ] + }, + "password": { + "type": "string" + }, + "base_path": { + "type": "string", + "examples": [ + "/go2rtc" + ] + }, + "static_dir": { + "type": "string", + "examples": [ + "/var/www" + ] + }, + "origin": { + "type": "string", + "const": "*" + }, + "tls_listen": { + "$ref": "#/definitions/listen" + }, + "tls_cert": { + "type": "string", + "examples": [ + "-----BEGIN CERTIFICATE-----", + "/ssl/fullchain.pem" + ] + }, + "tls_key": { + "type": "string", + "examples": [ + "-----BEGIN PRIVATE KEY-----", + "/ssl/privkey.pem" + ] + }, + "unix_listen": { + "type": "string", + "examples": [ + "/tmp/go2rtc.sock" + ] + } + } + }, + "ffmpeg": { + "type": "object", + "properties": { + "bin": { + "type": "string", + "default": "ffmpeg" + } + }, + "additionalProperties": { + "description": "FFmpeg template", + "type": "string" + } + }, + "hass": { + "type": "object", + "properties": { + "config": { + "description": "Home Assistant config directory path", + "type": "string", + "examples": [ + "/config" + ] + } + } + }, + "homekit": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "pin": { + "type": "string", + "default": "19550224", + "pattern": "^[0-9]{8}$" + }, + "name": { + "type": "string" + }, + "device_id": { + "type": "string" + }, + "device_private": { + "type": "string" + }, + "pairings": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "log": { + "type": "object", + "properties": { + "format": { + "type": "string", + "default": "color", + "enum": [ + "color", + "json", + "text" + ] + }, + "level": { + "description": "Defaul log level", + "default": "info", + "$ref": "#/definitions/log_level" + }, + "output": { + "type": "string", + "default": "stdout", + "enum": [ + "", + "stdout", + "stderr" + ] + }, + "time": { + "type": "string", + "default": "UNIXMS", + "anyOf": [ + { + "type": "string", + "enum": [ + "", + "UNIXMS", + "UNIXMICRO", + "UNIXNANO", + "2006-01-02T15:04:05Z07:00", + "2006-01-02T15:04:05.999999999Z07:00" + ] + }, + { + "type": "string" + } + ] + }, + "api": { + "$ref": "#/definitions/log_level" + }, + "echo": { + "$ref": "#/definitions/log_level" + }, + "exec": { + "description": "Value `exec: debug` will print stderr", + "$ref": "#/definitions/log_level" + }, + "expr": { + "$ref": "#/definitions/log_level" + }, + "ffmpeg": { + "description": "Will only be displayed with `exec: debug` setting", + "default": "error", + "$ref": "#/definitions/log_level" + }, + "hass": { + "$ref": "#/definitions/log_level" + }, + "hls": { + "$ref": "#/definitions/log_level" + }, + "homekit": { + "$ref": "#/definitions/log_level" + }, + "mp4": { + "$ref": "#/definitions/log_level" + }, + "ngrok": { + "$ref": "#/definitions/log_level" + }, + "onvif": { + "$ref": "#/definitions/log_level" + }, + "rtmp": { + "$ref": "#/definitions/log_level" + }, + "rtsp": { + "$ref": "#/definitions/log_level" + }, + "streams": { + "$ref": "#/definitions/log_level" + }, + "webrtc": { + "$ref": "#/definitions/log_level" + }, + "webtorrent": { + "$ref": "#/definitions/log_level" + } + } + }, + "ngrok": { + "type": "object", + "properties": { + "command": { + "type": "string", + "examples": [ + "ngrok tcp 8555 --authtoken xxx", + "ngrok start --all --config ngrok.yaml" + ] + } + } + }, + "publish": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string", + "examples": [ + "rtmp://xxx.rtmp.youtube.com/live2/xxxx-xxxx-xxxx-xxxx-xxxx", + "rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx" + ] + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "rtmp": { + "type": "object", + "properties": { + "listen": { + "examples": [ + ":1935" + ], + "$ref": "#/definitions/listen" + } + } + }, + "rtsp": { + "type": "object", + "properties": { + "listen": { + "default": ":8554", + "$ref": "#/definitions/listen" + }, + "username": { + "type": "string", + "examples": [ + "admin" + ] + }, + "password": { + "type": "string" + }, + "default_query": { + "type": "string", + "default": "video&audio" + }, + "pkt_size": { + "type": "integer" + } + } + }, + "srtp": { + "description": "SRTP server for HomeKit", + "type": "object", + "properties": { + "listen": { + "default": ":8443", + "$ref": "#/definitions/listen" + } + } + }, + "streams": { + "type": "object", + "additionalProperties": { + "title": "Stream", + "anyOf": [ + { + "description": "Source", + "type": "string", + "examples": [ + "rtsp://username:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif", + "rtsp://username:password@192.168.1.123/stream1", + "rtsp://username:password@192.168.1.123/h264Preview_01_main", + "rtmp://192.168.1.123/bcs/channel0_main.bcs?channel=0&stream=0&user=username&password=password", + "http://192.168.1.123/flv?port=1935&app=bcs&stream=channel0_main.bcs&user=username&password=password", + "http://username:password@192.168.1.123/cgi-bin/snapshot.cgi?channel=1", + "ffmpeg:media.mp4#video=h264#hadware#width=1920#height=1080#rotate=180#audio=copy", + "ffmpeg:virtual?video=testsrc&size=4K#video=h264#hardware#bitrate=50M", + "bubble://username:password@192.168.1.123:34567/bubble/live?ch=0&stream=0", + "dvrip://username:password@192.168.1.123:34567?channel=0&subtype=0", + "exec:ffmpeg -re -i media.mp4 -c copy -rtsp_transport tcp -f rtsp {output}", + "isapi://username:password@192.168.1.123:80/", + "kasa://username:password@192.168.1.123:19443/https/stream/mixed", + "onvif://username:password@192.168.1.123:80?subtype=0", + "tapo://password@192.168.1.123:8800?channel=0&subtype=0", + "webtorrent:?share=xxx&pwd=xxx" + ] + }, + { + "type": "array", + "items": { + "description": "Source", + "type": "string" + } + } + ] + } + }, + "webrtc": { + "type": "object", + "properties": { + "listen": { + "default": ":8555/tcp", + "type": "string", + "anyOf": [ + { + "type": "string", + "pattern": ":[0-9]{1,5}(/tcp|/udp)?$" + }, + { + "type": "string", + "const": "" + } + ] + }, + "candidates": { + "type": "array", + "items": { + "$ref": "#/definitions/listen/anyOf/0" + }, + "examples": [ + "216.58.210.174:8555", + "stun:8555", + "home.duckdns.org:8555" + ] + }, + "ice_servers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "urls": { + "type": "array", + "items": { + "type": "string", + "examples": [ + "stun:stun.l.google.com:19302", + "turn:123.123.123.123:3478" + ] + } + }, + "username": { + "type": "string" + }, + "credential": { + "type": "string" + } + } + } + }, + "filters": { + "type": "object", + "properties": { + "candidates": { + "description": "Keep only these candidates", + "type": "array", + "items": { + "type": "string" + } + }, + "interfaces": { + "description": "Keep only these interfaces", + "type": "array", + "items": { + "type": "string" + } + }, + "ips": { + "description": "Keep only these IP-addresses", + "type": "array", + "items": { + "type": "string" + } + }, + "networks": { + "description": "Use only these network types", + "type": "array", + "items": { + "enum": [ + "tcp4", + "tcp6", + "udp4", + "udp6" + ], + "type": "string" + } + }, + "udp_ports": { + "description": "Use only these UDP ports range [min, max]", + "type": "array", + "items": { + "type": "integer" + }, + "maxItems": 2, + "minItems": 2 + } + } + } + } + }, + "webtorrent": { + "type": "object", + "properties": { + "trackers": { + "type": "array", + "items": { + "type": "string" + } + }, + "shares": { + "additionalProperties": { + "type": "object", + "properties": { + "pwd": { + "type": "string" + }, + "src": { + "type": "string" + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/www/README.md b/www/README.md index 355a9af3..4da49485 100644 --- a/www/README.md +++ b/www/README.md @@ -72,6 +72,22 @@ User-Agent: `Mozilla/5.0 (X11; Linux armv7l) AppleWebKit/537.36 (KHTML, like Gec https://webrtc.org/getting-started/unified-plan-transition-guide?hl=en +## Web Icons + +[Favicon checker](https://realfavicongenerator.net/), skip: + +- Windows 8 and 10 (`browserconfig.xml`) +- Mac OS X El Capitan Safari + +```html + + + + + + +``` + ## Useful links - https://www.webrtc-experiment.com/DetectRTC/ diff --git a/www/add.html b/www/add.html index 3058f8dc..4b40f431 100644 --- a/www/add.html +++ b/www/add.html @@ -1,7 +1,7 @@ - Add Stream + go2rtc - Add Stream + + + + + + + \ No newline at end of file diff --git a/www/index.html b/www/index.html index 983e26f1..63fedcec 100644 --- a/www/index.html +++ b/www/index.html @@ -4,6 +4,9 @@ + + + go2rtc + + +
+ + + + diff --git a/www/stream.html b/www/stream.html index 9886f1c3..a2284ba5 100644 --- a/www/stream.html +++ b/www/stream.html @@ -2,6 +2,9 @@ + + + go2rtc - Stream