Merge branch 'AlexxIT:master' into master

This commit is contained in:
Rob van Oostenrijk
2024-06-22 18:23:28 +04:00
committed by GitHub
194 changed files with 5073 additions and 1827 deletions
+25 -5
View File
@@ -19,7 +19,7 @@ jobs:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: { go-version: '1.21' } with: { go-version: '1.22' }
- name: Build go2rtc_win64 - name: Build go2rtc_win64
env: { GOOS: windows, GOARCH: amd64 } env: { GOOS: windows, GOARCH: amd64 }
@@ -123,7 +123,9 @@ jobs:
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ github.repository }} images: |
${{ github.repository }}
ghcr.io/${{ github.repository }}
tags: | tags: |
type=ref,event=branch type=ref,event=branch
type=semver,pattern={{version}},enable=false type=semver,pattern={{version}},enable=false
@@ -142,6 +144,14 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} 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 - name: Build and push
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
@@ -168,10 +178,12 @@ jobs:
id: meta-hw id: meta-hw
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ github.repository }} images: |
${{ github.repository }}
ghcr.io/${{ github.repository }}
flavor: | flavor: |
suffix=-hardware suffix=-hardware,onlatest=true
latest=false latest=auto
tags: | tags: |
type=ref,event=branch type=ref,event=branch
type=semver,pattern={{version}},enable=false type=semver,pattern={{version}},enable=false
@@ -189,6 +201,14 @@ jobs:
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} 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 - name: Build and push
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: '1.21' go-version: '1.22'
- name: Build Go binary - name: Build Go binary
run: go build -ldflags "-s -w" -trimpath -o ./go2rtc run: go build -ldflags "-s -w" -trimpath -o ./go2rtc
+4
View File
@@ -4,6 +4,10 @@
go2rtc.yaml go2rtc.yaml
go2rtc.json go2rtc.json
go2rtc_linux*
go2rtc_mac*
go2rtc_win*
0_test.go 0_test.go
.DS_Store .DS_Store
+3 -1
View File
@@ -2,7 +2,7 @@
# 0. Prepare images # 0. Prepare images
ARG PYTHON_VERSION="3.11" ARG PYTHON_VERSION="3.11"
ARG GO_VERSION="1.21" ARG GO_VERSION="1.22"
ARG NGROK_VERSION="3" ARG NGROK_VERSION="3"
FROM python:${PYTHON_VERSION}-alpine AS base FROM python:${PYTHON_VERSION}-alpine AS base
@@ -20,6 +20,8 @@ ENV GOARCH=${TARGETARCH}
WORKDIR /build WORKDIR /build
RUN apk add git
# Cache dependencies # Cache dependencies
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN --mount=type=cache,target=/root/.cache/go-build go mod download RUN --mount=type=cache,target=/root/.cache/go-build go mod download
+24 -11
View File
@@ -1,6 +1,6 @@
<h1 align="center"> <h1 align="center">
![go2rtc](assets/logo.png) ![go2rtc](assets/logo.gif)
<br> <br>
[![stars](https://img.shields.io/github/stars/AlexxIT/go2rtc?style=flat-square&logo=github)](https://github.com/AlexxIT/go2rtc/stargazers) [![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) [![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 ### 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 ### 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} 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_h264: exec:libcamera-vid -t 0 --inline -o -
picam_mjpeg: exec:libcamera-vid -t 0 --codec mjpeg -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 canon: exec:gphoto2 --capture-movie --stdout#killsignal=2#killtimeout=5
play_pcma: exec:ffplay -fflags nobuffer -f alaw -ar 8000 -i -#backchannel=1 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 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). [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 ```yaml
streams: 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 #### Source: GoPro
*[New in v1.8.3](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.3)* *[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: You can publish any stream to streaming services (YouTube, Telegram, etc.) via RTMP/RTMPS. Important:
- Supported codecs: H264 for video and AAC for audio - 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 don't need to enable [RTMP module](#module-rtmp) listening for this task
You can use API: You can use API:
@@ -786,16 +792,19 @@ Or config file:
```yaml ```yaml
publish: publish:
# publish stream "tplink_tapo" to Telegram # publish stream "video_audio_transcode" to Telegram
tplink_tapo: rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx video_audio_transcode:
# publish stream "other_camera" to Telegram and YouTube
other_camera:
- rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx - 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: streams:
# for TP-Link cameras it's important to use transcoding because of wrong pixel format video_audio_transcode:
tplink_tapo: ffmpeg:rtsp://user:pass@192.168.1.123/stream1#video=h264#hardware#audio=aac - 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. - **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 `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) - 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 ### Module: Log
You can set different log levels for different modules. You can set different log levels for different modules.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

+39
View File
@@ -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()
}
+12 -12
View File
@@ -1,26 +1,27 @@
module github.com/AlexxIT/go2rtc module github.com/AlexxIT/go2rtc
go 1.21 go 1.22
require ( require (
github.com/asticode/go-astits v1.13.0 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/gorilla/websocket v1.5.1
github.com/mattn/go-isatty v0.0.20
github.com/miekg/dns v1.1.59 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/interceptor v0.1.29
github.com/pion/rtcp v1.2.14 github.com/pion/rtcp v1.2.14
github.com/pion/rtp v1.8.6 github.com/pion/rtp v1.8.6
github.com/pion/sdp/v3 v3.0.9 github.com/pion/sdp/v3 v3.0.9
github.com/pion/srtp/v2 v2.0.18 github.com/pion/srtp/v2 v2.0.18
github.com/pion/stun v0.6.1 github.com/pion/stun v0.6.1
github.com/pion/webrtc/v3 v3.2.39 github.com/pion/webrtc/v3 v3.2.40
github.com/rs/zerolog v1.32.0 github.com/rs/zerolog v1.33.0
github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f
github.com/stretchr/testify v1.9.0 github.com/stretchr/testify v1.9.0
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 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 gopkg.in/yaml.v3 v3.0.1
) )
@@ -30,9 +31,8 @@ require (
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/kr/pretty v0.2.1 // indirect github.com/kr/pretty v0.2.1 // indirect
github.com/mattn/go-colorable v0.1.13 // 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/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/logging v0.2.2 // indirect
github.com/pion/mdns v0.0.12 // indirect github.com/pion/mdns v0.0.12 // indirect
github.com/pion/randutil v0.1.0 // 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/transport/v2 v2.2.5 // indirect
github.com/pion/turn/v2 v2.1.6 // indirect github.com/pion/turn/v2 v2.1.6 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/mod v0.17.0 // indirect golang.org/x/mod v0.18.0 // indirect
golang.org/x/net v0.24.0 // indirect golang.org/x/net v0.26.0 // indirect
golang.org/x/sync v0.7.0 // indirect golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.19.0 // indirect golang.org/x/sys v0.21.0 // indirect
golang.org/x/tools v0.20.0 // indirect golang.org/x/tools v0.22.0 // indirect
) )
+26
View File
@@ -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/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 h1:m2hvtguFeVaVNTHj8L7BoAyt7O0PAIBaSVbjdHgRXMs=
github.com/expr-lang/expr v1.16.5/go.mod h1:uCkhfG+x7fcZ5A5sXHKuQ07jGZRl6J0FCAaf2k4PtVQ= 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/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.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 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.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 h1:u2Axk+FyIR1VFTPurktB+1zoEPGIW3bmyj3LEFrXjAA=
github.com/pion/dtls/v2 v2.2.10/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= 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 h1:1GoMRTMnB6bCP4aGy2MjxK3w4laDkk+m7svJb/eqybc=
github.com/pion/ice/v2 v2.3.19/go.mod h1:KXJJcZK7E8WzrBEYnV4UtqEZsGeWfHxsNqhVcVvgjxw= 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 h1:39fsnlP1U8gw2JzOFWdfCU82vHvhW9o0rZnZF56wF+M=
github.com/pion/interceptor v0.1.29/go.mod h1:ri+LGNjRUc5xUNtDEPzfdkmSqISixVTBF/z/Zms/6T4= 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= 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/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 h1:Lf2SIMGdE3M9VNm48KpoX5pR8SJ6TsMnktzOkc/oB0o=
github.com/pion/webrtc/v3 v3.2.39/go.mod h1:AQ8p56OLbm3MjhYovYdgPuyX6oc+JcKx/HFoCGFcYzA= 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/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE= github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/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 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0=
github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= 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 h1:NVK+OqnavpyFmUiKfUMHrpvbCi2VFoWTrcpI7aDaJ2I=
github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA= 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= 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.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 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= 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.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.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 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 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-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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 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.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= 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.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-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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/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.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 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 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.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 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY=
golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg= 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= 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 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
+1 -1
View File
@@ -4,7 +4,7 @@
# only debian 13 (trixie) has latest ffmpeg # only debian 13 (trixie) has latest ffmpeg
# https://packages.debian.org/trixie/ffmpeg # https://packages.debian.org/trixie/ffmpeg
ARG DEBIAN_VERSION="trixie-slim" ARG DEBIAN_VERSION="trixie-slim"
ARG GO_VERSION="1.21-bookworm" ARG GO_VERSION="1.22-bookworm"
ARG NGROK_VERSION="3" ARG NGROK_VERSION="3"
FROM debian:${DEBIAN_VERSION} AS base FROM debian:${DEBIAN_VERSION} AS base
+2 -2
View File
@@ -83,7 +83,7 @@ func initWS(origin string) {
if o.Host == r.Host { if o.Host == r.Host {
return true 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 // https://github.com/AlexxIT/go2rtc/issues/118
if i := strings.IndexByte(o.Host, ':'); i > 0 { if i := strings.IndexByte(o.Host, ':'); i > 0 {
return o.Host[:i] == r.Host return o.Host[:i] == r.Host
@@ -127,7 +127,7 @@ func apiWS(w http.ResponseWriter, r *http.Request) {
break 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 { if handler := wsHandlers[msg.Type]; handler != nil {
go func() { go func() {
+70
View File
@@ -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" ]
```
+62 -105
View File
@@ -1,144 +1,101 @@
package app package app
import ( import (
"errors"
"flag" "flag"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"runtime" "runtime"
"strings" "runtime/debug"
"github.com/AlexxIT/go2rtc/pkg/shell"
"github.com/AlexxIT/go2rtc/pkg/yaml"
"github.com/rs/zerolog/log"
) )
var Version = "1.9.0" var (
var UserAgent = "go2rtc/" + Version Version string
UserAgent string
ConfigPath string
Info = make(map[string]any)
)
var ConfigPath string const usage = `Usage of go2rtc:
var Info = map[string]any{
"version": Version, -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() { func Init() {
var confs Config var config flagConfig
var daemon bool var daemon bool
var version bool var version bool
flag.Var(&confs, "config", "go2rtc config (path to file or raw text), support multiple") flag.Var(&config, "config", "")
if runtime.GOOS != "windows" { flag.Var(&config, "c", "")
flag.BoolVar(&daemon, "daemon", false, "Run program in background") flag.BoolVar(&daemon, "daemon", false, "")
} flag.BoolVar(&daemon, "d", false, "")
flag.BoolVar(&version, "version", false, "Print the version of the application and exit") flag.BoolVar(&version, "version", false, "")
flag.BoolVar(&version, "v", false, "")
flag.Usage = func() { fmt.Print(usage) }
flag.Parse() flag.Parse()
revision, vcsTime := readRevisionTime()
if version { 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) os.Exit(0)
} }
if daemon { if daemon && os.Getppid() != 1 {
args := os.Args[1:] if runtime.GOOS == "windows" {
for i, arg := range args { fmt.Println("Daemon mode is not supported on Windows")
if arg == "-daemon" { os.Exit(1)
args[i] = ""
}
} }
// Re-run the program in background and exit // 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 { 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) fmt.Println("Running in daemon mode with PID:", cmd.Process.Pid)
os.Exit(0) os.Exit(0)
} }
if confs == nil { UserAgent = "go2rtc/" + Version
confs = []string{"go2rtc.yaml"}
}
for _, conf := range confs { Info["version"] = Version
if conf[0] != '{' { Info["revision"] = revision
// config as file
if ConfigPath == "" {
ConfigPath = conf
}
data, _ := os.ReadFile(conf) initConfig(config)
if data == nil { initLogger()
continue
}
data = []byte(shell.ReplaceEnvVars(string(data))) platform := fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)
configs = append(configs, data) Logger.Info().Str("version", Version).Str("platform", platform).Str("revision", revision).Msg("go2rtc")
} else { Logger.Debug().Str("version", runtime.Version()).Str("vcs.time", vcsTime).Msg("build")
// config as raw YAML
configs = append(configs, []byte(conf))
}
}
if ConfigPath != "" { if ConfigPath != "" {
if !filepath.IsAbs(ConfigPath) { Logger.Info().Str("path", ConfigPath).Msg("config")
if cwd, err := os.Getwd(); err == nil { }
ConfigPath = filepath.Join(cwd, ConfigPath) }
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
} }
return
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()
} }
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
+109
View File
@@ -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)
}
+80 -29
View File
@@ -4,49 +4,100 @@ import (
"io" "io"
"os" "os"
"github.com/mattn/go-isatty"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/rs/zerolog/log"
) )
var MemoryLog *circularBuffer var MemoryLog = newBuffer(16)
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)
}
func GetLogger(module string) zerolog.Logger { func GetLogger(module string) zerolog.Logger {
if s, ok := modules[module]; ok { if s, ok := modules[module]; ok {
lvl, err := zerolog.ParseLevel(s) lvl, err := zerolog.ParseLevel(s)
if err == nil { 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 // 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 const chunkSize = 1 << 16
-35
View File
@@ -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)
}
+3 -9
View File
@@ -7,13 +7,7 @@ import (
) )
func Init() { func Init() {
streams.HandleFunc("bubble", handle) streams.HandleFunc("bubble", func(source string) (core.Producer, error) {
} return bubble.Dial(source)
})
func handle(url string) (core.Producer, error) {
conn := bubble.NewClient(url)
if err := conn.Dial(); err != nil {
return nil, err
}
return conn, nil
} }
-8
View File
@@ -2,16 +2,8 @@ package debug
import ( import (
"github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core"
) )
func Init() { func Init() {
api.HandleFunc("api/stack", stackHandler) api.HandleFunc("api/stack", stackHandler)
streams.HandleFunc("null", nullHandler)
}
func nullHandler(string) (core.Producer, error) {
return nil, nil
} }
+2 -15
View File
@@ -10,26 +10,16 @@ import (
"github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/dvrip" "github.com/AlexxIT/go2rtc/pkg/dvrip"
"github.com/rs/zerolog/log"
) )
func Init() { func Init() {
streams.HandleFunc("dvrip", handle) streams.HandleFunc("dvrip", dvrip.Dial)
// DVRIP client autodiscovery // DVRIP client autodiscovery
api.HandleFunc("api/dvrip", apiDvrip) 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 const Port = 34569 // UDP port number for dvrip discovery
func apiDvrip(w http.ResponseWriter, r *http.Request) { func apiDvrip(w http.ResponseWriter, r *http.Request) {
@@ -92,10 +82,7 @@ func sendBroadcasts(conn *net.UDPConn) {
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
_, _ = conn.WriteToUDP(data, addr)
if _, err = conn.WriteToUDP(data, addr); err != nil {
log.Err(err).Caller().Send()
}
} }
} }
+39
View File
@@ -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())
}
+100 -43
View File
@@ -1,6 +1,7 @@
package exec package exec
import ( import (
"bufio"
"crypto/md5" "crypto/md5"
"encoding/hex" "encoding/hex"
"errors" "errors"
@@ -9,6 +10,7 @@ import (
"net/url" "net/url"
"os" "os"
"os/exec" "os/exec"
"slices"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -49,8 +51,10 @@ func Init() {
} }
func execHandle(rawURL string) (core.Producer, error) { func execHandle(rawURL string) (core.Producer, error) {
rawURL, rawQuery, _ := strings.Cut(rawURL, "#")
query := streams.ParseQuery(rawQuery)
var path string var path string
var query url.Values
// RTSP flow should have `{output}` inside URL // RTSP flow should have `{output}` inside URL
// pipe flow may have `#{params}` 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)) sum := md5.Sum([]byte(rawURL))
path = "/" + hex.EncodeToString(sum[:]) path = "/" + hex.EncodeToString(sum[:])
rawURL = rawURL[:i] + "rtsp://127.0.0.1:" + rtsp.Port + path + rawURL[i+8:] 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:` args := shell.QuoteSplit(rawURL[5:]) // remove `exec:`
cmd := exec.Command(args[0], args[1:]...) cmd := exec.Command(args[0], args[1:]...)
if log.Debug().Enabled() { cmd.Stderr = &logWriter{
cmd.Stderr = os.Stderr 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" { if query.Get("backchannel") == "1" {
return stdin.NewClient(cmd) 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 { if err != nil {
return nil, err 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 { if err = cmd.Start(); err != nil {
return nil, err return nil, err
} }
prod, err := magic.Open(r) prod, err := magic.Open(rc)
if err != nil { 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) { func handleRTSP(source string, cmd *exec.Cmd, cl io.Closer, 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
}
if log.Trace().Enabled() { if log.Trace().Enabled() {
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
} }
waiter := make(chan core.Producer) waiter := make(chan *pkg.Conn, 1)
waitersMu.Lock() waitersMu.Lock()
waiters[path] = waiter waiters[path] = waiter
@@ -127,12 +144,12 @@ func handleRTSP(url string, cmd *exec.Cmd, path string) (core.Producer, error) {
waitersMu.Unlock() 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() ts := time.Now()
if err := cmd.Start(); err != nil { 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 return nil, err
} }
@@ -142,15 +159,17 @@ func handleRTSP(url string, cmd *exec.Cmd, path string) (core.Producer, error) {
}() }()
select { select {
case <-time.After(time.Second * 60): case <-time.After(time.Minute):
_ = cmd.Process.Kill() log.Error().Str("source", source).Msg("[exec] timeout")
log.Error().Str("url", url).Msg("[exec] timeout") _ = cl.Close()
return nil, errors.New("timeout") return nil, errors.New("exec: timeout")
case <-done: case <-done:
// limit message size // limit message size
return nil, errors.New("exec: " + stderr.String()) return nil, fmt.Errorf("exec/rtsp\n%s", cmd.Stderr)
case prod := <-waiter: 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 return prod, nil
} }
} }
@@ -159,25 +178,63 @@ func handleRTSP(url string, cmd *exec.Cmd, path string) (core.Producer, error) {
var ( var (
log zerolog.Logger log zerolog.Logger
waiters = map[string]chan core.Producer{} waiters = make(map[string]chan *pkg.Conn)
waitersMu sync.Mutex waitersMu sync.Mutex
) )
type limitBuffer struct { type logWriter struct {
buf []byte buf []byte
n int debug bool
n int
} }
func (l *limitBuffer) String() string { func (l *logWriter) String() string {
if l.n == len(l.buf) { if l.n == len(l.buf) {
return string(l.buf) + "..." return string(l.buf) + "..."
} }
return string(l.buf[:l.n]) 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) { if l.n < cap(l.buf) {
l.n += copy(l.buf[l.n:], p) 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)
}
}
} }
-56
View File
@@ -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()
}
+7
View File
@@ -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 [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 ## Useful links
- https://superuser.com/questions/564402/explanation-of-x264-tune - https://superuser.com/questions/564402/explanation-of-x264-tune
+51
View File
@@ -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)
}
}
@@ -1,3 +1,5 @@
//go:build freebsd || netbsd || openbsd || dragonfly
package device package device
import ( import (
+2
View File
@@ -1,3 +1,5 @@
//go:build darwin || ios
package device package device
import ( import (
@@ -1,3 +1,5 @@
//go:build unix && !darwin && !freebsd && !netbsd && !openbsd && !dragonfly
package device package device
import ( import (
+2
View File
@@ -1,3 +1,5 @@
//go:build windows
package device package device
import ( import (
+4 -15
View File
@@ -1,11 +1,9 @@
package device package device
import ( import (
"errors"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings"
"sync" "sync"
"github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/api"
@@ -17,24 +15,15 @@ func Init(bin string) {
api.HandleFunc("api/ffmpeg/devices", apiDevices) api.HandleFunc("api/ffmpeg/devices", apiDevices)
} }
func GetInput(src string) (string, error) { func GetInput(src string) string {
i := strings.IndexByte(src, '?') query, err := url.ParseQuery(src)
if i < 0 {
return "", errors.New("empty query: " + src)
}
query, err := url.ParseQuery(src[i+1:])
if err != nil { if err != nil {
return "", err return ""
} }
runonce.Do(initDevices) runonce.Do(initDevices)
if input := queryToInput(query); input != "" { return queryToInput(query)
return input, nil
}
return "", errors.New("wrong query: " + src)
} }
var Bin string var Bin string
+76 -21
View File
@@ -2,35 +2,58 @@ package ffmpeg
import ( import (
"net/url" "net/url"
"slices"
"strings" "strings"
"github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/internal/ffmpeg/device" "github.com/AlexxIT/go2rtc/internal/ffmpeg/device"
"github.com/AlexxIT/go2rtc/internal/ffmpeg/hardware" "github.com/AlexxIT/go2rtc/internal/ffmpeg/hardware"
"github.com/AlexxIT/go2rtc/internal/ffmpeg/virtual" "github.com/AlexxIT/go2rtc/internal/ffmpeg/virtual"
"github.com/AlexxIT/go2rtc/internal/rtsp" "github.com/AlexxIT/go2rtc/internal/rtsp"
"github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/ffmpeg" "github.com/AlexxIT/go2rtc/pkg/ffmpeg"
"github.com/rs/zerolog"
) )
func Init() { func Init() {
var cfg struct { var cfg struct {
Mod map[string]string `yaml:"ffmpeg"` Mod map[string]string `yaml:"ffmpeg"`
Log struct {
Level string `yaml:"ffmpeg"`
} `yaml:"log"`
} }
cfg.Mod = defaults // will be overriden from yaml cfg.Mod = defaults // will be overriden from yaml
cfg.Log.Level = "error"
app.LoadConfig(&cfg) app.LoadConfig(&cfg)
if app.GetLogger("exec").GetLevel() >= 0 { log = app.GetLogger("ffmpeg")
defaults["global"] += " -v error"
// 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) { streams.RedirectFunc("ffmpeg", func(url string) (string, error) {
if _, err := Version(); err != nil {
return "", err
}
args := parseArgs(url[7:]) args := parseArgs(url[7:])
if slices.Contains(args.Codecs, "auto") {
return "", nil // force call streams.HandleFunc("ffmpeg")
}
return "exec:" + args.String(), nil return "exec:" + args.String(), nil
}) })
streams.HandleFunc("ffmpeg", NewProducer)
api.HandleFunc("api/ffmpeg", apiFFmpeg)
device.Init(defaults["bin"]) device.Init(defaults["bin"])
hardware.Init(defaults["bin"]) hardware.Init(defaults["bin"])
} }
@@ -49,16 +72,25 @@ var defaults = map[string]string{
// output // output
"output": "-user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}", "output": "-user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}",
"output/mjpeg": "-f mjpeg -", "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` // `-preset superfast` - we can't use ultrafast because it doesn't support `-profile main -level 4.1`
// `-tune zerolatency` - for minimal latency // `-tune zerolatency` - for minimal latency
// `-profile high -level 4.1` - most used streaming profile // `-profile high -level 4.1` - most used streaming profile
// `-pix_fmt:v yuv420p` - important for Telegram // `-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", "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",
//"mjpeg": "-c:v mjpeg -force_duplicated_matrix:v 1 -huffman:v 0 -pix_fmt:v yuvj420p", //"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://ffmpeg.org/ffmpeg-codecs.html#libopus-1
// https://github.com/pion/webrtc/issues/1514 // https://github.com/pion/webrtc/issues/1514
// https://ffmpeg.org/ffmpeg-resampler.html // 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", "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 // configTemplate - return template from config (defaults) if exist or return raw template
func configTemplate(template string) string { func configTemplate(template string) string {
if s := defaults[template]; s != "" { if s := defaults[template]; s != "" {
@@ -140,9 +174,10 @@ func inputTemplate(name, s string, query url.Values) string {
func parseArgs(s string) *ffmpeg.Args { func parseArgs(s string) *ffmpeg.Args {
// init FFmpeg arguments // init FFmpeg arguments
args := &ffmpeg.Args{ args := &ffmpeg.Args{
Bin: defaults["bin"], Bin: defaults["bin"],
Global: defaults["global"], Global: defaults["global"],
Output: defaults["output"], Output: defaults["output"],
Version: verAV,
} }
var query url.Values var query url.Values
@@ -188,16 +223,14 @@ func parseArgs(s string) *ffmpeg.Args {
s += "?video&audio" s += "?video&audio"
} }
args.Input = inputTemplate("rtsp", s, query) args.Input = inputTemplate("rtsp", s, query)
} else if strings.HasPrefix(s, "device?") { } else if i = strings.Index(s, "?"); i > 0 {
var err error switch s[:i] {
args.Input, err = device.GetInput(s) case "device":
if err != nil { args.Input = device.GetInput(s[i+1:])
return nil case "virtual":
} args.Input = virtual.GetInput(s[i+1:])
} else if strings.HasPrefix(s, "virtual?") { case "tts":
var err error args.Input = virtual.GetInputTTS(s[i+1:])
if args.Input, err = virtual.GetInput(s[8:]); err != nil {
return nil
} }
} else { } else {
args.Input = inputTemplate("file", s, query) 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 // 4. Process audio codecs
if args.Audio > 0 { if args.Audio > 0 {
for _, audio := range query["audio"] { for _, audio := range query["audio"] {
@@ -309,11 +348,27 @@ func parseArgs(s string) *ffmpeg.Args {
args.AddCodec("-an") args.AddCodec("-an")
} }
// transcoding to only mjpeg // change otput from RTSP to some other pipe format
if (args.Video == 1 && args.Audio == 0 && query.Get("video") == "mjpeg") || switch {
// no transcoding from mjpeg input case args.Video == 0 && args.Audio == 0:
(args.Video == 0 && args.Audio == 0 && strings.Contains(args.Input, " mjpeg ")) { // no transcoding from mjpeg input (ffmpeg device with support output as raw MJPEG)
args.Output = defaults["output/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 return args
+21
View File
@@ -3,6 +3,7 @@ package ffmpeg
import ( import (
"testing" "testing"
"github.com/AlexxIT/go2rtc/pkg/ffmpeg"
"github.com/stretchr/testify/require" "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())
})
}
}
-3
View File
@@ -7,8 +7,6 @@ import (
"github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/pkg/ffmpeg" "github.com/AlexxIT/go2rtc/pkg/ffmpeg"
"github.com/rs/zerolog/log"
) )
const ( const (
@@ -152,7 +150,6 @@ var cache = map[string]string{}
func run(bin string, args string) bool { func run(bin string, args string) bool {
err := exec.Command(bin, strings.Split(args, " ")...).Run() err := exec.Command(bin, strings.Split(args, " ")...).Run()
log.Printf("%v %v", args, err)
return err == nil return err == nil
} }
@@ -1,3 +1,5 @@
//go:build freebsd || netbsd || openbsd || dragonfly
package hardware package hardware
import ( import (
@@ -1,3 +1,5 @@
//go:build darwin || ios
package hardware package hardware
import ( import (
@@ -1,3 +1,5 @@
//go:build unix && !darwin && !freebsd && !netbsd && !openbsd && !dragonfly
package hardware package hardware
import ( import (
@@ -1,3 +1,5 @@
//go:build windows
package hardware package hardware
import "github.com/AlexxIT/go2rtc/internal/api" import "github.com/AlexxIT/go2rtc/internal/api"
+118
View File
@@ -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
}
+46
View File
@@ -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
}
+62 -42
View File
@@ -4,56 +4,76 @@ import (
"net/url" "net/url"
) )
func GetInput(src string) (string, error) { func GetInput(src string) string {
query, err := url.ParseQuery(src) query, err := url.ParseQuery(src)
if err != nil { if err != nil {
return "", err return ""
} }
// set defaults (using Add instead of Set) input := "-re"
query.Add("source", "testsrc")
query.Add("size", "1920x1080")
query.Add("decimals", "2")
// https://ffmpeg.org/ffmpeg-filters.html for _, video := range query["video"] {
source := query.Get("source") // https://ffmpeg.org/ffmpeg-filters.html
input := "-re -f lavfi -i " + source sep := "=" // first separator
sep := "=" // first separator if video == "" {
for key, values := range query { video = "testsrc=decimals=2" // default video
value := values[0] sep = ":"
// 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
} }
input += sep + key + "=" + value input += " -f lavfi -i " + video
sep = ":" // next separator
// 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 != "" { return input
input += ",format=" + s }
}
func GetInputTTS(src string) string {
return input, nil 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 + `"`
} }
+20
View File
@@ -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)
}
+3 -5
View File
@@ -10,15 +10,13 @@ import (
) )
func Init() { func Init() {
streams.HandleFunc("gopro", handleGoPro) streams.HandleFunc("gopro", func(source string) (core.Producer, error) {
return gopro.Dial(source)
})
api.HandleFunc("api/gopro", apiGoPro) api.HandleFunc("api/gopro", apiGoPro)
} }
func handleGoPro(rawURL string) (core.Producer, error) {
return gopro.Dial(rawURL)
}
func apiGoPro(w http.ResponseWriter, r *http.Request) { func apiGoPro(w http.ResponseWriter, r *http.Request) {
var items []*api.Source var items []*api.Source
+1 -1
View File
@@ -63,7 +63,7 @@ func apiStream(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
+4 -9
View File
@@ -21,7 +21,7 @@ import (
func Init() { func Init() {
var conf struct { var conf struct {
API struct { API struct {
Listen string `json:"listen"` Listen string `yaml:"listen"`
} `yaml:"api"` } `yaml:"api"`
Mod struct { Mod struct {
Config string `yaml:"config"` Config string `yaml:"config"`
@@ -45,19 +45,14 @@ func Init() {
return "", nil 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 // support hass://supervisor?entity_id=camera.driveway_doorbell
client, err := hass.NewClient(url) return hass.NewClient(source)
if err != nil {
return nil, err
}
return client, nil
}) })
// load static entries from Hass config // load static entries from Hass config
if err := importConfig(conf.Mod.Config); err != nil { 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) { api.HandleFunc("api/hass", func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "no hass config", http.StatusNotFound) http.Error(w, "no hass config", http.StatusNotFound)
+4 -7
View File
@@ -12,7 +12,6 @@ import (
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/mp4" "github.com/AlexxIT/go2rtc/pkg/mp4"
"github.com/AlexxIT/go2rtc/pkg/mpegts" "github.com/AlexxIT/go2rtc/pkg/mpegts"
"github.com/AlexxIT/go2rtc/pkg/tcp"
"github.com/rs/zerolog" "github.com/rs/zerolog"
) )
@@ -63,15 +62,13 @@ func handlerStream(w http.ResponseWriter, r *http.Request) {
medias := mp4.ParseQuery(r.URL.Query()) medias := mp4.ParseQuery(r.URL.Query())
if medias != nil { if medias != nil {
c := mp4.NewConsumer(medias) c := mp4.NewConsumer(medias)
c.Type = "HLS/fMP4 consumer" c.FormatName = "hls/fmp4"
c.RemoteAddr = tcp.RemoteAddr(r) c.WithRequest(r)
c.UserAgent = r.UserAgent()
cons = c cons = c
} else { } else {
c := mpegts.NewConsumer() c := mpegts.NewConsumer()
c.Type = "HLS/TS consumer" c.FormatName = "hls/mpegts"
c.RemoteAddr = tcp.RemoteAddr(r) c.WithRequest(r)
c.UserAgent = r.UserAgent()
cons = c cons = c
} }
+2 -4
View File
@@ -8,7 +8,6 @@ import (
"github.com/AlexxIT/go2rtc/internal/api/ws" "github.com/AlexxIT/go2rtc/internal/api/ws"
"github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/mp4" "github.com/AlexxIT/go2rtc/pkg/mp4"
"github.com/AlexxIT/go2rtc/pkg/tcp"
) )
func handlerWSHLS(tr *ws.Transport, msg *ws.Message) error { 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() codecs := msg.String()
medias := mp4.ParseCodecs(codecs, true) medias := mp4.ParseCodecs(codecs, true)
cons := mp4.NewConsumer(medias) cons := mp4.NewConsumer(medias)
cons.Type = "HLS/fMP4 consumer" cons.FormatName = "hls/fmp4"
cons.RemoteAddr = tcp.RemoteAddr(tr.Request) cons.WithRequest(tr.Request)
cons.UserAgent = tr.Request.UserAgent()
log.Trace().Msgf("[hls] new ws consumer codecs=%s", codecs) log.Trace().Msgf("[hls] new ws consumer codecs=%s", codecs)
+35 -8
View File
@@ -22,12 +22,11 @@ import (
func Init() { func Init() {
var cfg struct { var cfg struct {
Mod map[string]struct { Mod map[string]struct {
Pin string `json:"pin"` Pin string `yaml:"pin"`
Name string `json:"name"` Name string `yaml:"name"`
DeviceID string `json:"device_id"` DeviceID string `yaml:"device_id"`
DevicePrivate string `json:"device_private"` DevicePrivate string `yaml:"device_private"`
Pairings []string `json:"pairings"` Pairings []string `yaml:"pairings"`
//Listen string `json:"listen"`
} `yaml:"homekit"` } `yaml:"homekit"`
} }
app.LoadConfig(&cfg) app.LoadConfig(&cfg)
@@ -134,12 +133,19 @@ func Init() {
var log zerolog.Logger var log zerolog.Logger
var servers map[string]*server var servers map[string]*server
func streamHandler(url string) (core.Producer, error) { func streamHandler(rawURL string) (core.Producer, error) {
if srtp.Server == nil { if srtp.Server == nil {
return nil, errors.New("homekit: can't work without SRTP server") 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) { func hapPairSetup(w http.ResponseWriter, r *http.Request) {
@@ -200,3 +206,24 @@ func findHomeKitURL(stream *streams.Stream) string {
return "" 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)
}
+21 -8
View File
@@ -11,9 +11,9 @@ import (
"github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/hls" "github.com/AlexxIT/go2rtc/pkg/hls"
"github.com/AlexxIT/go2rtc/pkg/image"
"github.com/AlexxIT/go2rtc/pkg/magic" "github.com/AlexxIT/go2rtc/pkg/magic"
"github.com/AlexxIT/go2rtc/pkg/mjpeg" "github.com/AlexxIT/go2rtc/pkg/mpjpeg"
"github.com/AlexxIT/go2rtc/pkg/multipart"
"github.com/AlexxIT/go2rtc/pkg/tcp" "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) res, err := tcp.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -66,14 +81,12 @@ func handleHTTP(rawURL string) (core.Producer, error) {
} }
switch { 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": case ct == "application/vnd.apple.mpegurl" || ext == "m3u8":
return hls.OpenURL(req.URL, res.Body) 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) return magic.Open(res.Body)
+3 -12
View File
@@ -7,16 +7,7 @@ import (
) )
func Init() { func Init() {
streams.HandleFunc("isapi", handle) streams.HandleFunc("isapi", func(source string) (core.Producer, error) {
} return isapi.Dial(source)
})
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
} }
+2 -8
View File
@@ -4,16 +4,10 @@ import (
"github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/ivideon" "github.com/AlexxIT/go2rtc/pkg/ivideon"
"strings"
) )
func Init() { func Init() {
streams.HandleFunc("ivideon", func(url string) (core.Producer, error) { streams.HandleFunc("ivideon", func(source string) (core.Producer, error) {
id := strings.Replace(url[8:], "/", ":", 1) return ivideon.Dial(source)
prod := ivideon.NewClient(id)
if err := prod.Dial(); err != nil {
return nil, err
}
return prod, nil
}) })
} }
+38
View File
@@ -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"
```
+50 -39
View File
@@ -5,26 +5,36 @@ import (
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/internal/api/ws" "github.com/AlexxIT/go2rtc/internal/api/ws"
"github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/internal/ffmpeg" "github.com/AlexxIT/go2rtc/internal/ffmpeg"
"github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/ascii"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/magic" "github.com/AlexxIT/go2rtc/pkg/magic"
"github.com/AlexxIT/go2rtc/pkg/mjpeg" "github.com/AlexxIT/go2rtc/pkg/mjpeg"
"github.com/AlexxIT/go2rtc/pkg/tcp" "github.com/AlexxIT/go2rtc/pkg/mpjpeg"
"github.com/rs/zerolog/log" "github.com/AlexxIT/go2rtc/pkg/y4m"
"github.com/rs/zerolog"
) )
func Init() { func Init() {
api.HandleFunc("api/frame.jpeg", handlerKeyframe) api.HandleFunc("api/frame.jpeg", handlerKeyframe)
api.HandleFunc("api/stream.mjpeg", handlerStream) api.HandleFunc("api/stream.mjpeg", handlerStream)
api.HandleFunc("api/stream.ascii", handlerStream)
api.HandleFunc("api/stream.y4m", apiStreamY4M)
ws.HandleFunc("mjpeg", handlerWS) ws.HandleFunc("mjpeg", handlerWS)
log = app.GetLogger("mjpeg")
} }
var log zerolog.Logger
func handlerKeyframe(w http.ResponseWriter, r *http.Request) { func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
src := r.URL.Query().Get("src") src := r.URL.Query().Get("src")
stream := streams.Get(src) stream := streams.Get(src)
@@ -34,8 +44,7 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
} }
cons := magic.NewKeyframe() cons := magic.NewKeyframe()
cons.RemoteAddr = tcp.RemoteAddr(r) cons.WithRequest(r)
cons.UserAgent = r.UserAgent()
if err := stream.AddConsumer(cons); err != nil { if err := stream.AddConsumer(cons); err != nil {
log.Error().Err(err).Caller().Send() log.Error().Err(err).Caller().Send()
@@ -90,8 +99,7 @@ func outputMjpeg(w http.ResponseWriter, r *http.Request) {
} }
cons := mjpeg.NewConsumer() cons := mjpeg.NewConsumer()
cons.RemoteAddr = tcp.RemoteAddr(r) cons.WithRequest(r)
cons.UserAgent = r.UserAgent()
if err := stream.AddConsumer(cons); err != nil { if err := stream.AddConsumer(cons); err != nil {
log.Error().Err(err).Msg("[api.mjpeg] add consumer") 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 := w.Header()
h.Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
h.Set("Cache-Control", "no-cache") h.Set("Cache-Control", "no-cache")
h.Set("Connection", "close") h.Set("Connection", "close")
h.Set("Pragma", "no-cache") h.Set("Pragma", "no-cache")
wr := &writer{wr: w, buf: []byte(header)} if strings.HasSuffix(r.URL.Path, "mjpeg") {
_, _ = cons.WriteTo(wr) wr := mjpeg.NewWriter(w)
_, _ = cons.WriteTo(wr)
} else {
cons.FormatName = "ascii"
stream.RemoveConsumer(cons) query := r.URL.Query()
} wr := ascii.NewWriter(w, query.Get("color"), query.Get("back"), query.Get("text"))
_, _ = cons.WriteTo(wr)
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()
} }
return stream.RemoveConsumer(cons)
} }
func inputMjpeg(w http.ResponseWriter, r *http.Request) { func inputMjpeg(w http.ResponseWriter, r *http.Request) {
@@ -141,17 +133,16 @@ func inputMjpeg(w http.ResponseWriter, r *http.Request) {
return return
} }
res := &http.Response{Body: r.Body, Header: r.Header, Request: r} prod, _ := mpjpeg.Open(r.Body)
res.Header.Set("Content-Type", "multipart/mixed;boundary=") prod.WithRequest(r)
client := mjpeg.NewClient(res) stream.AddProducer(prod)
stream.AddProducer(client)
if err := client.Start(); err != nil && err != io.EOF { if err := prod.Start(); err != nil && err != io.EOF {
log.Warn().Err(err).Caller().Send() log.Warn().Err(err).Caller().Send()
} }
stream.RemoveProducer(client) stream.RemoveProducer(prod)
} }
func handlerWS(tr *ws.Transport, _ *ws.Message) error { func handlerWS(tr *ws.Transport, _ *ws.Message) error {
@@ -161,8 +152,7 @@ func handlerWS(tr *ws.Transport, _ *ws.Message) error {
} }
cons := mjpeg.NewConsumer() cons := mjpeg.NewConsumer()
cons.RemoteAddr = tcp.RemoteAddr(tr.Request) cons.WithRequest(tr.Request)
cons.UserAgent = tr.Request.UserAgent()
if err := stream.AddConsumer(cons); err != nil { if err := stream.AddConsumer(cons); err != nil {
log.Debug().Err(err).Msg("[mjpeg] add consumer") log.Debug().Err(err).Msg("[mjpeg] add consumer")
@@ -179,3 +169,24 @@ func handlerWS(tr *ws.Transport, _ *ws.Message) error {
return nil 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)
}
+17 -17
View File
@@ -1,6 +1,7 @@
package mp4 package mp4
import ( import (
"context"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -12,7 +13,6 @@ import (
"github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/mp4" "github.com/AlexxIT/go2rtc/pkg/mp4"
"github.com/AlexxIT/go2rtc/pkg/tcp"
"github.com/rs/zerolog" "github.com/rs/zerolog"
) )
@@ -99,9 +99,9 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) {
medias := mp4.ParseQuery(r.URL.Query()) medias := mp4.ParseQuery(r.URL.Query())
cons := mp4.NewConsumer(medias) cons := mp4.NewConsumer(medias)
cons.Type = "MP4/HTTP active consumer" cons.FormatName = "mp4"
cons.RemoteAddr = tcp.RemoteAddr(r) cons.Protocol = "http"
cons.UserAgent = r.UserAgent() cons.WithRequest(r)
if err := stream.AddConsumer(cons); err != nil { if err := stream.AddConsumer(cons); err != nil {
log.Error().Err(err).Caller().Send() 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+`"`) header.Set("Content-Disposition", `attachment; filename="`+filename+`"`)
} }
var duration *time.Timer ctx := r.Context() // handle when the client drops the connection
if s := query.Get("duration"); s != "" {
if i, _ := strconv.Atoi(s); i > 0 { if i := core.Atoi(query.Get("duration")); i > 0 {
duration = time.AfterFunc(time.Second*time.Duration(i), func() { timeout := time.Second * time.Duration(i)
_ = cons.Stop() var cancel context.CancelFunc
}) ctx, cancel = context.WithTimeout(ctx, timeout)
} defer cancel()
} }
go func() {
<-ctx.Done()
_ = cons.Stop()
stream.RemoveConsumer(cons)
}()
_, _ = cons.WriteTo(w) _, _ = cons.WriteTo(w)
stream.RemoveConsumer(cons)
if duration != nil {
duration.Stop()
}
} }
+3 -7
View File
@@ -8,7 +8,6 @@ import (
"github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/mp4" "github.com/AlexxIT/go2rtc/pkg/mp4"
"github.com/AlexxIT/go2rtc/pkg/tcp"
) )
func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error { 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 := mp4.NewConsumer(medias)
cons.Type = "MSE/WebSocket active consumer" cons.FormatName = "mse/fmp4"
cons.RemoteAddr = tcp.RemoteAddr(tr.Request) cons.WithRequest(tr.Request)
cons.UserAgent = tr.Request.UserAgent()
if err := stream.AddConsumer(cons); err != nil { if err := stream.AddConsumer(cons); err != nil {
log.Debug().Err(err).Msg("[mp4] add consumer") 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 := mp4.NewKeyframe(medias)
cons.Type = "MP4/WebSocket active consumer" cons.WithRequest(tr.Request)
cons.RemoteAddr = tcp.RemoteAddr(tr.Request)
cons.UserAgent = tr.Request.UserAgent()
if err := stream.AddConsumer(cons); err != nil { if err := stream.AddConsumer(cons); err != nil {
log.Error().Err(err).Caller().Send() log.Error().Err(err).Caller().Send()
+1 -5
View File
@@ -6,8 +6,6 @@ import (
"github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/aac" "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) { func apiStreamAAC(w http.ResponseWriter, r *http.Request) {
@@ -19,11 +17,9 @@ func apiStreamAAC(w http.ResponseWriter, r *http.Request) {
} }
cons := aac.NewConsumer() cons := aac.NewConsumer()
cons.RemoteAddr = tcp.RemoteAddr(r) cons.WithRequest(r)
cons.UserAgent = r.UserAgent()
if err := stream.AddConsumer(cons); err != nil { if err := stream.AddConsumer(cons); err != nil {
log.Error().Err(err).Caller().Send()
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
+1 -5
View File
@@ -6,8 +6,6 @@ import (
"github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/mpegts" "github.com/AlexxIT/go2rtc/pkg/mpegts"
"github.com/AlexxIT/go2rtc/pkg/tcp"
"github.com/rs/zerolog/log"
) )
func Init() { func Init() {
@@ -32,11 +30,9 @@ func outputMpegTS(w http.ResponseWriter, r *http.Request) {
} }
cons := mpegts.NewConsumer() cons := mpegts.NewConsumer()
cons.RemoteAddr = tcp.RemoteAddr(r) cons.WithRequest(r)
cons.UserAgent = r.UserAgent()
if err := stream.AddConsumer(cons); err != nil { if err := stream.AddConsumer(cons); err != nil {
log.Error().Err(err).Caller().Send()
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
+3 -9
View File
@@ -10,19 +10,13 @@ import (
) )
func Init() { func Init() {
streams.HandleFunc("nest", streamNest) streams.HandleFunc("nest", func(source string) (core.Producer, error) {
return nest.Dial(source)
})
api.HandleFunc("api/nest", apiNest) 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) { func apiNest(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query() query := r.URL.Query()
cliendID := query.Get("client_id") cliendID := query.Get("client_id")
+1 -1
View File
@@ -50,7 +50,7 @@ func Init() {
log.Info().Str("addr", address).Msg("[ngrok] add external candidate for WebRTC") log.Info().Str("addr", address).Msg("[ngrok] add external candidate for WebRTC")
webrtc.AddCandidate(address, "tcp") webrtc.AddCandidate("tcp", address)
} }
} }
}) })
+3 -12
View File
@@ -11,22 +11,13 @@ import (
) )
func Init() { func Init() {
streams.HandleFunc("roborock", handle) streams.HandleFunc("roborock", func(source string) (core.Producer, error) {
return roborock.Dial(source)
})
api.HandleFunc("api/roborock", apiHandle) 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 { var Auth struct {
UserData *roborock.UserInfo `json:"user_data"` UserData *roborock.UserInfo `json:"user_data"`
BaseURL string `json:"base_url"` BaseURL string `json:"base_url"`
+2 -9
View File
@@ -12,7 +12,6 @@ import (
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/flv" "github.com/AlexxIT/go2rtc/pkg/flv"
"github.com/AlexxIT/go2rtc/pkg/rtmp" "github.com/AlexxIT/go2rtc/pkg/rtmp"
"github.com/AlexxIT/go2rtc/pkg/tcp"
"github.com/rs/zerolog" "github.com/rs/zerolog"
) )
@@ -128,11 +127,7 @@ func tcpHandle(netConn net.Conn) error {
var log zerolog.Logger var log zerolog.Logger
func streamsHandle(url string) (core.Producer, error) { func streamsHandle(url string) (core.Producer, error) {
client, err := rtmp.DialPlay(url) return rtmp.DialPlay(url)
if err != nil {
return nil, err
}
return client, nil
} }
func streamsConsumerHandle(url string) (core.Consumer, func(), error) { func streamsConsumerHandle(url string) (core.Consumer, func(), error) {
@@ -165,9 +160,7 @@ func outputFLV(w http.ResponseWriter, r *http.Request) {
} }
cons := flv.NewConsumer() cons := flv.NewConsumer()
cons.Type = "HTTP-FLV consumer" cons.WithRequest(r)
cons.RemoteAddr = tcp.RemoteAddr(r)
cons.UserAgent = r.UserAgent()
if err := stream.AddConsumer(cons); err != nil { if err := stream.AddConsumer(cons); err != nil {
log.Error().Err(err).Caller().Send() log.Error().Err(err).Caller().Send()
+7 -2
View File
@@ -21,7 +21,7 @@ func Init() {
Username string `yaml:"username" json:"-"` Username string `yaml:"username" json:"-"`
Password string `yaml:"password" json:"-"` Password string `yaml:"password" json:"-"`
DefaultQuery string `yaml:"default_query" json:"default_query"` DefaultQuery string `yaml:"default_query" json:"default_query"`
PacketSize uint16 `yaml:"pkt_size"` PacketSize uint16 `yaml:"pkt_size" json:"pkt_size,omitempty"`
} `yaml:"rtsp"` } `yaml:"rtsp"`
} }
@@ -210,6 +210,11 @@ func tcpHandler(conn *rtsp.Conn) {
return 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") log.Debug().Str("stream", name).Msg("[rtsp] new producer")
stream.AddProducer(conn) stream.AddProducer(conn)
@@ -239,7 +244,7 @@ func tcpHandler(conn *rtsp.Conn) {
if closer != nil { if closer != nil {
if err := conn.Handle(); err != nil { if err := conn.Handle(); err != nil {
log.Debug().Msgf("[rtsp] handle=%s", err) log.Debug().Err(err).Msg("[rtsp] handle")
} }
closer() closer()
+8
View File
@@ -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}
```
+124
View File
@@ -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")
}
+175
View File
@@ -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()
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/AlexxIT/go2rtc/pkg/core" "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{} var handlers = map[string]Handler{}
+10 -6
View File
@@ -2,6 +2,8 @@ package streams
import ( import (
"errors" "errors"
"time"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
) )
@@ -80,18 +82,20 @@ func (s *Stream) Play(source string) error {
s.AddInternalProducer(src) s.AddInternalProducer(src)
s.AddInternalConsumer(cons) s.AddInternalConsumer(cons)
go func() {
_ = src.Start()
_ = dst.Stop()
s.RemoveProducer(src)
}()
go func() { go func() {
_ = dst.Start() _ = dst.Start()
_ = src.Stop() _ = src.Stop()
s.RemoveInternalConsumer(cons) 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 return nil
} }
+8 -5
View File
@@ -132,11 +132,10 @@ func (p *Producer) AddTrack(media *core.Media, codec *core.Codec, track *core.Re
} }
func (p *Producer) MarshalJSON() ([]byte, error) { func (p *Producer) MarshalJSON() ([]byte, error) {
if p.conn != nil { if conn := p.conn; conn != nil {
return json.Marshal(p.conn) return json.Marshal(conn)
} }
info := map[string]string{"url": p.url}
info := core.Info{URL: p.url}
return json.Marshal(info) return json.Marshal(info)
} }
@@ -207,7 +206,7 @@ func (p *Producer) reconnect(workerID, retry int) {
for _, media := range conn.GetMedias() { for _, media := range conn.GetMedias() {
switch media.Direction { switch media.Direction {
case core.DirectionRecvonly: case core.DirectionRecvonly:
for _, receiver := range p.receivers { for i, receiver := range p.receivers {
codec := media.MatchCodec(receiver.Codec) codec := media.MatchCodec(receiver.Codec)
if codec == nil { if codec == nil {
continue continue
@@ -219,6 +218,7 @@ func (p *Producer) reconnect(workerID, retry int) {
} }
receiver.Replace(track) receiver.Replace(track)
p.receivers[i] = track
break 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 p.conn = conn
go p.worker(conn, workerID) go p.worker(conn, workerID)
+9 -11
View File
@@ -88,6 +88,11 @@ func (s *Stream) RemoveProducer(prod core.Producer) {
} }
func (s *Stream) stopProducers() { func (s *Stream) stopProducers() {
if s.pending.Load() > 0 {
log.Trace().Msg("[streams] skip stop pending producer")
return
}
s.mu.Lock() s.mu.Lock()
producers: producers:
for _, producer := range s.producers { for _, producer := range s.producers {
@@ -107,19 +112,12 @@ producers:
} }
func (s *Stream) MarshalJSON() ([]byte, error) { func (s *Stream) MarshalJSON() ([]byte, error) {
if !s.mu.TryLock() { var info = struct {
log.Warn().Msgf("[streams] json locked")
return json.Marshal(nil)
}
var info struct {
Producers []*Producer `json:"producers"` Producers []*Producer `json:"producers"`
Consumers []core.Consumer `json:"consumers"` 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) return json.Marshal(info)
} }
+12 -75
View File
@@ -1,7 +1,7 @@
package streams package streams
import ( import (
"net/http" "errors"
"net/url" "net/url"
"regexp" "regexp"
"sync" "sync"
@@ -26,7 +26,8 @@ func Init() {
streams[name] = NewStream(item) streams[name] = NewStream(item)
} }
api.HandleFunc("api/streams", streamsHandler) api.HandleFunc("api/streams", apiStreams)
api.HandleFunc("api/streams.dot", apiStreamsDOT)
if cfg.Publish == nil { if cfg.Publish == nil {
return return
@@ -47,9 +48,16 @@ func Get(name string) *Stream {
var sanitize = regexp.MustCompile(`\s`) var sanitize = regexp.MustCompile(`\s`)
func New(name string, source string) *Stream { // Validate - not allow creating dynamic streams with spaces in the source
// not allow creating dynamic streams with spaces in the source func Validate(source string) error {
if sanitize.MatchString(source) { 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 return nil
} }
@@ -135,77 +143,6 @@ func Delete(id string) {
delete(streams, id) 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 log zerolog.Logger
var streams = map[string]*Stream{} var streams = map[string]*Stream{}
var streamsMu sync.Mutex var streamsMu sync.Mutex
+4 -4
View File
@@ -8,11 +8,11 @@ import (
) )
func Init() { func Init() {
streams.HandleFunc("kasa", func(url string) (core.Producer, error) { streams.HandleFunc("kasa", func(source string) (core.Producer, error) {
return kasa.Dial(url) return kasa.Dial(source)
}) })
streams.HandleFunc("tapo", func(url string) (core.Producer, error) { streams.HandleFunc("tapo", func(source string) (core.Producer, error) {
return tapo.Dial(url) return tapo.Dial(source)
}) })
} }
+99 -7
View File
@@ -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 ## Config
- supported TCP: fixed port (default), disabled **Important!** This example is not for copypasting!
- supported UDP: random port (default), fixed port
| Config examples | TCP | UDP | ```yaml
|-----------------------|-------|--------| webrtc:
| `listen: ":8555/tcp"` | fixed | random | # fix local TCP or UDP or both ports for WebRTC media
| `listen: ":8555"` | fixed | fixed | listen: ":8555/tcp" # address of your local server
| `listen: ""` | no | random |
# 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 ## Userful links
+60 -49
View File
@@ -2,57 +2,60 @@ package webrtc
import ( import (
"net" "net"
"slices"
"strings"
"github.com/AlexxIT/go2rtc/internal/api/ws" "github.com/AlexxIT/go2rtc/internal/api/ws"
"github.com/AlexxIT/go2rtc/pkg/webrtc" "github.com/AlexxIT/go2rtc/pkg/webrtc"
"github.com/pion/sdp/v3" pion "github.com/pion/webrtc/v3"
) )
type Address struct { type Address struct {
Host string host string
Port string Port string
Network string Network string
Offset int Priority uint32
} }
func (a *Address) Marshal() string { func (a *Address) Host() string {
host := a.Host if a.host == "stun" {
if host == "stun" {
ip, err := webrtc.GetCachedPublicIP() ip, err := webrtc.GetCachedPublicIP()
if err != nil { if err != nil {
return "" return ""
} }
host = ip.String() return ip.String()
} }
return a.host
}
switch a.Network { func (a *Address) Marshal() string {
case "udp": if host := a.Host(); host != "" {
return webrtc.CandidateManualHostUDP(host, a.Port, a.Offset) return webrtc.CandidateICE(a.Network, host, a.Port, a.Priority)
case "tcp":
return webrtc.CandidateManualHostTCPPassive(host, a.Port, a.Offset)
} }
return "" return ""
} }
var addresses []*Address 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) host, port, err := net.SplitHostPort(address)
if err != nil { if err != nil {
return 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 { priority := webrtc.CandidateHostPriority(network, candidateIndex)
case "tcp", "udp": addresses = append(addresses, &Address{host, port, network, priority})
addresses = append(addresses, &Address{host, port, network, offset})
default:
addresses = append(
addresses, &Address{host, port, "udp", offset}, &Address{host, port, "tcp", offset},
)
}
} }
func GetCandidates() (candidates []string) { func GetCandidates() (candidates []string) {
@@ -64,6 +67,38 @@ func GetCandidates() (candidates []string) {
return 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) { func asyncCandidates(tr *ws.Transport, cons *webrtc.Conn) {
tr.WithContext(func(ctx map[any]any) { tr.WithContext(func(ctx map[any]any) {
if candidates, ok := ctx["candidate"].([]string); ok { 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 { func candidateHandler(tr *ws.Transport, msg *ws.Message) error {
// process incoming candidate in sync function // process incoming candidate in sync function
tr.WithContext(func(ctx map[any]any) { tr.WithContext(func(ctx map[any]any) {
+13 -5
View File
@@ -41,7 +41,7 @@ func streamsHandler(rawURL string) (core.Producer, error) {
// https://aws.amazon.com/kinesis/video-streams/ // https://aws.amazon.com/kinesis/video-streams/
// https://docs.aws.amazon.com/kinesisvideostreams-webrtc-dg/latest/devguide/what-is-kvswebrtc.html // https://docs.aws.amazon.com/kinesisvideostreams-webrtc-dg/latest/devguide/what-is-kvswebrtc.html
// https://github.com/orgs/awslabs/repositories?q=kinesis+webrtc // 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" { } else if format == "openipc" {
return openIPCClient(rawURL, query) return openIPCClient(rawURL, query)
} else { } else {
@@ -77,17 +77,23 @@ func go2rtcClient(url string) (core.Producer, error) {
// 2. Create PeerConnection // 2. Create PeerConnection
pc, err := PeerConnection(true) pc, err := PeerConnection(true)
if err != nil { if err != nil {
log.Error().Err(err).Caller().Send()
return nil, err return nil, err
} }
defer func() {
if err != nil {
_ = pc.Close()
}
}()
// waiter will wait PC error or WS error or nil (connection OK) // waiter will wait PC error or WS error or nil (connection OK)
var connState core.Waiter var connState core.Waiter
var connMu sync.Mutex var connMu sync.Mutex
prod := webrtc.NewConn(pc) prod := webrtc.NewConn(pc)
prod.Desc = "WebRTC/WebSocket async"
prod.Mode = core.ModeActiveProducer prod.Mode = core.ModeActiveProducer
prod.Protocol = "ws"
prod.URL = url
prod.Listen(func(msg any) { prod.Listen(func(msg any) {
switch msg := msg.(type) { switch msg := msg.(type) {
case *pion.ICECandidate: case *pion.ICECandidate:
@@ -132,7 +138,8 @@ func go2rtcClient(url string) (core.Producer, error) {
} }
if msg.Type != "webrtc/answer" { 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() answer := msg.String()
@@ -180,8 +187,9 @@ func whepClient(url string) (core.Producer, error) {
} }
prod := webrtc.NewConn(pc) prod := webrtc.NewConn(pc)
prod.Desc = "WebRTC/WHEP sync"
prod.Mode = core.ModeActiveProducer prod.Mode = core.ModeActiveProducer
prod.Protocol = "http"
prod.URL = url
medias := []*core.Media{ medias := []*core.Media{
{Kind: core.KindVideo, Direction: core.DirectionRecvonly}, {Kind: core.KindVideo, Direction: core.DirectionRecvonly},
+5 -3
View File
@@ -34,7 +34,7 @@ func (k kinesisResponse) String() string {
return fmt.Sprintf("type=%s, payload=%s", k.Type, k.Payload) 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 // 1. Connect to signalign server
conn, _, err := websocket.DefaultDialer.Dial(rawURL, nil) conn, _, err := websocket.DefaultDialer.Dial(rawURL, nil)
if err != nil { if err != nil {
@@ -79,8 +79,10 @@ func kinesisClient(rawURL string, query url.Values, desc string) (core.Producer,
} }
prod := webrtc.NewConn(pc) prod := webrtc.NewConn(pc)
prod.Desc = desc prod.FormatName = format
prod.Mode = core.ModeActiveProducer prod.Mode = core.ModeActiveProducer
prod.Protocol = "ws"
prod.URL = rawURL
prod.Listen(func(msg any) { prod.Listen(func(msg any) {
switch msg := msg.(type) { switch msg := msg.(type) {
case *pion.ICECandidate: case *pion.ICECandidate:
@@ -216,5 +218,5 @@ func wyzeClient(rawURL string) (core.Producer, error) {
"ice_servers": []string{string(kvs.Servers)}, "ice_servers": []string{string(kvs.Servers)},
} }
return kinesisClient(kvs.URL, query, "WebRTC/Wyze") return kinesisClient(kvs.URL, query, "webrtc/wyze")
} }
+3 -1
View File
@@ -193,8 +193,10 @@ func milestoneClient(rawURL string, query url.Values) (core.Producer, error) {
} }
prod := webrtc.NewConn(pc) prod := webrtc.NewConn(pc)
prod.Desc = "WebRTC/Milestone" prod.FormatName = "webrtc/milestone"
prod.Mode = core.ModeActiveProducer prod.Mode = core.ModeActiveProducer
prod.Protocol = "http"
prod.URL = rawURL
offer, err := mc.GetOffer() offer, err := mc.GetOffer()
if err != nil { if err != nil {
+3 -1
View File
@@ -53,8 +53,10 @@ func openIPCClient(rawURL string, query url.Values) (core.Producer, error) {
var connState core.Waiter var connState core.Waiter
prod := webrtc.NewConn(pc) prod := webrtc.NewConn(pc)
prod.Desc = "WebRTC/OpenIPC" prod.FormatName = "webrtc/openipc"
prod.Mode = core.ModeActiveProducer prod.Mode = core.ModeActiveProducer
prod.Protocol = "ws"
prod.URL = rawURL
prod.Listen(func(msg any) { prod.Listen(func(msg any) {
switch msg := msg.(type) { switch msg := msg.(type) {
case *pion.ICECandidate: case *pion.ICECandidate:
+6 -8
View File
@@ -65,6 +65,7 @@ func outputWebRTC(w http.ResponseWriter, r *http.Request) {
url := r.URL.Query().Get("src") url := r.URL.Query().Get("src")
stream := streams.Get(url) stream := streams.Get(url)
if stream == nil { if stream == nil {
http.Error(w, api.StreamNotFound, http.StatusNotFound)
return return
} }
@@ -100,11 +101,11 @@ func outputWebRTC(w http.ResponseWriter, r *http.Request) {
switch mediaType { switch mediaType {
case "application/json": case "application/json":
desc = "WebRTC/JSON sync" desc = "webrtc/json"
case MimeSDP: case MimeSDP:
desc = "WebRTC/WHEP sync" desc = "webrtc/whep"
default: default:
desc = "WebRTC/HTTP sync" desc = "webrtc/post"
} }
answer, err := ExchangeSDP(stream, offer, desc, r.UserAgent()) answer, err := ExchangeSDP(stream, offer, desc, r.UserAgent())
@@ -168,8 +169,8 @@ func inputWebRTC(w http.ResponseWriter, r *http.Request) {
// create new webrtc instance // create new webrtc instance
prod := webrtc.NewConn(pc) prod := webrtc.NewConn(pc)
prod.Desc = "WebRTC/WHIP sync"
prod.Mode = core.ModePassiveProducer prod.Mode = core.ModePassiveProducer
prod.Protocol = "http"
prod.UserAgent = r.UserAgent() prod.UserAgent = r.UserAgent()
if err = prod.SetOffer(string(offer)); err != nil { if err = prod.SetOffer(string(offer)); err != nil {
@@ -178,10 +179,7 @@ func inputWebRTC(w http.ResponseWriter, r *http.Request) {
return return
} }
answer, err := prod.GetCompleteAnswer() answer, err := prod.GetCompleteAnswer(GetCandidates(), FilterCandidate)
if err == nil {
answer, err = syncCanditates(answer)
}
if err != nil { if err != nil {
log.Warn().Err(err).Caller().Send() log.Warn().Err(err).Caller().Send()
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
+13 -17
View File
@@ -20,6 +20,7 @@ func Init() {
Listen string `yaml:"listen"` Listen string `yaml:"listen"`
Candidates []string `yaml:"candidates"` Candidates []string `yaml:"candidates"`
IceServers []pion.ICEServer `yaml:"ice_servers"` IceServers []pion.ICEServer `yaml:"ice_servers"`
Filters webrtc.Filters `yaml:"filters"`
} `yaml:"webrtc"` } `yaml:"webrtc"`
} }
@@ -32,20 +33,15 @@ func Init() {
log = app.GetLogger("webrtc") log = app.GetLogger("webrtc")
filters = cfg.Mod.Filters
address, network, _ := strings.Cut(cfg.Mod.Listen, "/") address, network, _ := strings.Cut(cfg.Mod.Listen, "/")
var candidateHost []string
for _, candidate := range cfg.Mod.Candidates { for _, candidate := range cfg.Mod.Candidates {
if strings.HasPrefix(candidate, "host:") { AddCandidate(network, candidate)
candidateHost = append(candidateHost, candidate[5:])
continue
}
AddCandidate(candidate, network)
} }
// create pionAPI with custom codecs list and custom network settings // 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 { if err != nil {
log.Error().Err(err).Caller().Send() log.Error().Err(err).Caller().Send()
return return
@@ -55,8 +51,7 @@ func Init() {
clientAPI := serverAPI clientAPI := serverAPI
if address != "" { if address != "" {
log.Info().Str("addr", address).Msg("[webrtc] listen") log.Info().Str("addr", cfg.Mod.Listen).Msg("[webrtc] listen")
clientAPI, _ = webrtc.NewAPI() clientAPI, _ = webrtc.NewAPI()
} }
@@ -122,8 +117,8 @@ func asyncHandler(tr *ws.Transport, msg *ws.Message) error {
defer sendAnswer.Done(nil) defer sendAnswer.Done(nil)
conn := webrtc.NewConn(pc) conn := webrtc.NewConn(pc)
conn.Desc = "WebRTC/WebSocket async"
conn.Mode = mode conn.Mode = mode
conn.Protocol = "ws"
conn.UserAgent = tr.Request.UserAgent() conn.UserAgent = tr.Request.UserAgent()
conn.Listen(func(msg any) { conn.Listen(func(msg any) {
switch msg := msg.(type) { switch msg := msg.(type) {
@@ -139,6 +134,9 @@ func asyncHandler(tr *ws.Transport, msg *ws.Message) error {
} }
case *pion.ICECandidate: case *pion.ICECandidate:
if !FilterCandidate(msg) {
return
}
_ = sendAnswer.Wait() _ = sendAnswer.Wait()
s := msg.ToJSON().Candidate s := msg.ToJSON().Candidate
@@ -209,8 +207,9 @@ func ExchangeSDP(stream *streams.Stream, offer, desc, userAgent string) (answer
// create new webrtc instance // create new webrtc instance
conn := webrtc.NewConn(pc) conn := webrtc.NewConn(pc)
conn.Desc = desc conn.FormatName = desc
conn.UserAgent = userAgent conn.UserAgent = userAgent
conn.Protocol = "http"
conn.Listen(func(msg any) { conn.Listen(func(msg any) {
switch msg := msg.(type) { switch msg := msg.(type) {
case pion.PeerConnectionState: case pion.PeerConnectionState:
@@ -248,10 +247,7 @@ func ExchangeSDP(stream *streams.Stream, offer, desc, userAgent string) (answer
stream.AddProducer(conn) stream.AddProducer(conn)
} }
answer, err = conn.GetCompleteAnswer() answer, err = conn.GetCompleteAnswer(GetCandidates(), FilterCandidate)
if err == nil {
answer, err = syncCanditates(answer)
}
log.Trace().Msgf("[webrtc] answer\n%s", answer) log.Trace().Msgf("[webrtc] answer\n%s", answer)
if err != nil { if err != nil {
+1 -1
View File
@@ -47,7 +47,7 @@ func Init() {
if stream == nil { if stream == nil {
return "", errors.New(api.StreamNotFound) return "", errors.New(api.StreamNotFound)
} }
return webrtc.ExchangeSDP(stream, offer, "WebRTC/WebTorrent sync", "") return webrtc.ExchangeSDP(stream, offer, "webtorrent", "")
}, },
} }
+2
View File
@@ -36,6 +36,8 @@ import (
) )
func main() { func main() {
app.Version = "1.9.4"
// 1. Core modules: app, api/ws, streams // 1. Core modules: app, api/ws, streams
app.Init() // init config and logs app.Init() // init config and logs
+82
View File
@@ -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 ## Useful links
- https://www.wowza.com/blog/streaming-protocols - https://www.wowza.com/blog/streaming-protocols
+2
View File
@@ -69,6 +69,8 @@ func DecodeConfig(b []byte) (objType, sampleFreqIdx, channels byte, sampleRate u
sampleFreqIdx = rd.ReadBits8(4) sampleFreqIdx = rd.ReadBits8(4)
if sampleFreqIdx == 0b1111 { if sampleFreqIdx == 0b1111 {
sampleRate = rd.ReadBits(24) sampleRate = rd.ReadBits(24)
} else {
sampleRate = sampleRates[sampleFreqIdx]
} }
channels = rd.ReadBits8(4) channels = rd.ReadBits8(4)
+9
View File
@@ -41,3 +41,12 @@ func TestADTS(t *testing.T) {
require.Equal(t, src[:len(dst)], dst) 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))
}
+12 -11
View File
@@ -8,15 +8,12 @@ import (
) )
type Consumer struct { type Consumer struct {
core.SuperConsumer core.Connection
wr *core.WriteBuffer wr *core.WriteBuffer
} }
func NewConsumer() *Consumer { func NewConsumer() *Consumer {
cons := &Consumer{ medias := []*core.Media{
wr: core.NewWriteBuffer(nil),
}
cons.Medias = []*core.Media{
{ {
Kind: core.KindAudio, Kind: core.KindAudio,
Direction: core.DirectionSendonly, 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 { 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) { func (c *Consumer) WriteTo(wr io.Writer) (int64, error) {
return c.wr.WriteTo(wr) return c.wr.WriteTo(wr)
} }
func (c *Consumer) Stop() error {
_ = c.SuperConsumer.Close()
return c.wr.Close()
}
+12 -14
View File
@@ -10,9 +10,8 @@ import (
) )
type Producer struct { type Producer struct {
core.SuperProducer core.Connection
rd *bufio.Reader rd *bufio.Reader
cl io.Closer
} }
func Open(r io.Reader) (*Producer, error) { func Open(r io.Reader) (*Producer, error) {
@@ -23,18 +22,22 @@ func Open(r io.Reader) (*Producer, error) {
return nil, err return nil, err
} }
codec := ADTSToCodec(b) medias := []*core.Media{
prod := &Producer{rd: rd, cl: r.(io.Closer)}
prod.Type = "ADTS producer"
prod.Medias = []*core.Media{
{ {
Kind: core.KindAudio, Kind: core.KindAudio,
Direction: core.DirectionRecvonly, 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 { func (c *Producer) Start() error {
@@ -66,8 +69,3 @@ func (c *Producer) Start() error {
c.Receivers[0].WriteRTP(pkt) c.Receivers[0].WriteRTP(pkt)
} }
} }
func (c *Producer) Stop() error {
_ = c.SuperProducer.Close()
return c.cl.Close()
}
+6
View File
@@ -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
+173
View File
@@ -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
}
+4
View File
@@ -131,3 +131,7 @@ func (r *Reader) ReadSEGolomb() int32 {
func (r *Reader) Left() []byte { func (r *Reader) Left() []byte {
return r.buf[r.pos:] return r.buf[r.pos:]
} }
func (r *Reader) Pos() (int, byte) {
return r.pos - 1, r.bits
}
+7 -2
View File
@@ -22,6 +22,7 @@ import (
"github.com/pion/rtp" "github.com/pion/rtp"
) )
// Deprecated: should be rewritten to core.Connection
type Client struct { type Client struct {
core.Listener core.Listener
@@ -43,8 +44,12 @@ type Client struct {
recv int recv int
} }
func NewClient(url string) *Client { func Dial(rawURL string) (*Client, error) {
return &Client{url: url} client := &Client{url: rawURL}
if err := client.Dial(); err != nil {
return nil, err
}
return client, nil
} }
const ( const (
+10 -5
View File
@@ -65,11 +65,16 @@ func (c *Client) Stop() error {
} }
func (c *Client) MarshalJSON() ([]byte, error) { func (c *Client) MarshalJSON() ([]byte, error) {
info := &core.Info{ info := &core.Connection{
Type: "Bubble active producer", ID: core.ID(c),
Medias: c.medias, FormatName: "bubble",
Recv: c.recv, Protocol: "http",
Receivers: c.receivers, Medias: c.medias,
Recv: c.recv,
Receivers: c.receivers,
}
if c.conn != nil {
info.RemoteAddr = c.conn.RemoteAddr().String()
} }
return json.Marshal(info) return json.Marshal(info)
} }
+61 -20
View File
@@ -2,8 +2,8 @@ package core
import ( import (
"encoding/base64" "encoding/base64"
"encoding/json"
"fmt" "fmt"
"strconv"
"strings" "strings"
"unicode" "unicode"
@@ -18,34 +18,76 @@ type Codec struct {
PayloadType uint8 PayloadType uint8
} }
func (c *Codec) String() string { // MarshalJSON - return FFprobe compatible output
s := fmt.Sprintf("%d %s", c.PayloadType, c.Name) 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 { if c.ClockRate != 0 && c.ClockRate != 90000 {
s = fmt.Sprintf("%s/%d", s, c.ClockRate) info["sample_rate"] = c.ClockRate
} }
if c.Channels > 0 { 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 { func FFmpegCodecName(name string) string {
switch c.Name { switch name {
case CodecH264: case CodecH264:
if profile := DecodeH264(c.FmtpLine); profile != "" { return "h264"
return "H.264 " + profile case CodecH265:
} return "hevc"
return c.Name 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 { if c.ClockRate != 0 && c.ClockRate != 90000 {
s += "/" + strconv.Itoa(int(c.ClockRate)) s += fmt.Sprintf("/%d", c.ClockRate)
} }
if c.Channels > 0 { if c.Channels > 0 {
s += "/" + strconv.Itoa(int(c.Channels)) s += fmt.Sprintf("/%d", c.Channels)
} }
return s return
} }
func (c *Codec) IsRTP() bool { func (c *Codec) IsRTP() bool {
@@ -181,10 +223,9 @@ func UnmarshalCodec(md *sdp.MediaDescription, payloadType string) *Codec {
return c return c
} }
func DecodeH264(fmtp string) string { func DecodeH264(fmtp string) (profile string, level byte) {
if ps := Between(fmtp, "sprop-parameter-sets=", ","); ps != "" { if ps := Between(fmtp, "sprop-parameter-sets=", ","); ps != "" {
if sps, _ := base64.StdEncoding.DecodeString(ps); len(sps) >= 4 { if sps, _ := base64.StdEncoding.DecodeString(ps); len(sps) >= 4 {
var profile string
switch sps[1] { switch sps[1] {
case 0x42: case 0x42:
profile = "Baseline" profile = "Baseline"
@@ -198,8 +239,8 @@ func DecodeH264(fmtp string) string {
profile = fmt.Sprintf("0x%02X", sps[1]) 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
} }
+139
View File
@@ -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
}
+5 -85
View File
@@ -1,5 +1,7 @@
package core package core
import "encoding/json"
const ( const (
DirectionRecvonly = "recvonly" DirectionRecvonly = "recvonly"
DirectionSendonly = "sendonly" DirectionSendonly = "sendonly"
@@ -18,6 +20,7 @@ const (
CodecVP9 = "VP9" CodecVP9 = "VP9"
CodecAV1 = "AV1" CodecAV1 = "AV1"
CodecJPEG = "JPEG" // payloadType: 26 CodecJPEG = "JPEG" // payloadType: 26
CodecRAW = "RAW"
CodecPCMU = "PCMU" // payloadType: 0 CodecPCMU = "PCMU" // payloadType: 0
CodecPCMA = "PCMA" // payloadType: 8 CodecPCMA = "PCMA" // payloadType: 8
@@ -89,89 +92,6 @@ func (m Mode) String() string {
return "unknown" return "unknown"
} }
type Info struct { func (m Mode) MarshalJSON() ([]byte, error) {
Type string `json:"type,omitempty"` return json.Marshal(m.String())
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
} }
+120
View File
@@ -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()
}
+7
View File
@@ -38,6 +38,13 @@ func RandString(size, base byte) string {
return string(b) 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 { func Between(s, sub1, sub2 string) string {
i := strings.Index(s, sub1) i := strings.Index(s, sub1)
if i < 0 { if i < 0 {
+2 -2
View File
@@ -22,7 +22,7 @@ type Media struct {
func (m *Media) String() string { func (m *Media) String() string {
s := fmt.Sprintf("%s, %s", m.Kind, m.Direction) s := fmt.Sprintf("%s, %s", m.Kind, m.Direction)
for _, codec := range m.Codecs { for _, codec := range m.Codecs {
name := codec.Text() name := codec.String()
if strings.Contains(s, name) { if strings.Contains(s, name) {
continue continue
@@ -92,7 +92,7 @@ func (m *Media) Equal(media *Media) bool {
func GetKind(name string) string { func GetKind(name string) string {
switch name { switch name {
case CodecH264, CodecH265, CodecVP8, CodecVP9, CodecAV1, CodecJPEG: case CodecH264, CodecH265, CodecVP8, CodecVP9, CodecAV1, CodecJPEG, CodecRAW:
return KindVideo return KindVideo
case CodecPCMU, CodecPCMA, CodecAAC, CodecOpus, CodecG722, CodecMP3, CodecPCM, CodecPCML, CodecELD, CodecFLAC: case CodecPCMU, CodecPCMA, CodecAAC, CodecOpus, CodecG722, CodecMP3, CodecPCM, CodecPCML, CodecELD, CodecFLAC:
return KindAudio return KindAudio
+88
View File
@@ -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
}
}
+156 -145
View File
@@ -3,201 +3,212 @@ package core
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"strconv"
"sync"
"github.com/pion/rtp" "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") var ErrCantGetTrack = errors.New("can't get track")
type Receiver struct { type Receiver struct {
Codec *Codec Node
Media *Media
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 Bytes int `json:"bytes,omitempty"`
mu sync.RWMutex Packets int `json:"packets,omitempty"`
bytes int
} }
func NewReceiver(media *Media, codec *Codec) *Receiver { func NewReceiver(media *Media, codec *Codec) *Receiver {
Assert(codec != nil) r := &Receiver{
return &Receiver{Codec: codec, Media: media} Node: Node{id: NewID(), Codec: codec},
} Media: media,
}
// WriteRTP - fast and non blocking write to all readers buffers r.Input = func(packet *Packet) {
func (t *Receiver) WriteRTP(packet *rtp.Packet) { r.Bytes += len(packet.Payload)
t.mu.Lock() r.Packets++
t.bytes += len(packet.Payload) for _, child := range r.childs {
for sender, buffer := range t.senders { child.Input(packet)
select {
case buffer <- packet:
default:
sender.overflow++
} }
} }
t.mu.Unlock() return r
} }
func (t *Receiver) Senders() (senders []*Sender) { // Deprecated: should be removed
t.mu.RLock() func (r *Receiver) WriteRTP(packet *rtp.Packet) {
for sender := range t.senders { r.Input(packet)
senders = append(senders, sender) }
// 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() { // Deprecated: should be removed
t.mu.Lock() func (r *Receiver) Replace(target *Receiver) {
// close all sender channel buffers and erase senders list MoveNode(&target.Node, &r.Node)
for _, buffer := range t.senders {
close(buffer)
}
t.senders = nil
t.mu.Unlock()
} }
func (t *Receiver) Replace(target *Receiver) { func (r *Receiver) Close() {
// move this receiver senders to new receiver r.Node.Close()
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())
} }
type Sender struct { type Sender struct {
Codec *Codec Node
Media *Media
Handler HandlerFunc // Deprecated:
Media *Media `json:"-"`
// Deprecated:
Handler HandlerFunc `json:"-"`
receivers []*Receiver Bytes int `json:"bytes,omitempty"`
mu sync.RWMutex Packets int `json:"packets,omitempty"`
bytes int Drops int `json:"drops,omitempty"`
overflow int buf chan *Packet
done chan struct{}
} }
func NewSender(media *Media, codec *Codec) *Sender { func NewSender(media *Media, codec *Codec) *Sender {
return &Sender{Codec: codec, Media: media} var bufSize uint16
}
// HandlerFunc like http.HandlerFunc if GetKind(codec.Name) == KindVideo {
type HandlerFunc func(packet *rtp.Packet) if codec.IsRTP() {
func (s *Sender) HandleRTP(track *Receiver) {
bufferSize := 100
if GetKind(track.Codec.Name) == KindVideo {
if track.Codec.IsRTP() {
// in my tests 40Mbit/s 4K-video can generate up to 1500 items // in my tests 40Mbit/s 4K-video can generate up to 1500 items
// for the h264.RTPDepay => RTPPay queue // for the h264.RTPDepay => RTPPay queue
bufferSize = 5000 bufSize = 4096
} else { } else {
bufferSize = 50 bufSize = 64
} }
} else {
bufSize = 128
} }
buffer := make(chan *rtp.Packet, bufferSize) buf := make(chan *Packet, bufSize)
s := &Sender{
track.mu.Lock() Node: Node{id: NewID(), Codec: codec},
if track.senders == nil { Media: media,
track.senders = map[*Sender]chan *rtp.Packet{} buf: buf,
} }
track.senders[s] = buffer s.Input = func(packet *Packet) {
track.mu.Unlock() // writing to nil chan - OK, writing to closed chan - panic
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.mu.Lock() s.mu.Lock()
for i, receiver := range s.receivers { select {
if receiver == track { case s.buf <- packet:
s.receivers = append(s.receivers[:i], s.receivers[i+1:]...) s.Bytes += len(packet.Payload)
break s.Packets++
} default:
s.Drops++
} }
s.mu.Unlock() 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() { func (s *Sender) Wait() {
s.mu.Lock() if done := s.done; s.done != nil {
// remove this sender from all receivers list <-done
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()
} }
s.receivers = nil
s.mu.Unlock()
} }
func (s *Sender) String() string { func (s *Sender) State() string {
info := s.Codec.String() + ", bytes=" + strconv.Itoa(s.bytes) if s.buf == nil {
s.mu.RLock() return "closed"
info += ", receivers=" + strconv.Itoa(len(s.receivers))
s.mu.RUnlock()
if s.overflow > 0 {
info += ", overflow=" + strconv.Itoa(s.overflow)
} }
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) { func (s *Sender) MarshalJSON() ([]byte, error) {
return json.Marshal(s.String()) v := struct {
} ID uint32 `json:"id"`
Codec *Codec `json:"codec"`
// VA - helper, for extract video and audio receivers from list Parent uint32 `json:"parent,omitempty"`
func VA(receivers []*Receiver) (video, audio *Receiver) { Bytes int `json:"bytes,omitempty"`
for _, receiver := range receivers { Packets int `json:"packets,omitempty"`
switch GetKind(receiver.Codec.Name) { Drops int `json:"drops,omitempty"`
case KindVideo: }{
video = receiver ID: s.Node.id,
case KindAudio: Codec: s.Node.Codec,
audio = receiver Bytes: s.Bytes,
} Packets: s.Packets,
Drops: s.Drops,
} }
return if s.parent != nil {
v.Parent = s.parent.id
}
return json.Marshal(v)
} }
+2 -2
View File
@@ -24,7 +24,7 @@ func Logger(include func(packet *rtp.Packet) bool) func(packet *rtp.Packet) {
now := time.Now() now := time.Now()
fmt.Printf( 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"), now.Format("15:04:05.000"),
len(packet.Payload), packet.Timestamp, packet.PayloadType, packet.SSRC, packet.SequenceNumber, packet.Marker, len(packet.Payload), packet.Timestamp, packet.PayloadType, packet.SSRC, packet.SequenceNumber, packet.Marker,
packet.Timestamp-lastTS, now.Sub(lastTime).Milliseconds(), 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 { if dt := now.Sub(secTime); dt > time.Second {
fmt.Printf( 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"), now.Format("15:04:05.000"),
secSize, secCnt, lastTS-secTS, dt.Milliseconds(), secSize, secCnt, lastTS-secTS, dt.Milliseconds(),
) )
@@ -8,16 +8,16 @@ import (
"github.com/pion/rtp" "github.com/pion/rtp"
) )
type Consumer struct { type Backchannel struct {
core.SuperConsumer core.Connection
client *Client 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 return nil, core.ErrCantGetTrack
} }
func (c *Consumer) Start() error { func (c *Backchannel) Start() error {
if err := c.client.conn.SetReadDeadline(time.Time{}); err != nil { if err := c.client.conn.SetReadDeadline(time.Time{}); err != nil {
return err return err
} }
@@ -30,12 +30,7 @@ func (c *Consumer) Start() error {
} }
} }
func (c *Consumer) Stop() error { func (c *Backchannel) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error {
_ = c.SuperConsumer.Close()
return c.client.Close()
}
func (c *Consumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error {
if err := c.client.Talk(); err != nil { if err := c.client.Talk(); err != nil {
return err return err
} }
+1 -1
View File
@@ -114,7 +114,7 @@ func (c *Client) Play() error {
} }
func (c *Client) Talk() 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") data := fmt.Sprintf(format, c.session, "Claim")
if _, err := c.WriteCmd(OPTalkClaim, []byte(data)); err != nil { if _, err := c.WriteCmd(OPTalkClaim, []byte(data)); err != nil {

Some files were not shown because too many files have changed in this diff Show More