diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 188727d6..0bc21d11 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 - with: { go-version: '1.21' } + with: { go-version: '1.22' } - name: Build go2rtc_win64 env: { GOOS: windows, GOARCH: amd64 } @@ -29,7 +29,7 @@ jobs: with: { name: go2rtc_win64, path: go2rtc.exe } - name: Build go2rtc_win32 - env: { GOOS: windows, GOARCH: 386 } + env: { GOOS: windows, GOARCH: 386, GOTOOLCHAIN: go1.20.14 } run: go build -ldflags "-s -w" -trimpath - name: Upload go2rtc_win32 uses: actions/upload-artifact@v4 @@ -85,7 +85,7 @@ jobs: with: { name: go2rtc_linux_mipsel, path: go2rtc } - name: Build go2rtc_mac_amd64 - env: { GOOS: darwin, GOARCH: amd64 } + env: { GOOS: darwin, GOARCH: amd64, GOTOOLCHAIN: go1.20.14 } run: go build -ldflags "-s -w" -trimpath - name: Upload go2rtc_mac_amd64 uses: actions/upload-artifact@v4 @@ -102,14 +102,14 @@ jobs: env: { GOOS: freebsd, GOARCH: amd64 } run: go build -ldflags "-s -w" -trimpath - name: Upload go2rtc_freebsd_amd64 - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: { name: go2rtc_freebsd_amd64, path: go2rtc } - name: Build go2rtc_freebsd_arm64 env: { GOOS: freebsd, GOARCH: arm64 } run: go build -ldflags "-s -w" -trimpath - name: Upload go2rtc_freebsd_arm64 - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: { name: go2rtc_freebsd_arm64, path: go2rtc } docker-master: @@ -123,7 +123,9 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ${{ github.repository }} + images: | + ${{ github.repository }} + ghcr.io/${{ github.repository }} tags: | type=ref,event=branch type=semver,pattern={{version}},enable=false @@ -142,6 +144,14 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push uses: docker/build-push-action@v5 with: @@ -149,6 +159,7 @@ jobs: platforms: | linux/amd64 linux/386 + linux/arm/v6 linux/arm/v7 linux/arm64/v8 push: ${{ github.event_name != 'pull_request' }} @@ -168,7 +179,9 @@ jobs: id: meta-hw uses: docker/metadata-action@v5 with: - images: ${{ github.repository }} + images: | + ${{ github.repository }} + ghcr.io/${{ github.repository }} flavor: | suffix=-hardware,onlatest=true latest=auto @@ -189,6 +202,14 @@ jobs: with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push uses: docker/build-push-action@v5 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b23faf53..dc47bdb5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,7 +26,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version: '1.22' - name: Build Go binary run: go build -ldflags "-s -w" -trimpath -o ./go2rtc diff --git a/.gitignore b/.gitignore index 04ae894a..52fe9c86 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ go2rtc.yaml go2rtc.json +go2rtc_freebsd* go2rtc_linux* go2rtc_mac* go2rtc_win* diff --git a/Dockerfile b/Dockerfile index 46b85d30..4bc74d79 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,14 +2,19 @@ # 0. Prepare images ARG PYTHON_VERSION="3.11" -ARG GO_VERSION="1.21" -ARG NGROK_VERSION="3" - -FROM python:${PYTHON_VERSION}-alpine AS base -FROM ngrok/ngrok:${NGROK_VERSION}-alpine AS ngrok +ARG GO_VERSION="1.22" -# 1. Build go2rtc binary +# 1. Download ngrok binary (for support arm/v6) +FROM alpine AS ngrok +ARG TARGETARCH +ARG TARGETOS + +ADD https://bin.equinox.io/c/bNyj1mQVY4c/ngrok-v3-stable-${TARGETOS}-${TARGETARCH}.tgz / +RUN tar -xzf /ngrok-v3-stable-${TARGETOS}-${TARGETARCH}.tgz -C /bin + + +# 2. Build go2rtc binary FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS build ARG TARGETPLATFORM ARG TARGETOS @@ -30,21 +35,14 @@ COPY . . RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -ldflags "-s -w" -trimpath -# 2. Collect all files -FROM scratch AS rootfs - -COPY --from=build /build/go2rtc /usr/local/bin/ -COPY --from=ngrok /bin/ngrok /usr/local/bin/ - - # 3. Final image -FROM base +FROM python:${PYTHON_VERSION}-alpine AS base # Install ffmpeg, tini (for signal handling), # and other common tools for the echo source. # alsa-plugins-pulse for ALSA support (+0MB) # font-droid for FFmpeg drawtext filter (+2MB) -RUN apk add --no-cache tini ffmpeg bash curl jq alsa-plugins-pulse font-droid +RUN apk add --no-cache tini ffmpeg ffplay bash curl jq alsa-plugins-pulse font-droid # Hardware Acceleration for Intel CPU (+50MB) ARG TARGETARCH @@ -56,7 +54,8 @@ RUN if [ "${TARGETARCH}" = "amd64" ]; then apk add --no-cache libva-intel-driver # Hardware: AMD and NVidia VDPAU (not sure about this) # RUN libva-vdpau-driver mesa-vdpau-gallium (+150MB total) -COPY --from=rootfs / / +COPY --from=build /build/go2rtc /usr/local/bin/ +COPY --from=ngrok /bin/ngrok /usr/local/bin/ ENTRYPOINT ["/sbin/tini", "--"] VOLUME /config diff --git a/README.md b/README.md index c31ed748..224139c7 100644 --- a/README.md +++ b/README.md @@ -115,8 +115,8 @@ Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg Download binary for your OS from [latest release](https://github.com/AlexxIT/go2rtc/releases/): -- `go2rtc_win64.zip` - Windows 64-bit -- `go2rtc_win32.zip` - Windows 32-bit +- `go2rtc_win64.zip` - Windows 10+ 64-bit +- `go2rtc_win32.zip` - Windows 7+ 32-bit - `go2rtc_win_arm64.zip` - Windows ARM 64-bit - `go2rtc_linux_amd64` - Linux 64-bit - `go2rtc_linux_i386` - Linux 32-bit @@ -124,8 +124,10 @@ Download binary for your OS from [latest release](https://github.com/AlexxIT/go2 - `go2rtc_linux_arm` - Linux ARM 32-bit (ex. Raspberry 32-bit OS) - `go2rtc_linux_armv6` - Linux ARMv6 (for old Raspberry 1 and Zero) - `go2rtc_linux_mipsel` - Linux MIPS (ex. [Xiaomi Gateway 3](https://github.com/AlexxIT/XiaomiGateway3), [Wyze cameras](https://github.com/gtxaspec/wz_mini_hacks)) -- `go2rtc_mac_amd64.zip` - Mac Intel 64-bit -- `go2rtc_mac_arm64.zip` - Mac ARM 64-bit +- `go2rtc_mac_amd64.zip` - macOS 10.13+ Intel 64-bit +- `go2rtc_mac_arm64.zip` - macOS ARM 64-bit +- `go2rtc_freebsd_amd64.zip` - FreeBSD 64-bit +- `go2rtc_freebsd_arm64.zip` - FreeBSD ARM 64-bit Don't forget to fix the rights `chmod +x go2rtc_xxx_xxx` on Linux and Mac. @@ -170,7 +172,7 @@ Available modules: - [api](#module-api) - HTTP API (important for WebRTC support) - [rtsp](#module-rtsp) - RTSP Server (important for FFmpeg support) - [webrtc](#module-webrtc) - WebRTC Server -- [mp4](#module-mp4) - MSE, MP4 stream and MP4 shapshot Server +- [mp4](#module-mp4) - MSE, MP4 stream and MP4 snapshot Server - [hls](#module-hls) - HLS TS or fMP4 stream Server - [mjpeg](#module-mjpeg) - MJPEG Server - [ffmpeg](#source-ffmpeg) - FFmpeg integration @@ -648,10 +650,11 @@ This source type support Roborock vacuums with cameras. Known working models: - Roborock S6 MaxV - only video (the vacuum has no microphone) - Roborock S7 MaxV - video and two way audio +- Roborock Qrevo MaxV - video and two way audio -Source support load Roborock credentials from Home Assistant [custom integration](https://github.com/humbertogontijo/homeassistant-roborock). Otherwise, you need to log in to your Roborock account (MiHome account is not supported). Go to: go2rtc WebUI > Add webpage. Copy `roborock://...` source for your vacuum and paste it to `go2rtc.yaml` config. +Source support load Roborock credentials from Home Assistant [custom integration](https://github.com/humbertogontijo/homeassistant-roborock) or the [core integration](https://www.home-assistant.io/integrations/roborock). Otherwise, you need to log in to your Roborock account (MiHome account is not supported). Go to: go2rtc WebUI > Add webpage. Copy `roborock://...` source for your vacuum and paste it to `go2rtc.yaml` config. -If you have graphic pin for your vacuum - add it as numeric pin (lines: 123, 456, 678) to the end of the roborock-link. +If you have graphic pin for your vacuum - add it as numeric pin (lines: 123, 456, 789) to the end of the roborock-link. #### Source: WebRTC @@ -880,7 +883,7 @@ Read more about [codecs filters](#codecs-filters). You can get any stream as RTMP-stream: `rtmp://192.168.1.123/{stream_name}`. Only H264/AAC codecs supported right now. -[Incoming stream](#incoming-sources) in RTMP-format tested only with [OBS Studio](https://obsproject.com/) and Dahua camera. Different FFmpeg versions has differnt problems with this format. +[Incoming stream](#incoming-sources) in RTMP-format tested only with [OBS Studio](https://obsproject.com/) and Dahua camera. Different FFmpeg versions has different problems with this format. ```yaml rtmp: diff --git a/examples/go2rtc_mjpeg/main.go b/examples/go2rtc_mjpeg/main.go new file mode 100644 index 00000000..3c915b3c --- /dev/null +++ b/examples/go2rtc_mjpeg/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/api/ws" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/ffmpeg" + "github.com/AlexxIT/go2rtc/internal/mjpeg" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/internal/v4l2" + "github.com/AlexxIT/go2rtc/pkg/shell" +) + +func main() { + app.Init() + streams.Init() + + api.Init() + ws.Init() + + ffmpeg.Init() + mjpeg.Init() + v4l2.Init() + + shell.RunUntilSignal() +} diff --git a/examples/onvif_client/main.go b/examples/onvif_client/main.go new file mode 100644 index 00000000..03dd12ba --- /dev/null +++ b/examples/onvif_client/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "log" + "net" + "net/url" + "os" + + "github.com/AlexxIT/go2rtc/pkg/onvif" +) + +func main() { + var rawURL = os.Args[1] + var operation = os.Args[2] + var token string + if len(os.Args) > 3 { + token = os.Args[3] + } + + client, err := onvif.NewClient(rawURL) + if err != nil { + log.Panic(err) + } + + var b []byte + + switch operation { + case onvif.ServiceGetServiceCapabilities: + b, err = client.MediaRequest(operation) + case onvif.DeviceGetCapabilities, + onvif.DeviceGetDeviceInformation, + onvif.DeviceGetDiscoveryMode, + onvif.DeviceGetDNS, + onvif.DeviceGetHostname, + onvif.DeviceGetNetworkDefaultGateway, + onvif.DeviceGetNetworkInterfaces, + onvif.DeviceGetNetworkProtocols, + onvif.DeviceGetNTP, + onvif.DeviceGetScopes, + onvif.DeviceGetServices, + onvif.DeviceGetSystemDateAndTime, + onvif.DeviceSystemReboot: + b, err = client.DeviceRequest(operation) + case onvif.MediaGetProfiles, onvif.MediaGetVideoSources: + b, err = client.MediaRequest(operation) + case onvif.MediaGetProfile: + b, err = client.GetProfile(token) + case onvif.MediaGetVideoSourceConfiguration: + b, err = client.GetVideoSourceConfiguration(token) + case onvif.MediaGetStreamUri: + b, err = client.GetStreamUri(token) + case onvif.MediaGetSnapshotUri: + b, err = client.GetSnapshotUri(token) + default: + log.Printf("unknown action\n") + } + + if err != nil { + log.Printf("%s\n", err) + } + + u, err := url.Parse(rawURL) + if err != nil { + log.Fatal(err) + } + + host, _, _ := net.SplitHostPort(u.Host) + + if err = os.WriteFile(host+"_"+operation+".xml", b, 0644); err != nil { + log.Printf("%s\n", err) + } +} diff --git a/examples/rtsp_client/main.go b/examples/rtsp_client/main.go new file mode 100644 index 00000000..9c2112d1 --- /dev/null +++ b/examples/rtsp_client/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "log" + "os" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/rtsp" + "github.com/AlexxIT/go2rtc/pkg/shell" +) + +func main() { + client := rtsp.NewClient(os.Args[1]) + if err := client.Dial(); err != nil { + log.Panic(err) + } + + client.Medias = []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + {Name: core.CodecPCMU, ClockRate: 8000}, + }, + ID: "streamid=0", + }, + } + if err := client.Announce(); err != nil { + log.Panic(err) + } + if _, err := client.SetupMedia(client.Medias[0]); err != nil { + log.Panic(err) + } + if err := client.Record(); err != nil { + log.Panic(err) + } + + shell.RunUntilSignal() +} diff --git a/go.mod b/go.mod index b1ba4b4c..5f0a193b 100644 --- a/go.mod +++ b/go.mod @@ -1,48 +1,50 @@ module github.com/AlexxIT/go2rtc -go 1.21 +go 1.20 require ( github.com/asticode/go-astits v1.13.0 - github.com/expr-lang/expr v1.16.5 - github.com/gorilla/websocket v1.5.1 - github.com/miekg/dns v1.1.59 - github.com/pion/ice/v2 v2.3.24 - github.com/pion/interceptor v0.1.29 - github.com/pion/rtcp v1.2.14 - github.com/pion/rtp v1.8.6 + github.com/expr-lang/expr v1.16.9 + github.com/gorilla/websocket v1.5.3 + github.com/mattn/go-isatty v0.0.20 + github.com/miekg/dns v1.1.62 + github.com/pion/ice/v2 v2.3.37 + github.com/pion/interceptor v0.1.37 + github.com/pion/rtcp v1.2.15 + github.com/pion/rtp v1.8.10 github.com/pion/sdp/v3 v3.0.9 - github.com/pion/srtp/v2 v2.0.18 + github.com/pion/srtp/v2 v2.0.20 github.com/pion/stun v0.6.1 - github.com/pion/webrtc/v3 v3.2.40 - github.com/rs/zerolog v1.32.0 + github.com/pion/webrtc/v3 v3.3.5 + github.com/rs/zerolog v1.33.0 github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f - github.com/stretchr/testify v1.9.0 + github.com/stretchr/testify v1.10.0 github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 - golang.org/x/crypto v0.23.0 + golang.org/x/crypto v0.31.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/asticode/go-astikit v0.30.0 // indirect + github.com/asticode/go-astikit v0.45.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/kr/pretty v0.2.1 // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/pion/datachannel v1.5.6 // indirect - github.com/pion/dtls/v2 v2.2.11 // indirect + github.com/pion/datachannel v1.5.10 // indirect + github.com/pion/dtls/v2 v2.2.12 // indirect github.com/pion/logging v0.2.2 // indirect github.com/pion/mdns v0.0.12 // indirect github.com/pion/randutil v0.1.0 // indirect - github.com/pion/sctp v1.8.16 // indirect - github.com/pion/transport/v2 v2.2.5 // indirect + github.com/pion/sctp v1.8.35 // indirect + github.com/pion/transport/v2 v2.2.10 // indirect + github.com/pion/transport/v3 v3.0.7 // indirect github.com/pion/turn/v2 v2.1.6 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/mod v0.17.0 // indirect - golang.org/x/net v0.25.0 // indirect - golang.org/x/sync v0.7.0 // indirect - golang.org/x/sys v0.20.0 // indirect - golang.org/x/tools v0.20.0 // indirect + github.com/wlynxg/anet v0.0.5 // indirect + golang.org/x/mod v0.20.0 // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/tools v0.24.0 // indirect ) diff --git a/go.sum b/go.sum index 09b0abb9..c75ffced 100644 --- a/go.sum +++ b/go.sum @@ -1,46 +1,45 @@ -github.com/asticode/go-astikit v0.30.0 h1:DkBkRQRIxYcknlaU7W7ksNfn4gMFsB0tqMJflxkRsZA= github.com/asticode/go-astikit v0.30.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= +github.com/asticode/go-astikit v0.45.0 h1:08to/jrbod9tchF2bJ9moW+RTDK7DBUxLdIeSE7v7Sw= +github.com/asticode/go-astikit v0.45.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= github.com/asticode/go-astits v1.13.0 h1:XOgkaadfZODnyZRR5Y0/DWkA9vrkLLPLeeOvDwfKZ1c= github.com/asticode/go-astits v1.13.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/expr-lang/expr v1.16.5 h1:m2hvtguFeVaVNTHj8L7BoAyt7O0PAIBaSVbjdHgRXMs= -github.com/expr-lang/expr v1.16.5/go.mod h1:uCkhfG+x7fcZ5A5sXHKuQ07jGZRl6J0FCAaf2k4PtVQ= +github.com/expr-lang/expr v1.16.9 h1:WUAzmR0JNI9JCiF0/ewwHB1gmcGw5wW7nWt8gc6PpCI= +github.com/expr-lang/expr v1.16.9/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= -github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/miekg/dns v1.1.59 h1:C9EXc/UToRwKLhK5wKU/I4QVsBUc8kE6MkHBkeypWZs= -github.com/miekg/dns v1.1.59/go.mod h1:nZpewl5p6IvctfgrckopVx2OlSEHPRO/U4SYkRklrEk= -github.com/pion/datachannel v1.5.6 h1:1IxKJntfSlYkpUj8LlYRSWpYiTTC02nUrOE8T3DqGeg= -github.com/pion/datachannel v1.5.6/go.mod h1:1eKT6Q85pRnr2mHiWHxJwO50SfZRtWHTsNIVb/NfGW4= +github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ= +github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ= +github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= +github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= -github.com/pion/dtls/v2 v2.2.10 h1:u2Axk+FyIR1VFTPurktB+1zoEPGIW3bmyj3LEFrXjAA= -github.com/pion/dtls/v2 v2.2.10/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= -github.com/pion/dtls/v2 v2.2.11 h1:9U/dpCYl1ySttROPWJgqWKEylUdT0fXp/xst6JwY5Ks= -github.com/pion/dtls/v2 v2.2.11/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= -github.com/pion/ice/v2 v2.3.19 h1:1GoMRTMnB6bCP4aGy2MjxK3w4laDkk+m7svJb/eqybc= -github.com/pion/ice/v2 v2.3.19/go.mod h1:KXJJcZK7E8WzrBEYnV4UtqEZsGeWfHxsNqhVcVvgjxw= -github.com/pion/ice/v2 v2.3.24 h1:RYgzhH/u5lH0XO+ABatVKCtRd+4U1GEaCXSMjNr13tI= -github.com/pion/ice/v2 v2.3.24/go.mod h1:KXJJcZK7E8WzrBEYnV4UtqEZsGeWfHxsNqhVcVvgjxw= -github.com/pion/interceptor v0.1.29 h1:39fsnlP1U8gw2JzOFWdfCU82vHvhW9o0rZnZF56wF+M= -github.com/pion/interceptor v0.1.29/go.mod h1:ri+LGNjRUc5xUNtDEPzfdkmSqISixVTBF/z/Zms/6T4= +github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk= +github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= +github.com/pion/ice/v2 v2.3.37 h1:ObIdaNDu1rCo7hObhs34YSBcO7fjslJMZV0ux+uZWh0= +github.com/pion/ice/v2 v2.3.37/go.mod h1:mBF7lnigdqgtB+YHkaY/Y6s6tsyRyo4u4rPGRuOjUBQ= +github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI= +github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y= github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= github.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8= @@ -48,43 +47,42 @@ github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYF github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= -github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE= -github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= +github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= +github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= -github.com/pion/rtp v1.8.6 h1:MTmn/b0aWWsAzux2AmP8WGllusBVw4NPYPVFFd7jUPw= -github.com/pion/rtp v1.8.6/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= -github.com/pion/sctp v1.8.13/go.mod h1:YKSgO/bO/6aOMP9LCie1DuD7m+GamiK2yIiPM6vH+GA= -github.com/pion/sctp v1.8.16 h1:PKrMs+o9EMLRvFfXq59WFsC+V8mN1wnKzqrv+3D/gYY= -github.com/pion/sctp v1.8.16/go.mod h1:P6PbDVA++OJMrVNg2AL3XtYHV4uD6dvfyOovCgMs0PE= +github.com/pion/rtp v1.8.10 h1:puphjdbjPB+L+NFaVuZ5h6bt1g5q4kFIoI+r5q/g0CU= +github.com/pion/rtp v1.8.10/go.mod h1:8uMBJj32Pa1wwx8Fuv/AsFhn8jsgw+3rUC2PfoBZ8p4= +github.com/pion/sctp v1.8.35 h1:qwtKvNK1Wc5tHMIYgTDJhfZk7vATGVHhXbUDfHbYwzA= +github.com/pion/sctp v1.8.35/go.mod h1:EcXP8zCYVTRy3W9xtOF7wJm1L1aXfKRQzaM33SjQlzg= github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY= github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M= -github.com/pion/srtp/v2 v2.0.18 h1:vKpAXfawO9RtTRKZJbG4y0v1b11NZxQnxRl85kGuUlo= -github.com/pion/srtp/v2 v2.0.18/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA= +github.com/pion/srtp/v2 v2.0.20 h1:HNNny4s+OUmG280ETrCdgFndp4ufx3/uy85EawYEhTk= +github.com/pion/srtp/v2 v2.0.20/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA= github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4= github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8= github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= -github.com/pion/transport/v2 v2.2.2/go.mod h1:OJg3ojoBJopjEeECq2yJdXH9YVrUJ1uQ++NjXLOUorc= github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= -github.com/pion/transport/v2 v2.2.5 h1:iyi25i/21gQck4hfRhomF6SktmUQjRsRW4WJdhfc3Kc= -github.com/pion/transport/v2 v2.2.5/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= +github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q= +github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E= github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= -github.com/pion/transport/v3 v3.0.2 h1:r+40RJR25S9w3jbA6/5uEPTzcdn7ncyU44RWCbHkLg4= -github.com/pion/transport/v3 v3.0.2/go.mod h1:nIToODoOlb5If2jF9y2Igfx3PFYWfuXi37m0IlWa/D0= +github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= +github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY= github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc= github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY= -github.com/pion/webrtc/v3 v3.2.39 h1:Lf2SIMGdE3M9VNm48KpoX5pR8SJ6TsMnktzOkc/oB0o= -github.com/pion/webrtc/v3 v3.2.39/go.mod h1:AQ8p56OLbm3MjhYovYdgPuyX6oc+JcKx/HFoCGFcYzA= -github.com/pion/webrtc/v3 v3.2.40 h1:Wtfi6AZMQg+624cvCXUuSmrKWepSB7zfgYDOYqsSOVU= -github.com/pion/webrtc/v3 v3.2.40/go.mod h1:M1RAe3TNTD1tzyvqHrbVODfwdPGSXOUo/OgpoGGJqFY= +github.com/pion/webrtc/v3 v3.3.5 h1:ZsSzaMz/i9nblPdiAkZoP+E6Kmjw+jnyq3bEmU3EtRg= +github.com/pion/webrtc/v3 v3.3.5/go.mod h1:liNa+E1iwyzyXqNUwvoMRNQ10x8h8FOeJKL8RkIbamE= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0= -github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 h1:NVK+OqnavpyFmUiKfUMHrpvbCi2VFoWTrcpI7aDaJ2I= github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA= github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f h1:1R9KdKjCNSd7F8iGTxIpoID9prlYH8nuNYKt0XvweHA= @@ -98,47 +96,42 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 h1:aeN+ghOV0b2VCmKKO3gqnDQ8mLbpABZgRR2FVYx4ouI= github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9/go.mod h1:roo6cZ/uqpwKMuvPG0YmzI5+AmUiMWfjCBZpGXqbTxE= +github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= -golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= -golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= -golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= -golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= +golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= -golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= -golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -150,40 +143,37 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= -golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY= -golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg= +golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= +golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= +golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= +golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= +golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= +golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE= +golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= diff --git a/hardware.Dockerfile b/hardware.Dockerfile index 0aa85374..2254f9be 100644 --- a/hardware.Dockerfile +++ b/hardware.Dockerfile @@ -4,7 +4,7 @@ # only debian 13 (trixie) has latest ffmpeg # https://packages.debian.org/trixie/ffmpeg ARG DEBIAN_VERSION="trixie-slim" -ARG GO_VERSION="1.21-bookworm" +ARG GO_VERSION="1.22-bookworm" ARG NGROK_VERSION="3" FROM debian:${DEBIAN_VERSION} AS base diff --git a/internal/api/api.go b/internal/api/api.go index 86817bd0..419e2bdf 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -69,6 +69,8 @@ func Init() { } if cfg.Mod.Listen != "" { + _, port, _ := net.SplitHostPort(cfg.Mod.Listen) + Port, _ = strconv.Atoi(port) go listen("tcp", cfg.Mod.Listen) } @@ -92,10 +94,6 @@ func listen(network, address string) { log.Info().Str("addr", address).Msg("[api] listen") - if network == "tcp" { - Port = ln.Addr().(*net.TCPAddr).Port - } - server := http.Server{ Handler: Handler, ReadHeaderTimeout: 5 * time.Second, // Example: Set to 5 seconds diff --git a/internal/api/ws/ws.go b/internal/api/ws/ws.go index 800a377d..1d945bfe 100644 --- a/internal/api/ws/ws.go +++ b/internal/api/ws/ws.go @@ -1,6 +1,7 @@ package ws import ( + "encoding/json" "io" "net/http" "net/url" @@ -38,20 +39,19 @@ type Message struct { Value any `json:"value,omitempty"` } -func (m *Message) String() string { +func (m *Message) String() (value string) { if s, ok := m.Value.(string); ok { return s } - return "" + return } -func (m *Message) GetString(key string) string { - if v, ok := m.Value.(map[string]any); ok { - if s, ok := v[key].(string); ok { - return s - } +func (m *Message) Unmarshal(v any) error { + b, err := json.Marshal(m.Value) + if err != nil { + return err } - return "" + return json.Unmarshal(b, v) } type WSHandler func(tr *Transport, msg *Message) error diff --git a/internal/app/README.md b/internal/app/README.md index e3d0571f..9ec3d9fc 100644 --- a/internal/app/README.md +++ b/internal/app/README.md @@ -1,6 +1,10 @@ - By default go2rtc will search config file `go2rtc.yaml` in current work directory -- go2rtc support multiple config files -- go2rtc support inline config as `YAML`, `JSON` or `key=value` format from command line +- 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) ``` @@ -15,21 +19,30 @@ go2rtc -c log.format=text -c /config/go2rtc.yaml -c rtsp.listen='' -c /usr/local ## Environment variables -Also go2rtc support templates for using environment variables in any part of config: +There is support for loading external variables into the config. First, they will be attempted to be loaded from [credential files](https://systemd.io/CREDENTIALS). If `CREDENTIALS_DIRECTORY` is not set, then the key will be loaded from an environment variable. If no environment variable is set, then the string will be left as-is. ```yaml streams: camera1: rtsp://rtsp:${CAMERA_PASSWORD}@192.168.1.123/av_stream/ch0 - ${LOGS:} # empty default value - rtsp: - username: ${RTSP_USER:admin} # "admin" if env "RTSP_USER" not set - password: ${RTSP_PASS:secret} # "secret" if env "RTSP_PASS" not set + username: ${RTSP_USER:admin} # "admin" if "RTSP_USER" not set + password: ${RTSP_PASS:secret} # "secret" if "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" @@ -38,7 +51,10 @@ ffmpeg: bin: "ffmpeg" log: + format: "color" level: "info" + output: "stdout" + time: "UNIXMS" rtsp: listen: ":8554" @@ -51,4 +67,4 @@ webrtc: listen: ":8555/tcp" ice_servers: - urls: [ "stun:stun.l.google.com:19302" ] -``` \ No newline at end of file +``` diff --git a/internal/app/app.go b/internal/app/app.go index 9dec2848..eb803584 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,29 +1,20 @@ package app import ( - "errors" "flag" "fmt" "os" "os/exec" - "path/filepath" "runtime" "runtime/debug" - "strings" - - "github.com/AlexxIT/go2rtc/pkg/shell" - "github.com/AlexxIT/go2rtc/pkg/yaml" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" ) -var Version = "1.9.2" -var UserAgent = "go2rtc/" + Version - -var ConfigPath string -var Info = map[string]any{ - "version": Version, -} +var ( + Version string + UserAgent string + ConfigPath string + Info = make(map[string]any) +) const usage = `Usage of go2rtc: @@ -33,12 +24,12 @@ const usage = `Usage of go2rtc: ` func Init() { - var confs Config + var config flagConfig var daemon bool var version bool - flag.Var(&confs, "config", "") - flag.Var(&confs, "c", "") + flag.Var(&config, "config", "") + flag.Var(&config, "c", "") flag.BoolVar(&daemon, "daemon", false, "") flag.BoolVar(&daemon, "d", false, "") flag.BoolVar(&version, "version", false, "") @@ -54,133 +45,39 @@ func Init() { os.Exit(0) } - if daemon { + if daemon && os.Getppid() != 1 { if runtime.GOOS == "windows" { - fmt.Println("Daemon not supported on Windows") + fmt.Println("Daemon mode is not supported on Windows") os.Exit(1) } - args := os.Args[1:] - for i, arg := range args { - if arg == "-daemon" { - args[i] = "" - } - } // Re-run the program in background and exit - cmd := exec.Command(os.Args[0], args...) + cmd := exec.Command(os.Args[0], os.Args[1:]...) if err := cmd.Start(); err != nil { - log.Fatal().Err(err).Send() + fmt.Println("Failed to start daemon:", err) + os.Exit(1) } fmt.Println("Running in daemon mode with PID:", cmd.Process.Pid) os.Exit(0) } - if confs == nil { - confs = []string{"go2rtc.yaml"} - } - - 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 - } + UserAgent = "go2rtc/" + Version + Info["version"] = Version Info["revision"] = revision - var cfg struct { - Mod map[string]string `yaml:"log"` - } - - cfg.Mod = map[string]string{ - "format": "", // useless, but anyway - "level": "info", - "output": "stdout", // TODO: change to stderr someday - "time": zerolog.TimeFormatUnixMs, - } - - LoadConfig(&cfg) - - log.Logger = NewLogger(cfg.Mod) - - modules = cfg.Mod + initConfig(config) + initLogger() platform := fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH) - log.Info().Str("version", Version).Str("platform", platform).Str("revision", revision).Msg("go2rtc") - log.Debug().Str("version", runtime.Version()).Str("vcs.time", vcsTime).Msg("build") + Logger.Info().Str("version", Version).Str("platform", platform).Str("revision", revision).Msg("go2rtc") + Logger.Debug().Str("version", runtime.Version()).Str("vcs.time", vcsTime).Msg("build") if ConfigPath != "" { - log.Info().Str("path", ConfigPath).Msg("config") - } - - 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") - } + Logger.Info().Str("path", ConfigPath).Msg("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 - func readRevisionTime() (revision, vcsTime string) { if info, ok := debug.ReadBuildInfo(); ok { for _, setting := range info.Settings { @@ -202,25 +99,3 @@ func readRevisionTime() (revision, vcsTime string) { } return } - -func parseConfString(s string) []byte { - i := strings.IndexByte(s, '=') - if i < 0 { - return nil - } - - items := strings.Split(s[:i], ".") - if len(items) < 2 { - return nil - } - - // `log.level=trace` => `{log: {level: trace}}` - var pre string - var suf = s[i+1:] - for _, item := range items { - pre += "{" + item + ": " - suf += "}" - } - - return []byte(pre + suf) -} diff --git a/internal/app/config.go b/internal/app/config.go new file mode 100644 index 00000000..8ae6d460 --- /dev/null +++ b/internal/app/config.go @@ -0,0 +1,109 @@ +package app + +import ( + "errors" + "os" + "path/filepath" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/shell" + "github.com/AlexxIT/go2rtc/pkg/yaml" +) + +func LoadConfig(v any) { + for _, data := range configs { + if err := yaml.Unmarshal(data, v); err != nil { + Logger.Warn().Err(err).Send() + } + } +} + +func PatchConfig(key string, value any, path ...string) error { + if ConfigPath == "" { + return errors.New("config file disabled") + } + + // empty config is OK + b, _ := os.ReadFile(ConfigPath) + + b, err := yaml.Patch(b, key, value, path...) + if err != nil { + return err + } + + return os.WriteFile(ConfigPath, b, 0644) +} + +type flagConfig []string + +func (c *flagConfig) String() string { + return strings.Join(*c, " ") +} + +func (c *flagConfig) Set(value string) error { + *c = append(*c, value) + return nil +} + +var configs [][]byte + +func initConfig(confs flagConfig) { + if confs == nil { + confs = []string{"go2rtc.yaml"} + } + + for _, conf := range confs { + if len(conf) == 0 { + continue + } + if conf[0] == '{' { + // config as raw YAML or JSON + configs = append(configs, []byte(conf)) + } else if data := parseConfString(conf); data != nil { + configs = append(configs, data) + } else { + // config as file + if ConfigPath == "" { + ConfigPath = conf + } + + if data, _ = os.ReadFile(conf); data == nil { + continue + } + + data = []byte(shell.ReplaceEnvVars(string(data))) + configs = append(configs, data) + } + } + + if ConfigPath != "" { + if !filepath.IsAbs(ConfigPath) { + if cwd, err := os.Getwd(); err == nil { + ConfigPath = filepath.Join(cwd, ConfigPath) + } + } + Info["config_path"] = ConfigPath + } +} + +func parseConfString(s string) []byte { + i := strings.IndexByte(s, '=') + if i < 0 { + return nil + } + + items := strings.Split(s[:i], ".") + if len(items) < 2 { + return nil + } + + // `log.level=trace` => `{log: {level: trace}}` + var pre string + var suf = s[i+1:] + for _, item := range items { + pre += "{" + item + ": " + suf += "}" + } + + return []byte(pre + suf) +} diff --git a/internal/app/log.go b/internal/app/log.go index 713110e8..4a5a0ee4 100644 --- a/internal/app/log.go +++ b/internal/app/log.go @@ -7,20 +7,39 @@ import ( "github.com/mattn/go-isatty" "github.com/rs/zerolog" - "github.com/rs/zerolog/log" ) var MemoryLog = newBuffer(16) -// NewLogger support: +func GetLogger(module string) zerolog.Logger { + if s, ok := modules[module]; ok { + lvl, err := zerolog.ParseLevel(s) + if err == nil { + return Logger.Level(lvl) + } + Logger.Warn().Err(err).Caller().Send() + } + + 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 NewLogger(config map[string]string) zerolog.Logger { +func initLogger() { + var cfg struct { + Mod map[string]string `yaml:"log"` + } + + cfg.Mod = modules // defaults + + LoadConfig(&cfg) + var writer io.Writer - switch output, path, _ := strings.Cut(config["output"], ":"); output { + switch output, path, _ := strings.Cut(modules["output"], ":"); output { case "stderr": writer = os.Stderr case "stdout": @@ -33,10 +52,10 @@ func NewLogger(config map[string]string) zerolog.Logger { writer, _ = os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) } - timeFormat := config["time"] + timeFormat := modules["time"] if writer != nil { - if format := config["format"]; format != "json" { + if format := modules["format"]; format != "json" { console := &zerolog.ConsoleWriter{Out: writer} switch format { @@ -68,31 +87,24 @@ func NewLogger(config map[string]string) zerolog.Logger { writer = MemoryLog } - logger := zerolog.New(writer) + lvl, _ := zerolog.ParseLevel(modules["level"]) + Logger = zerolog.New(writer).Level(lvl) if timeFormat != "" { zerolog.TimeFieldFormat = timeFormat - logger = logger.With().Timestamp().Logger() + Logger = Logger.With().Timestamp().Logger() } - - lvl, _ := zerolog.ParseLevel(config["level"]) - return logger.Level(lvl) } -func GetLogger(module string) zerolog.Logger { - if s, ok := modules[module]; ok { - lvl, err := zerolog.ParseLevel(s) - if err == nil { - return log.Level(lvl) - } - log.Warn().Err(err).Caller().Send() - } - - return log.Logger -} +var Logger zerolog.Logger // modules log levels -var modules map[string]string +var modules = map[string]string{ + "format": "", // useless, but anyway + "level": "info", + "output": "stdout", // TODO: change to stderr someday + "time": zerolog.TimeFormatUnixMs, +} const chunkSize = 1 << 16 diff --git a/internal/app/migrate.go b/internal/app/migrate.go deleted file mode 100644 index 95c51c51..00000000 --- a/internal/app/migrate.go +++ /dev/null @@ -1,35 +0,0 @@ -package app - -import ( - "encoding/json" - "os" - - "github.com/rs/zerolog/log" -) - -func migrateStore() { - const name = "go2rtc.json" - - data, _ := os.ReadFile(name) - if data == nil { - return - } - - var store struct { - Streams map[string]string `json:"streams"` - } - - if err := json.Unmarshal(data, &store); err != nil { - log.Warn().Err(err).Caller().Send() - return - } - - for id, url := range store.Streams { - if err := PatchConfig(id, url, "streams"); err != nil { - log.Warn().Err(err).Caller().Send() - return - } - } - - _ = os.Remove(name) -} diff --git a/internal/bubble/bubble.go b/internal/bubble/bubble.go index 65d0237e..6c526fc5 100644 --- a/internal/bubble/bubble.go +++ b/internal/bubble/bubble.go @@ -7,13 +7,7 @@ import ( ) func Init() { - streams.HandleFunc("bubble", handle) -} - -func handle(url string) (core.Producer, error) { - conn := bubble.NewClient(url) - if err := conn.Dial(); err != nil { - return nil, err - } - return conn, nil + streams.HandleFunc("bubble", func(source string) (core.Producer, error) { + return bubble.Dial(source) + }) } diff --git a/internal/debug/debug.go b/internal/debug/debug.go index 3d40d1f1..fc7d2453 100644 --- a/internal/debug/debug.go +++ b/internal/debug/debug.go @@ -2,16 +2,8 @@ package debug import ( "github.com/AlexxIT/go2rtc/internal/api" - "github.com/AlexxIT/go2rtc/internal/streams" - "github.com/AlexxIT/go2rtc/pkg/core" ) func Init() { api.HandleFunc("api/stack", stackHandler) - - streams.HandleFunc("null", nullHandler) -} - -func nullHandler(string) (core.Producer, error) { - return nil, nil } diff --git a/internal/doorbird/doorbird.go b/internal/doorbird/doorbird.go new file mode 100644 index 00000000..c56fc0f9 --- /dev/null +++ b/internal/doorbird/doorbird.go @@ -0,0 +1,36 @@ +package doorbird + +import ( + "net/url" + + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/doorbird" +) + +func Init() { + streams.RedirectFunc("doorbird", func(rawURL string) (string, error) { + u, err := url.Parse(rawURL) + if err != nil { + return "", err + } + + // https://www.doorbird.com/downloads/api_lan.pdf + switch u.Query().Get("media") { + case "video": + u.Path = "/bha-api/video.cgi" + case "audio": + u.Path = "/bha-api/audio-receive.cgi" + default: + return "", nil + } + + u.Scheme = "http" + + return u.String(), nil + }) + + streams.HandleFunc("doorbird", func(source string) (core.Producer, error) { + return doorbird.Dial(source) + }) +} diff --git a/internal/dvrip/dvrip.go b/internal/dvrip/dvrip.go index 470e8afd..db1c60db 100644 --- a/internal/dvrip/dvrip.go +++ b/internal/dvrip/dvrip.go @@ -10,26 +10,16 @@ import ( "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/streams" - "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/dvrip" - "github.com/rs/zerolog/log" ) func Init() { - streams.HandleFunc("dvrip", handle) + streams.HandleFunc("dvrip", dvrip.Dial) // DVRIP client autodiscovery api.HandleFunc("api/dvrip", apiDvrip) } -func handle(url string) (core.Producer, error) { - client, err := dvrip.Dial(url) - if err != nil { - return nil, err - } - return client, nil -} - const Port = 34569 // UDP port number for dvrip discovery func apiDvrip(w http.ResponseWriter, r *http.Request) { @@ -92,10 +82,7 @@ func sendBroadcasts(conn *net.UDPConn) { for i := 0; i < 3; i++ { time.Sleep(100 * time.Millisecond) - - if _, err = conn.WriteToUDP(data, addr); err != nil { - log.Err(err).Caller().Send() - } + _, _ = conn.WriteToUDP(data, addr) } } diff --git a/internal/exec/exec.go b/internal/exec/exec.go index 454c54a4..89add393 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -1,15 +1,17 @@ package exec import ( + "bufio" "crypto/md5" "encoding/hex" "errors" "fmt" + "io" "net/url" "os" - "os/exec" "strings" "sync" + "syscall" "time" "github.com/AlexxIT/go2rtc/internal/app" @@ -47,9 +49,11 @@ func Init() { log = app.GetLogger("exec") } -func execHandle(rawURL string) (core.Producer, error) { +func execHandle(rawURL string) (prod core.Producer, err error) { + rawURL, rawQuery, _ := strings.Cut(rawURL, "#") + query := streams.ParseQuery(rawQuery) + var path string - var query url.Values // RTSP flow should have `{output}` inside URL // pipe flow may have `#{params}` inside URL @@ -61,35 +65,59 @@ func execHandle(rawURL string) (core.Producer, error) { sum := md5.Sum([]byte(rawURL)) path = "/" + hex.EncodeToString(sum[:]) rawURL = rawURL[:i] + "rtsp://127.0.0.1:" + rtsp.Port + path + rawURL[i+8:] - } else if i = strings.IndexByte(rawURL, '#'); i > 0 { - query = streams.ParseQuery(rawURL[i+1:]) - rawURL = rawURL[:i] } - args := shell.QuoteSplit(rawURL[5:]) // remove `exec:` - cmd := exec.Command(args[0], args[1:]...) + cmd := shell.NewCommand(rawURL[5:]) // remove `exec:` cmd.Stderr = &logWriter{ buf: make([]byte, 512), debug: log.Debug().Enabled(), } - if path == "" { - return handlePipe(rawURL, cmd, query) + if s := query.Get("killsignal"); s != "" { + sig := syscall.Signal(core.Atoi(s)) + cmd.Cancel = func() error { + log.Debug().Msgf("[exec] kill with signal=%d", sig) + return cmd.Process.Signal(sig) + } } - return handleRTSP(rawURL, cmd, path) -} + if s := query.Get("killtimeout"); s != "" { + cmd.WaitDelay = time.Duration(core.Atoi(s)) * time.Second + } -func handlePipe(_ string, cmd *exec.Cmd, query url.Values) (core.Producer, error) { if query.Get("backchannel") == "1" { return stdin.NewClient(cmd) } - r, err := PipeCloser(cmd, query) + if path == "" { + prod, err = handlePipe(rawURL, cmd) + } else { + prod, err = handleRTSP(rawURL, cmd, path) + } + + if err != nil { + _ = cmd.Close() + } + + return +} + +func handlePipe(source string, cmd *shell.Command) (core.Producer, error) { + stdout, err := cmd.StdoutPipe() if err != nil { return nil, err } + rd := struct { + io.Reader + io.Closer + }{ + // add buffer for pipe reader to reduce syscall + bufio.NewReaderSize(stdout, core.BufferSize), + // stop cmd on close pipe call + cmd, + } + log.Debug().Strs("args", cmd.Args).Msg("[exec] run pipe") ts := time.Now() @@ -98,22 +126,27 @@ func handlePipe(_ string, cmd *exec.Cmd, query url.Values) (core.Producer, error return nil, err } - prod, err := magic.Open(r) + prod, err := magic.Open(rd) if err != nil { - _ = r.Close() + return nil, fmt.Errorf("exec/pipe: %w\n%s", err, cmd.Stderr) + } + + 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, fmt.Errorf("exec/pipe: %w\n%s", err, cmd.Stderr) + return prod, nil } -func handleRTSP(url string, cmd *exec.Cmd, path string) (core.Producer, error) { +func handleRTSP(source string, cmd *shell.Command, path string) (core.Producer, error) { if log.Trace().Enabled() { cmd.Stdout = os.Stdout } - waiter := make(chan core.Producer) + waiter := make(chan *pkg.Conn, 1) waitersMu.Lock() waiters[path] = waiter @@ -130,25 +163,26 @@ func handleRTSP(url string, cmd *exec.Cmd, path string) (core.Producer, error) { ts := time.Now() if err := cmd.Start(); err != nil { - log.Error().Err(err).Str("url", url).Msg("[exec]") + log.Error().Err(err).Str("source", source).Msg("[exec]") return nil, err } - done := make(chan error, 1) - go func() { - done <- cmd.Wait() - }() + timeout := time.NewTimer(30 * time.Second) + defer timeout.Stop() select { - case <-time.After(time.Second * 60): - _ = cmd.Process.Kill() - log.Error().Str("url", url).Msg("[exec] timeout") + case <-timeout.C: + // haven't received data from app in timeout + log.Error().Str("source", source).Msg("[exec] timeout") return nil, errors.New("exec: timeout") - case <-done: - // limit message size + case <-cmd.Done(): + // app fail before we receive any data return nil, fmt.Errorf("exec/rtsp\n%s", cmd.Stderr) case prod := <-waiter: + // app started successfully log.Debug().Stringer("launch", time.Since(ts)).Msg("[exec] run rtsp") + setRemoteInfo(prod, source, cmd.Args) + prod.OnClose = cmd.Close return prod, nil } } @@ -157,7 +191,7 @@ func handleRTSP(url string, cmd *exec.Cmd, path string) (core.Producer, error) { var ( log zerolog.Logger - waiters = map[string]chan core.Producer{} + waiters = make(map[string]chan *pkg.Conn) waitersMu sync.Mutex ) @@ -205,3 +239,15 @@ func trimSpace(b []byte) []byte { } return b[start:stop] } + +func setRemoteInfo(info core.Info, source string, args []string) { + info.SetSource(source) + + if i := core.Index(args, "-i"); i > 0 && i < len(args)-1 { + rawURL := args[i+1] + if u, err := url.Parse(rawURL); err == nil && u.Host != "" { + info.SetRemoteAddr(u.Host) + info.SetURL(rawURL) + } + } +} diff --git a/internal/exec/pipe.go b/internal/exec/pipe.go deleted file mode 100644 index 12ea136b..00000000 --- a/internal/exec/pipe.go +++ /dev/null @@ -1,56 +0,0 @@ -package exec - -import ( - "bufio" - "errors" - "io" - "net/url" - "os/exec" - "syscall" - "time" - - "github.com/AlexxIT/go2rtc/pkg/core" -) - -// PipeCloser - return StdoutPipe that Kill cmd on Close call -func PipeCloser(cmd *exec.Cmd, query url.Values) (io.ReadCloser, error) { - stdout, err := cmd.StdoutPipe() - if err != nil { - return nil, err - } - - // add buffer for pipe reader to reduce syscall - return &pipeCloser{bufio.NewReaderSize(stdout, core.BufferSize), stdout, cmd, query}, nil -} - -type pipeCloser struct { - io.Reader - io.Closer - cmd *exec.Cmd - query url.Values -} - -func (p *pipeCloser) Close() error { - return errors.Join(p.Closer.Close(), p.Kill(), p.Wait()) -} - -func (p *pipeCloser) Kill() error { - if s := p.query.Get("killsignal"); s != "" { - log.Trace().Msgf("[exec] kill with custom sig=%s", s) - sig := syscall.Signal(core.Atoi(s)) - return p.cmd.Process.Signal(sig) - } - return p.cmd.Process.Kill() -} - -func (p *pipeCloser) Wait() error { - if s := p.query.Get("killtimeout"); s != "" { - timeout := time.Duration(core.Atoi(s)) * time.Second - timer := time.AfterFunc(timeout, func() { - log.Trace().Msgf("[exec] kill after timeout=%s", s) - _ = p.cmd.Process.Kill() - }) - defer timer.Stop() // stop timer if Wait ends before timeout - } - return p.cmd.Wait() -} diff --git a/internal/ffmpeg/ffmpeg.go b/internal/ffmpeg/ffmpeg.go index aeba85fb..25d61e4b 100644 --- a/internal/ffmpeg/ffmpeg.go +++ b/internal/ffmpeg/ffmpeg.go @@ -2,7 +2,6 @@ package ffmpeg import ( "net/url" - "slices" "strings" "github.com/AlexxIT/go2rtc/internal/api" @@ -14,6 +13,7 @@ import ( "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/ffmpeg" + "github.com/rs/zerolog" ) func Init() { @@ -29,6 +29,8 @@ func Init() { app.LoadConfig(&cfg) + log = app.GetLogger("ffmpeg") + // zerolog levels: trace debug info warn error fatal panic disabled // FFmpeg levels: trace debug verbose info warning error fatal panic quiet if cfg.Log.Level == "warn" { @@ -41,7 +43,7 @@ func Init() { return "", err } args := parseArgs(url[7:]) - if slices.Contains(args.Codecs, "auto") { + if core.Contains(args.Codecs, "auto") { return "", nil // force call streams.HandleFunc("ffmpeg") } return "exec:" + args.String(), nil @@ -145,6 +147,8 @@ var defaults = map[string]string{ "h265/videotoolbox": "-c:v hevc_videotoolbox -g 50 -bf 0 -profile:v main -level:v 5.1", } +var log zerolog.Logger + // configTemplate - return template from config (defaults) if exist or return raw template func configTemplate(template string) string { if s := defaults[template]; s != "" { @@ -175,6 +179,7 @@ func parseArgs(s string) *ffmpeg.Args { Version: verAV, } + var source = s var query url.Values if i := strings.IndexByte(s, '#'); i >= 0 { query = streams.ParseQuery(s[i+1:]) @@ -217,6 +222,10 @@ func parseArgs(s string) *ffmpeg.Args { default: s += "?video&audio" } + s += "&source=ffmpeg:" + url.QueryEscape(source) + for _, v := range query["query"] { + s += "&" + v + } args.Input = inputTemplate("rtsp", s, query) } else if i = strings.Index(s, "?"); i > 0 { switch s[:i] { diff --git a/internal/ffmpeg/ffmpeg_test.go b/internal/ffmpeg/ffmpeg_test.go index 3fc5d208..2ab1170d 100644 --- a/internal/ffmpeg/ffmpeg_test.go +++ b/internal/ffmpeg/ffmpeg_test.go @@ -31,7 +31,7 @@ func TestParseArgsFile(t *testing.T) { { name: "[FILE] video will be transcoded to H265 and rotate 270ยบ, audio will be skipped", source: "/media/bbb.mp4#video=h265#rotate=-90", - expect: `ffmpeg -hide_banner -re -i /media/bbb.mp4 -c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency -an -vf "transpose=2" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + expect: `ffmpeg -hide_banner -re -i /media/bbb.mp4 -c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p -an -vf "transpose=2" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, }, { name: "[FILE] video will be output for MJPEG to pipe, audio will be skipped", @@ -53,85 +53,143 @@ func TestParseArgsFile(t *testing.T) { } func TestParseArgsDevice(t *testing.T) { - // [DEVICE] video will be output for MJPEG to pipe, with size 1920x1080 - args := parseArgs("device?video=0&video_size=1920x1080") - require.Equal(t, `ffmpeg -hide_banner -f dshow -video_size 1920x1080 -i "video=0" -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) - - // [DEVICE] video will be transcoded to H265 with framerate 20, audio will be skipped - //args = parseArgs("device?video=0&video_size=1280x720&framerate=20#video=h265#audio=pcma") - args = parseArgs("device?video=0&framerate=20#video=h265") - require.Equal(t, `ffmpeg -hide_banner -f dshow -framerate 20 -i "video=0" -c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) - - args = parseArgs("device?video=FaceTime HD Camera&audio=Microphone (High Definition Audio Device)") - require.Equal(t, `ffmpeg -hide_banner -f dshow -i "video=FaceTime HD Camera:audio=Microphone (High Definition Audio Device)" -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) + tests := []struct { + name string + source string + expect string + }{ + { + name: "[DEVICE] video will be output for MJPEG to pipe, with size 1920x1080", + source: "device?video=0&video_size=1920x1080", + expect: `ffmpeg -hide_banner -f dshow -video_size 1920x1080 -i "video=0" -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[DEVICE] video will be transcoded to H265 with framerate 20, audio will be skipped", + source: "device?video=0&framerate=20#video=h265", + expect: `ffmpeg -hide_banner -f dshow -framerate 20 -i "video=0" -c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[DEVICE] video/audio", + source: "device?video=FaceTime HD Camera&audio=Microphone (High Definition Audio Device)", + expect: `ffmpeg -hide_banner -f dshow -i "video=FaceTime HD Camera:audio=Microphone (High Definition Audio Device)" -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()) + }) + } } func TestParseArgsIpCam(t *testing.T) { - // [HTTP] video will be copied - args := parseArgs("http://example.com") - require.Equal(t, `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -i http://example.com -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) - - // [HTTP-MJPEG] video will be transcoded to H264 - args = parseArgs("http://example.com#video=h264") - require.Equal(t, `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -i http://example.com -c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) - - // [HLS] video will be copied, audio will be skipped - args = parseArgs("https://example.com#video=copy") - require.Equal(t, `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -i https://example.com -c:v copy -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) - - // [RTSP] video will be copied without transcoding codecs - args = parseArgs("rtsp://example.com") - require.Equal(t, `ffmpeg -hide_banner -allowed_media_types video+audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) - - // [RTSP] video with resize to 1280x720, should be transcoded, so select H265 - args = parseArgs("rtsp://example.com#video=h265#width=1280#height=720") - require.Equal(t, `ffmpeg -hide_banner -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency -an -vf "scale=1280:720" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) - - // [RTSP] video will be copied, changing RTSP transport from TCP to UDP+TCP - args = parseArgs("rtsp://example.com#input=rtsp/udp") - require.Equal(t, `ffmpeg -hide_banner -allowed_media_types video+audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -i rtsp://example.com -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) - - // [RTMP] video will be copied, changing RTSP transport from TCP to UDP+TCP - args = parseArgs("rtmp://example.com#input=rtsp/udp") - require.Equal(t, `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -i rtmp://example.com -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) + tests := []struct { + name string + source string + expect string + }{ + { + name: "[HTTP] video will be copied", + source: "http://example.com", + expect: `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -i http://example.com -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[HTTP-MJPEG] video will be transcoded to H264", + source: "http://example.com#video=h264", + expect: `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -i http://example.com -c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[HLS] video will be copied, audio will be skipped", + source: "https://example.com#video=copy", + expect: `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -i https://example.com -c:v copy -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[RTSP] video will be copied without transcoding codecs", + source: "rtsp://example.com", + expect: `ffmpeg -hide_banner -allowed_media_types video+audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[RTSP] video with resize to 1280x720, should be transcoded, so select H265", + source: "rtsp://example.com#video=h265#width=1280#height=720", + expect: `ffmpeg -hide_banner -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p -an -vf "scale=1280:720" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[RTSP] video will be copied, changing RTSP transport from TCP to UDP+TCP", + source: "rtsp://example.com#input=rtsp/udp", + expect: `ffmpeg -hide_banner -allowed_media_types video+audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -i rtsp://example.com -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[RTMP] video will be copied, changing RTSP transport from TCP to UDP+TCP", + source: "rtmp://example.com#input=rtsp/udp", + expect: `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -i rtmp://example.com -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()) + }) + } } func TestParseArgsAudio(t *testing.T) { - // [AUDIO] audio will be transcoded to AAC, video will be skipped - args := parseArgs("rtsp:///example.com#audio=aac") - require.Equal(t, `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp:///example.com -c:a aac -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) - - // [AUDIO] audio will be transcoded to AAC/16000, video will be skipped - args = parseArgs("rtsp:///example.com#audio=aac/16000") - require.Equal(t, `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp:///example.com -c:a aac -ar:a 16000 -ac:a 1 -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) - - // [AUDIO] audio will be transcoded to OPUS, video will be skipped - args = parseArgs("rtsp:///example.com#audio=opus") - require.Equal(t, `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp:///example.com -c:a libopus -application:a lowdelay -min_comp 0 -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) - - // [AUDIO] audio will be transcoded to PCMU, video will be skipped - args = parseArgs("rtsp:///example.com#audio=pcmu") - require.Equal(t, `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp:///example.com -c:a pcm_mulaw -ar:a 8000 -ac:a 1 -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) - - // [AUDIO] audio will be transcoded to PCMU/16000, video will be skipped - args = parseArgs("rtsp:///example.com#audio=pcmu/16000") - require.Equal(t, `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp:///example.com -c:a pcm_mulaw -ar:a 16000 -ac:a 1 -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) - - // [AUDIO] audio will be transcoded to PCMU/48000, video will be skipped - args = parseArgs("rtsp:///example.com#audio=pcmu/48000") - require.Equal(t, `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp:///example.com -c:a pcm_mulaw -ar:a 48000 -ac:a 1 -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) - - // [AUDIO] audio will be transcoded to PCMA, video will be skipped - args = parseArgs("rtsp:///example.com#audio=pcma") - require.Equal(t, `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp:///example.com -c:a pcm_alaw -ar:a 8000 -ac:a 1 -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) - - // [AUDIO] audio will be transcoded to PCMA/16000, video will be skipped - args = parseArgs("rtsp:///example.com#audio=pcma/16000") - require.Equal(t, `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp:///example.com -c:a pcm_alaw -ar:a 16000 -ac:a 1 -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) - - // [AUDIO] audio will be transcoded to PCMA/48000, video will be skipped - args = parseArgs("rtsp:///example.com#audio=pcma/48000") - require.Equal(t, `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp:///example.com -c:a pcm_alaw -ar:a 48000 -ac:a 1 -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String()) + tests := []struct { + name string + source string + expect string + }{ + { + name: "[AUDIO] audio will be transcoded to AAC, video will be skipped", + source: "rtsp://example.com#audio=aac", + expect: `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:a aac -vn -f adts -`, + }, + { + name: "[AUDIO] audio will be transcoded to AAC/16000, video will be skipped", + source: "rtsp://example.com#audio=aac/16000", + expect: `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:a aac -ar:a 16000 -ac:a 1 -vn -f adts -`, + }, + { + name: "[AUDIO] audio will be transcoded to OPUS, video will be skipped", + source: "rtsp://example.com#audio=opus", + expect: `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:a libopus -application:a lowdelay -min_comp 0 -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[AUDIO] audio will be transcoded to PCMU, video will be skipped", + source: "rtsp://example.com#audio=pcmu", + expect: `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:a pcm_mulaw -ar:a 8000 -ac:a 1 -vn -f wav -`, + }, + { + name: "[AUDIO] audio will be transcoded to PCMU/16000, video will be skipped", + source: "rtsp://example.com#audio=pcmu/16000", + expect: `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:a pcm_mulaw -ar:a 16000 -ac:a 1 -vn -f wav -`, + }, + { + name: "[AUDIO] audio will be transcoded to PCMU/48000, video will be skipped", + source: "rtsp://example.com#audio=pcmu/48000", + expect: `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:a pcm_mulaw -ar:a 48000 -ac:a 1 -vn -f wav -`, + }, + { + name: "[AUDIO] audio will be transcoded to PCMA, video will be skipped", + source: "rtsp://example.com#audio=pcma", + expect: `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:a pcm_alaw -ar:a 8000 -ac:a 1 -vn -f wav -`, + }, + { + name: "[AUDIO] audio will be transcoded to PCMA/16000, video will be skipped", + source: "rtsp://example.com#audio=pcma/16000", + expect: `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:a pcm_alaw -ar:a 16000 -ac:a 1 -vn -f wav -`, + }, + { + name: "[AUDIO] audio will be transcoded to PCMA/48000, video will be skipped", + source: "rtsp://example.com#audio=pcma/48000", + expect: `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:a pcm_alaw -ar:a 48000 -ac:a 1 -vn -f wav -`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + args := parseArgs(test.source) + require.Equal(t, test.expect, args.String()) + }) + } } func TestParseArgsHwVaapi(t *testing.T) { diff --git a/internal/ffmpeg/hardware/hardware.go b/internal/ffmpeg/hardware/hardware.go index ebbdc4fa..39ce3323 100644 --- a/internal/ffmpeg/hardware/hardware.go +++ b/internal/ffmpeg/hardware/hardware.go @@ -7,8 +7,6 @@ import ( "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/pkg/ffmpeg" - - "github.com/rs/zerolog/log" ) const ( @@ -152,7 +150,6 @@ var cache = map[string]string{} func run(bin string, args string) bool { err := exec.Command(bin, strings.Split(args, " ")...).Run() - log.Printf("%v %v", args, err) return err == nil } diff --git a/internal/ffmpeg/producer.go b/internal/ffmpeg/producer.go index 05df69e3..d132d253 100644 --- a/internal/ffmpeg/producer.go +++ b/internal/ffmpeg/producer.go @@ -13,7 +13,7 @@ import ( ) type Producer struct { - core.SuperProducer + core.Connection url string query url.Values ffmpeg core.Producer @@ -31,7 +31,8 @@ func NewProducer(url string) (core.Producer, error) { return nil, errors.New("ffmpeg: unsupported params: " + url[i:]) } - p.Type = "FFmpeg producer" + 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 @@ -81,7 +82,7 @@ func (p *Producer) Stop() error { func (p *Producer) MarshalJSON() ([]byte, error) { if p.ffmpeg == nil { - return json.Marshal(p.SuperProducer) + return json.Marshal(p.Connection) } return json.Marshal(p.ffmpeg) } diff --git a/internal/ffmpeg/version.go b/internal/ffmpeg/version.go index 976c92d0..717e08a4 100644 --- a/internal/ffmpeg/version.go +++ b/internal/ffmpeg/version.go @@ -6,7 +6,6 @@ import ( "sync" "github.com/AlexxIT/go2rtc/pkg/ffmpeg" - "github.com/rs/zerolog/log" ) var verMu sync.Mutex diff --git a/internal/gopro/gopro.go b/internal/gopro/gopro.go index 55d2641b..ee578049 100644 --- a/internal/gopro/gopro.go +++ b/internal/gopro/gopro.go @@ -10,15 +10,13 @@ import ( ) func Init() { - streams.HandleFunc("gopro", handleGoPro) + streams.HandleFunc("gopro", func(source string) (core.Producer, error) { + return gopro.Dial(source) + }) api.HandleFunc("api/gopro", apiGoPro) } -func handleGoPro(rawURL string) (core.Producer, error) { - return gopro.Dial(rawURL) -} - func apiGoPro(w http.ResponseWriter, r *http.Request) { var items []*api.Source diff --git a/internal/hass/api.go b/internal/hass/api.go index 4628cc11..e3de23b3 100644 --- a/internal/hass/api.go +++ b/internal/hass/api.go @@ -63,7 +63,7 @@ func apiStream(w http.ResponseWriter, r *http.Request) { return } - s, err = webrtc.ExchangeSDP(stream, string(offer), "WebRTC/Hass sync", r.UserAgent()) + s, err = webrtc.ExchangeSDP(stream, string(offer), "hass/webrtc", r.UserAgent()) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/internal/hass/hass.go b/internal/hass/hass.go index cd95ffe1..ea172b02 100644 --- a/internal/hass/hass.go +++ b/internal/hass/hass.go @@ -45,14 +45,9 @@ func Init() { return "", nil }) - streams.HandleFunc("hass", func(url string) (core.Producer, error) { + streams.HandleFunc("hass", func(source string) (core.Producer, error) { // support hass://supervisor?entity_id=camera.driveway_doorbell - client, err := hass.NewClient(url) - if err != nil { - return nil, err - } - - return client, nil + return hass.NewClient(source) }) // load static entries from Hass config diff --git a/internal/hls/hls.go b/internal/hls/hls.go index 5d3cd918..5c136450 100644 --- a/internal/hls/hls.go +++ b/internal/hls/hls.go @@ -12,7 +12,6 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/mp4" "github.com/AlexxIT/go2rtc/pkg/mpegts" - "github.com/AlexxIT/go2rtc/pkg/tcp" "github.com/rs/zerolog" ) @@ -63,15 +62,13 @@ func handlerStream(w http.ResponseWriter, r *http.Request) { medias := mp4.ParseQuery(r.URL.Query()) if medias != nil { c := mp4.NewConsumer(medias) - c.Type = "HLS/fMP4 consumer" - c.RemoteAddr = tcp.RemoteAddr(r) - c.UserAgent = r.UserAgent() + c.FormatName = "hls/fmp4" + c.WithRequest(r) cons = c } else { c := mpegts.NewConsumer() - c.Type = "HLS/TS consumer" - c.RemoteAddr = tcp.RemoteAddr(r) - c.UserAgent = r.UserAgent() + c.FormatName = "hls/mpegts" + c.WithRequest(r) cons = c } diff --git a/internal/hls/ws.go b/internal/hls/ws.go index ea1f5a3a..608f515f 100644 --- a/internal/hls/ws.go +++ b/internal/hls/ws.go @@ -8,7 +8,6 @@ import ( "github.com/AlexxIT/go2rtc/internal/api/ws" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/mp4" - "github.com/AlexxIT/go2rtc/pkg/tcp" ) func handlerWSHLS(tr *ws.Transport, msg *ws.Message) error { @@ -20,9 +19,8 @@ func handlerWSHLS(tr *ws.Transport, msg *ws.Message) error { codecs := msg.String() medias := mp4.ParseCodecs(codecs, true) cons := mp4.NewConsumer(medias) - cons.Type = "HLS/fMP4 consumer" - cons.RemoteAddr = tcp.RemoteAddr(tr.Request) - cons.UserAgent = tr.Request.UserAgent() + cons.FormatName = "hls/fmp4" + cons.WithRequest(tr.Request) log.Trace().Msgf("[hls] new ws consumer codecs=%s", codecs) diff --git a/internal/homekit/homekit.go b/internal/homekit/homekit.go index bfe3e971..743aeab9 100644 --- a/internal/homekit/homekit.go +++ b/internal/homekit/homekit.go @@ -133,12 +133,19 @@ func Init() { var log zerolog.Logger var servers map[string]*server -func streamHandler(url string) (core.Producer, error) { +func streamHandler(rawURL string) (core.Producer, error) { if srtp.Server == nil { return nil, errors.New("homekit: can't work without SRTP server") } - return homekit.Dial(url, srtp.Server) + rawURL, rawQuery, _ := strings.Cut(rawURL, "#") + client, err := homekit.Dial(rawURL, srtp.Server) + if client != nil && rawQuery != "" { + query := streams.ParseQuery(rawQuery) + client.Bitrate = parseBitrate(query.Get("bitrate")) + } + + return client, err } func hapPairSetup(w http.ResponseWriter, r *http.Request) { @@ -199,3 +206,24 @@ func findHomeKitURL(stream *streams.Stream) string { return "" } + +func parseBitrate(s string) int { + n := len(s) + if n == 0 { + return 0 + } + + var k int + switch n--; s[n] { + case 'K': + k = 1024 + s = s[:n] + case 'M': + k = 1024 * 1024 + s = s[:n] + default: + k = 1 + } + + return k * core.Atoi(s) +} diff --git a/internal/http/http.go b/internal/http/http.go index 8b1903f3..4b0560c1 100644 --- a/internal/http/http.go +++ b/internal/http/http.go @@ -11,9 +11,10 @@ import ( "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/hls" + "github.com/AlexxIT/go2rtc/pkg/image" "github.com/AlexxIT/go2rtc/pkg/magic" - "github.com/AlexxIT/go2rtc/pkg/mjpeg" - "github.com/AlexxIT/go2rtc/pkg/multipart" + "github.com/AlexxIT/go2rtc/pkg/mpjpeg" + "github.com/AlexxIT/go2rtc/pkg/pcm" "github.com/AlexxIT/go2rtc/pkg/tcp" ) @@ -45,6 +46,21 @@ func handleHTTP(rawURL string) (core.Producer, error) { } } + prod, err := do(req) + if err != nil { + return nil, err + } + + if info, ok := prod.(core.Info); ok { + info.SetProtocol("http") + info.SetRemoteAddr(req.URL.Host) // TODO: rewrite to net.Conn + info.SetURL(rawURL) + } + + return prod, nil +} + +func do(req *http.Request) (core.Producer, error) { res, err := tcp.Do(req) if err != nil { return nil, err @@ -66,14 +82,15 @@ func handleHTTP(rawURL string) (core.Producer, error) { } switch { - case ct == "image/jpeg": - return mjpeg.NewClient(res), nil - - case ct == "multipart/x-mixed-replace": - return multipart.Open(res.Body) - case ct == "application/vnd.apple.mpegurl" || ext == "m3u8": return hls.OpenURL(req.URL, res.Body) + case ct == "image/jpeg": + return image.Open(res) + case ct == "multipart/x-mixed-replace": + return mpjpeg.Open(res.Body) + //https://www.iana.org/assignments/media-types/audio/basic + case ct == "audio/basic": + return pcm.Open(res.Body) } return magic.Open(res.Body) diff --git a/internal/isapi/init.go b/internal/isapi/init.go index a37afa23..887a6748 100644 --- a/internal/isapi/init.go +++ b/internal/isapi/init.go @@ -7,16 +7,7 @@ import ( ) func Init() { - streams.HandleFunc("isapi", handle) -} - -func handle(url string) (core.Producer, error) { - conn, err := isapi.NewClient(url) - if err != nil { - return nil, err - } - if err = conn.Dial(); err != nil { - return nil, err - } - return conn, nil + streams.HandleFunc("isapi", func(source string) (core.Producer, error) { + return isapi.Dial(source) + }) } diff --git a/internal/ivideon/ivideon.go b/internal/ivideon/ivideon.go index 0ae5dc9f..03feb742 100644 --- a/internal/ivideon/ivideon.go +++ b/internal/ivideon/ivideon.go @@ -4,16 +4,10 @@ import ( "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/ivideon" - "strings" ) func Init() { - streams.HandleFunc("ivideon", func(url string) (core.Producer, error) { - id := strings.Replace(url[8:], "/", ":", 1) - prod := ivideon.NewClient(id) - if err := prod.Dial(); err != nil { - return nil, err - } - return prod, nil + streams.HandleFunc("ivideon", func(source string) (core.Producer, error) { + return ivideon.Dial(source) }) } diff --git a/internal/mjpeg/init.go b/internal/mjpeg/init.go index ea65e2d7..2bb7093a 100644 --- a/internal/mjpeg/init.go +++ b/internal/mjpeg/init.go @@ -10,15 +10,16 @@ import ( "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/api/ws" + "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/internal/ffmpeg" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/ascii" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/magic" "github.com/AlexxIT/go2rtc/pkg/mjpeg" - "github.com/AlexxIT/go2rtc/pkg/tcp" + "github.com/AlexxIT/go2rtc/pkg/mpjpeg" "github.com/AlexxIT/go2rtc/pkg/y4m" - "github.com/rs/zerolog/log" + "github.com/rs/zerolog" ) func Init() { @@ -28,8 +29,12 @@ func Init() { api.HandleFunc("api/stream.y4m", apiStreamY4M) ws.HandleFunc("mjpeg", handlerWS) + + log = app.GetLogger("mjpeg") } +var log zerolog.Logger + func handlerKeyframe(w http.ResponseWriter, r *http.Request) { src := r.URL.Query().Get("src") stream := streams.Get(src) @@ -39,8 +44,7 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) { } cons := magic.NewKeyframe() - cons.RemoteAddr = tcp.RemoteAddr(r) - cons.UserAgent = r.UserAgent() + cons.WithRequest(r) if err := stream.AddConsumer(cons); err != nil { log.Error().Err(err).Caller().Send() @@ -95,8 +99,7 @@ func outputMjpeg(w http.ResponseWriter, r *http.Request) { } cons := mjpeg.NewConsumer() - cons.RemoteAddr = tcp.RemoteAddr(r) - cons.UserAgent = r.UserAgent() + cons.WithRequest(r) if err := stream.AddConsumer(cons); err != nil { log.Error().Err(err).Msg("[api.mjpeg] add consumer") @@ -112,7 +115,7 @@ func outputMjpeg(w http.ResponseWriter, r *http.Request) { wr := mjpeg.NewWriter(w) _, _ = cons.WriteTo(wr) } else { - cons.Type = "ASCII passive consumer " + cons.FormatName = "ascii" query := r.URL.Query() wr := ascii.NewWriter(w, query.Get("color"), query.Get("back"), query.Get("text")) @@ -130,17 +133,16 @@ func inputMjpeg(w http.ResponseWriter, r *http.Request) { return } - res := &http.Response{Body: r.Body, Header: r.Header, Request: r} - res.Header.Set("Content-Type", "multipart/mixed;boundary=") + prod, _ := mpjpeg.Open(r.Body) + prod.WithRequest(r) - client := mjpeg.NewClient(res) - stream.AddProducer(client) + stream.AddProducer(prod) - if err := client.Start(); err != nil && err != io.EOF { + if err := prod.Start(); err != nil && err != io.EOF { log.Warn().Err(err).Caller().Send() } - stream.RemoveProducer(client) + stream.RemoveProducer(prod) } func handlerWS(tr *ws.Transport, _ *ws.Message) error { @@ -150,8 +152,7 @@ func handlerWS(tr *ws.Transport, _ *ws.Message) error { } cons := mjpeg.NewConsumer() - cons.RemoteAddr = tcp.RemoteAddr(tr.Request) - cons.UserAgent = tr.Request.UserAgent() + cons.WithRequest(tr.Request) if err := stream.AddConsumer(cons); err != nil { log.Debug().Err(err).Msg("[mjpeg] add consumer") @@ -178,8 +179,7 @@ func apiStreamY4M(w http.ResponseWriter, r *http.Request) { } cons := y4m.NewConsumer() - cons.RemoteAddr = tcp.RemoteAddr(r) - cons.UserAgent = r.UserAgent() + cons.WithRequest(r) if err := stream.AddConsumer(cons); err != nil { log.Error().Err(err).Caller().Send() diff --git a/internal/mp4/mp4.go b/internal/mp4/mp4.go index 78708a35..cca5220c 100644 --- a/internal/mp4/mp4.go +++ b/internal/mp4/mp4.go @@ -1,6 +1,7 @@ package mp4 import ( + "context" "net/http" "strconv" "strings" @@ -12,7 +13,6 @@ import ( "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/mp4" - "github.com/AlexxIT/go2rtc/pkg/tcp" "github.com/rs/zerolog" ) @@ -99,9 +99,9 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) { medias := mp4.ParseQuery(r.URL.Query()) cons := mp4.NewConsumer(medias) - cons.Type = "MP4/HTTP active consumer" - cons.RemoteAddr = tcp.RemoteAddr(r) - cons.UserAgent = r.UserAgent() + cons.FormatName = "mp4" + cons.Protocol = "http" + cons.WithRequest(r) if err := stream.AddConsumer(cons); err != nil { log.Error().Err(err).Caller().Send() @@ -127,20 +127,20 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) { header.Set("Content-Disposition", `attachment; filename="`+filename+`"`) } - var duration *time.Timer - if s := query.Get("duration"); s != "" { - if i, _ := strconv.Atoi(s); i > 0 { - duration = time.AfterFunc(time.Second*time.Duration(i), func() { - _ = cons.Stop() - }) - } + ctx := r.Context() // handle when the client drops the connection + + if i := core.Atoi(query.Get("duration")); i > 0 { + timeout := time.Second * time.Duration(i) + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() } + go func() { + <-ctx.Done() + _ = cons.Stop() + stream.RemoveConsumer(cons) + }() + _, _ = cons.WriteTo(w) - - stream.RemoveConsumer(cons) - - if duration != nil { - duration.Stop() - } } diff --git a/internal/mp4/ws.go b/internal/mp4/ws.go index 060ff5f6..c880fb58 100644 --- a/internal/mp4/ws.go +++ b/internal/mp4/ws.go @@ -8,7 +8,6 @@ import ( "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/mp4" - "github.com/AlexxIT/go2rtc/pkg/tcp" ) func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error { @@ -24,9 +23,8 @@ func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error { } cons := mp4.NewConsumer(medias) - cons.Type = "MSE/WebSocket active consumer" - cons.RemoteAddr = tcp.RemoteAddr(tr.Request) - cons.UserAgent = tr.Request.UserAgent() + cons.FormatName = "mse/fmp4" + cons.WithRequest(tr.Request) if err := stream.AddConsumer(cons); err != nil { log.Debug().Err(err).Msg("[mp4] add consumer") @@ -57,9 +55,7 @@ func handlerWSMP4(tr *ws.Transport, msg *ws.Message) error { } cons := mp4.NewKeyframe(medias) - cons.Type = "MP4/WebSocket active consumer" - cons.RemoteAddr = tcp.RemoteAddr(tr.Request) - cons.UserAgent = tr.Request.UserAgent() + cons.WithRequest(tr.Request) if err := stream.AddConsumer(cons); err != nil { log.Error().Err(err).Caller().Send() diff --git a/internal/mpegts/aac.go b/internal/mpegts/aac.go index 3008a658..3b1522fe 100644 --- a/internal/mpegts/aac.go +++ b/internal/mpegts/aac.go @@ -6,8 +6,6 @@ import ( "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/aac" - "github.com/AlexxIT/go2rtc/pkg/tcp" - "github.com/rs/zerolog/log" ) func apiStreamAAC(w http.ResponseWriter, r *http.Request) { @@ -19,11 +17,9 @@ func apiStreamAAC(w http.ResponseWriter, r *http.Request) { } cons := aac.NewConsumer() - cons.RemoteAddr = tcp.RemoteAddr(r) - cons.UserAgent = r.UserAgent() + cons.WithRequest(r) if err := stream.AddConsumer(cons); err != nil { - log.Error().Err(err).Caller().Send() http.Error(w, err.Error(), http.StatusInternalServerError) return } diff --git a/internal/mpegts/mpegts.go b/internal/mpegts/mpegts.go index 6f4f6ab2..d5f7752b 100644 --- a/internal/mpegts/mpegts.go +++ b/internal/mpegts/mpegts.go @@ -6,8 +6,6 @@ import ( "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/mpegts" - "github.com/AlexxIT/go2rtc/pkg/tcp" - "github.com/rs/zerolog/log" ) func Init() { @@ -32,11 +30,9 @@ func outputMpegTS(w http.ResponseWriter, r *http.Request) { } cons := mpegts.NewConsumer() - cons.RemoteAddr = tcp.RemoteAddr(r) - cons.UserAgent = r.UserAgent() + cons.WithRequest(r) if err := stream.AddConsumer(cons); err != nil { - log.Error().Err(err).Caller().Send() http.Error(w, err.Error(), http.StatusInternalServerError) return } diff --git a/internal/nest/init.go b/internal/nest/init.go index 1281ccdc..8289af73 100644 --- a/internal/nest/init.go +++ b/internal/nest/init.go @@ -2,6 +2,7 @@ package nest import ( "net/http" + "strings" "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/streams" @@ -10,19 +11,13 @@ import ( ) func Init() { - streams.HandleFunc("nest", streamNest) + streams.HandleFunc("nest", func(source string) (core.Producer, error) { + return nest.Dial(source) + }) api.HandleFunc("api/nest", apiNest) } -func streamNest(url string) (core.Producer, error) { - client, err := nest.NewClient(url) - if err != nil { - return nil, err - } - return client, nil -} - func apiNest(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() cliendID := query.Get("client_id") @@ -44,11 +39,12 @@ func apiNest(w http.ResponseWriter, r *http.Request) { var items []*api.Source - for name, deviceID := range devices { - query.Set("device_id", deviceID) + for _, device := range devices { + query.Set("device_id", device.DeviceID) + query.Set("protocols", strings.Join(device.Protocols, ",")) items = append(items, &api.Source{ - Name: name, URL: "nest:?" + query.Encode(), + Name: device.Name, URL: "nest:?" + query.Encode(), }) } diff --git a/internal/onvif/README.md b/internal/onvif/README.md new file mode 100644 index 00000000..ee922fbf --- /dev/null +++ b/internal/onvif/README.md @@ -0,0 +1,25 @@ +# ONVIF + +A regular camera has a single video source (`GetVideoSources`) and two profiles (`GetProfiles`). + +Go2rtc has one video source and one profile per stream. + +## Tested clients + +Go2rtc works as ONVIF server: + +- Happytime onvif client (windows) +- Home Assistant ONVIF integration (linux) +- Onvier (android) +- ONVIF Device Manager (windows) + +PS. Support only TCP transport for RTSP protocol. UDP and HTTP transports - unsupported yet. + +## Tested cameras + +Go2rtc works as ONVIF client: + +- Dahua IPC-K42 +- OpenIPC +- Reolink RLC-520A +- TP-Link Tapo TC60 diff --git a/internal/onvif/init.go b/internal/onvif/onvif.go similarity index 64% rename from internal/onvif/init.go rename to internal/onvif/onvif.go index 014c5e18..d332ca38 100644 --- a/internal/onvif/init.go +++ b/internal/onvif/onvif.go @@ -55,49 +55,65 @@ func onvifDeviceService(w http.ResponseWriter, r *http.Request) { return } - action := onvif.GetRequestAction(b) - if action == "" { + operation := onvif.GetRequestAction(b) + if operation == "" { http.Error(w, "malformed request body", http.StatusBadRequest) return } - log.Trace().Msgf("[onvif] %s", action) + log.Trace().Msgf("[onvif] server request %s %s:\n%s", r.Method, r.RequestURI, b) - var res string + switch operation { + case onvif.DeviceGetNetworkInterfaces, // important for Hass + onvif.DeviceGetSystemDateAndTime, // important for Hass + onvif.DeviceGetDiscoveryMode, + onvif.DeviceGetDNS, + onvif.DeviceGetHostname, + onvif.DeviceGetNetworkDefaultGateway, + onvif.DeviceGetNetworkProtocols, + onvif.DeviceGetNTP, + onvif.DeviceGetScopes: + b = onvif.StaticResponse(operation) - switch action { - case onvif.ActionGetCapabilities: + case onvif.DeviceGetCapabilities: // important for Hass: Media section - res = onvif.GetCapabilitiesResponse(r.Host) + b = onvif.GetCapabilitiesResponse(r.Host) - case onvif.ActionGetSystemDateAndTime: - // important for Hass - res = onvif.GetSystemDateAndTimeResponse() + case onvif.DeviceGetServices: + b = onvif.GetServicesResponse(r.Host) - case onvif.ActionGetNetworkInterfaces: - // important for Hass: none - res = onvif.GetNetworkInterfacesResponse() - - case onvif.ActionGetDeviceInformation: + case onvif.DeviceGetDeviceInformation: // important for Hass: SerialNumber (unique server ID) - res = onvif.GetDeviceInformationResponse("", "go2rtc", app.Version, r.Host) + b = onvif.GetDeviceInformationResponse("", "go2rtc", app.Version, r.Host) - case onvif.ActionGetServiceCapabilities: + case onvif.ServiceGetServiceCapabilities: // important for Hass - res = onvif.GetServiceCapabilitiesResponse() + // TODO: check path links to media + b = onvif.GetMediaServiceCapabilitiesResponse() - case onvif.ActionSystemReboot: - res = onvif.SystemRebootResponse() + case onvif.DeviceSystemReboot: + b = onvif.StaticResponse(operation) time.AfterFunc(time.Second, func() { os.Exit(0) }) - case onvif.ActionGetProfiles: - // important for Hass: H264 codec, width, height - res = onvif.GetProfilesResponse(streams.GetAll()) + case onvif.MediaGetVideoSources: + b = onvif.GetVideoSourcesResponse(streams.GetAll()) - case onvif.ActionGetStreamUri: + case onvif.MediaGetProfiles: + // important for Hass: H264 codec, width, height + b = onvif.GetProfilesResponse(streams.GetAll()) + + case onvif.MediaGetProfile: + token := onvif.FindTagValue(b, "ProfileToken") + b = onvif.GetProfileResponse(token) + + case onvif.MediaGetVideoSourceConfiguration: + token := onvif.FindTagValue(b, "ConfigurationToken") + b = onvif.GetVideoSourceConfigurationResponse(token) + + case onvif.MediaGetStreamUri: host, _, err := net.SplitHostPort(r.Host) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -105,16 +121,22 @@ func onvifDeviceService(w http.ResponseWriter, r *http.Request) { } uri := "rtsp://" + host + ":" + rtsp.Port + "/" + onvif.FindTagValue(b, "ProfileToken") - res = onvif.GetStreamUriResponse(uri) + b = onvif.GetStreamUriResponse(uri) + + case onvif.MediaGetSnapshotUri: + uri := "http://" + r.Host + "/api/frame.jpeg?src=" + onvif.FindTagValue(b, "ProfileToken") + b = onvif.GetSnapshotUriResponse(uri) default: - http.Error(w, "unsupported action", http.StatusBadRequest) + http.Error(w, "unsupported operation", http.StatusBadRequest) log.Debug().Msgf("[onvif] unsupported request:\n%s", b) return } + log.Trace().Msgf("[onvif] server response:\n%s", b) + w.Header().Set("Content-Type", "application/soap+xml; charset=utf-8") - if _, err = w.Write([]byte(res)); err != nil { + if _, err = w.Write(b); err != nil { log.Error().Err(err).Caller().Send() } } @@ -160,7 +182,7 @@ func apiOnvif(w http.ResponseWriter, r *http.Request) { } if l := log.Trace(); l.Enabled() { - b, _ := client.GetProfiles() + b, _ := client.MediaRequest(onvif.MediaGetProfiles) l.Msgf("[onvif] src=%s profiles:\n%s", src, b) } diff --git a/internal/ring/ring.go b/internal/ring/ring.go new file mode 100644 index 00000000..673ea480 --- /dev/null +++ b/internal/ring/ring.go @@ -0,0 +1,102 @@ +package ring + +import ( + "encoding/json" + "net/http" + "net/url" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/ring" +) + +func Init() { + streams.HandleFunc("ring", func(source string) (core.Producer, error) { + return ring.Dial(source) + }) + + api.HandleFunc("api/ring", apiRing) +} + +func apiRing(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + var ringAPI *ring.RingRestClient + var err error + + // Check auth method + if email := query.Get("email"); email != "" { + // Email/Password Flow + password := query.Get("password") + code := query.Get("code") + + ringAPI, err = ring.NewRingRestClient(ring.EmailAuth{ + Email: email, + Password: password, + }, nil) + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Try authentication (this will trigger 2FA if needed) + if _, err = ringAPI.GetAuth(code); err != nil { + if ringAPI.Using2FA { + // Return 2FA prompt + json.NewEncoder(w).Encode(map[string]interface{}{ + "needs_2fa": true, + "prompt": ringAPI.PromptFor2FA, + }) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } else { + // Refresh Token Flow + refreshToken := query.Get("refresh_token") + if refreshToken == "" { + http.Error(w, "either email/password or refresh_token is required", http.StatusBadRequest) + return + } + + ringAPI, err = ring.NewRingRestClient(ring.RefreshTokenAuth{ + RefreshToken: refreshToken, + }, nil) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + + // Fetch devices + devices, err := ringAPI.FetchRingDevices() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Create clean query with only required parameters + cleanQuery := url.Values{} + cleanQuery.Set("refresh_token", ringAPI.RefreshToken) + + var items []*api.Source + for _, camera := range devices.AllCameras { + cleanQuery.Set("device_id", camera.DeviceID) + + // Stream source + items = append(items, &api.Source{ + Name: camera.Description, + URL: "ring:?" + cleanQuery.Encode(), + }) + + // Snapshot source + items = append(items, &api.Source{ + Name: camera.Description + " Snapshot", + URL: "ring:?" + cleanQuery.Encode() + "&snapshot", + }) + } + + api.ResponseSources(w, items) +} diff --git a/internal/roborock/roborock.go b/internal/roborock/roborock.go index 27e29bb5..32a436d8 100644 --- a/internal/roborock/roborock.go +++ b/internal/roborock/roborock.go @@ -11,22 +11,13 @@ import ( ) func Init() { - streams.HandleFunc("roborock", handle) + streams.HandleFunc("roborock", func(source string) (core.Producer, error) { + return roborock.Dial(source) + }) api.HandleFunc("api/roborock", apiHandle) } -func handle(url string) (core.Producer, error) { - conn := roborock.NewClient(url) - if err := conn.Dial(); err != nil { - return nil, err - } - if err := conn.Connect(); err != nil { - return nil, err - } - return conn, nil -} - var Auth struct { UserData *roborock.UserInfo `json:"user_data"` BaseURL string `json:"base_url"` diff --git a/internal/rtmp/rtmp.go b/internal/rtmp/rtmp.go index 07aa5f71..b3d7f932 100644 --- a/internal/rtmp/rtmp.go +++ b/internal/rtmp/rtmp.go @@ -12,7 +12,6 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/flv" "github.com/AlexxIT/go2rtc/pkg/rtmp" - "github.com/AlexxIT/go2rtc/pkg/tcp" "github.com/rs/zerolog" ) @@ -128,17 +127,13 @@ func tcpHandle(netConn net.Conn) error { var log zerolog.Logger func streamsHandle(url string) (core.Producer, error) { - client, err := rtmp.DialPlay(url) - if err != nil { - return nil, err - } - return client, nil + return rtmp.DialPlay(url) } func streamsConsumerHandle(url string) (core.Consumer, func(), error) { cons := flv.NewConsumer() run := func() { - wr, err := rtmp.DialPublish(url) + wr, err := rtmp.DialPublish(url, cons) if err != nil { return } @@ -165,9 +160,7 @@ func outputFLV(w http.ResponseWriter, r *http.Request) { } cons := flv.NewConsumer() - cons.Type = "HTTP-FLV consumer" - cons.RemoteAddr = tcp.RemoteAddr(r) - cons.UserAgent = r.UserAgent() + cons.WithRequest(r) if err := stream.AddConsumer(cons); err != nil { log.Error().Err(err).Caller().Send() diff --git a/internal/rtsp/rtsp.go b/internal/rtsp/rtsp.go index 230bdece..9b18982f 100644 --- a/internal/rtsp/rtsp.go +++ b/internal/rtsp/rtsp.go @@ -1,6 +1,7 @@ package rtsp import ( + "errors" "io" "net" "net/url" @@ -147,6 +148,7 @@ func tcpHandler(conn *rtsp.Conn) { var closer func() trace := log.Trace().Enabled() + level := zerolog.WarnLevel conn.Listen(func(msg any) { if trace { @@ -184,12 +186,38 @@ func tcpHandler(conn *rtsp.Conn) { } } + if query.Get("backchannel") == "1" { + conn.Medias = append(conn.Medias, &core.Media{ + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + {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}, + }, + }) + } + if s := query.Get("pkt_size"); s != "" { conn.PacketSize = uint16(core.Atoi(s)) } + // param name like ffmpeg style https://ffmpeg.org/ffmpeg-protocols.html + if s := query.Get("log_level"); s != "" { + if lvl, err := zerolog.ParseLevel(s); err == nil { + level = lvl + } + } + + // will help to protect looping requests to same source + conn.Connection.Source = query.Get("source") + if err := stream.AddConsumer(conn); err != nil { - log.Warn().Err(err).Str("stream", name).Msg("[rtsp]") + log.WithLevel(level).Err(err).Str("stream", name).Msg("[rtsp]") return } @@ -226,8 +254,10 @@ func tcpHandler(conn *rtsp.Conn) { }) if err := conn.Accept(); err != nil { - if err != io.EOF { - log.Warn().Err(err).Caller().Send() + if errors.Is(err, rtsp.FailedAuth) { + log.Warn().Str("remote_addr", conn.Connection.RemoteAddr).Msg("[rtsp] failed authentication") + } else if err != io.EOF { + log.WithLevel(level).Err(err).Caller().Send() } if closer != nil { closer() diff --git a/internal/streams/README.md b/internal/streams/README.md new file mode 100644 index 00000000..6bbc268a --- /dev/null +++ b/internal/streams/README.md @@ -0,0 +1,8 @@ +## Testing notes + +```yaml +streams: + test1-basic: ffmpeg:virtual?video#video=h264 + test2-reconnect: ffmpeg:virtual?video&duration=10#video=h264 + test3-execkill: exec:./examples/rtsp_client/rtsp_client/rtsp_client {output} +``` diff --git a/internal/streams/add_consumer.go b/internal/streams/add_consumer.go index eb767691..7400ce6e 100644 --- a/internal/streams/add_consumer.go +++ b/internal/streams/add_consumer.go @@ -22,6 +22,12 @@ func (s *Stream) AddConsumer(cons core.Consumer) (err error) { producers: for prodN, prod := range s.producers { + // check for loop request, ex. `camera1: ffmpeg:camera1` + if info, ok := cons.(core.Info); ok && prod.url == info.GetSource() { + log.Trace().Msgf("[streams] skip cons=%d prod=%d", consN, prodN) + continue + } + if prodErrors[prodN] != nil { log.Trace().Msgf("[streams] skip cons=%d prod=%d", consN, prodN) continue @@ -129,7 +135,7 @@ func formatError(consMedias, prodMedias []*core.Media, prodErrors []error) error for _, media := range prodMedias { if media.Direction == core.DirectionRecvonly { for _, codec := range media.Codecs { - prod = appendString(prod, codec.PrintName()) + prod = appendString(prod, media.Kind+":"+codec.PrintName()) } } } @@ -137,7 +143,7 @@ func formatError(consMedias, prodMedias []*core.Media, prodErrors []error) error for _, media := range consMedias { if media.Direction == core.DirectionSendonly { for _, codec := range media.Codecs { - cons = appendString(cons, codec.PrintName()) + cons = appendString(cons, media.Kind+":"+codec.PrintName()) } } } diff --git a/internal/streams/api.go b/internal/streams/api.go new file mode 100644 index 00000000..d6042974 --- /dev/null +++ b/internal/streams/api.go @@ -0,0 +1,124 @@ +package streams + +import ( + "net/http" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/pkg/probe" +) + +func apiStreams(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + src := query.Get("src") + + // without source - return all streams list + if src == "" && r.Method != "POST" { + api.ResponseJSON(w, streams) + return + } + + // Not sure about all this API. Should be rewrited... + switch r.Method { + case "GET": + stream := Get(src) + if stream == nil { + http.Error(w, "", http.StatusNotFound) + return + } + + cons := probe.NewProbe(query) + if len(cons.Medias) != 0 { + cons.WithRequest(r) + if err := stream.AddConsumer(cons); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + api.ResponsePrettyJSON(w, stream) + + stream.RemoveConsumer(cons) + } else { + api.ResponsePrettyJSON(w, streams[src]) + } + + case "PUT": + name := query.Get("name") + if name == "" { + name = src + } + + if New(name, query["src"]...) == nil { + http.Error(w, "", http.StatusBadRequest) + return + } + + if err := app.PatchConfig(name, query["src"], "streams"); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } + + case "PATCH": + name := query.Get("name") + if name == "" { + http.Error(w, "", http.StatusBadRequest) + return + } + + // support {input} templates: https://github.com/AlexxIT/go2rtc#module-hass + if Patch(name, src) == nil { + http.Error(w, "", http.StatusBadRequest) + } + + case "POST": + // with dst - redirect source to dst + if dst := query.Get("dst"); dst != "" { + if stream := Get(dst); stream != nil { + if err := Validate(src); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } else if err = stream.Play(src); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } else { + api.ResponseJSON(w, stream) + } + } else if stream = Get(src); stream != nil { + if err := Validate(dst); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } else if err = stream.Publish(dst); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } else { + http.Error(w, "", http.StatusNotFound) + } + } else { + http.Error(w, "", http.StatusBadRequest) + } + + case "DELETE": + delete(streams, src) + + if err := app.PatchConfig(src, nil, "streams"); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } +} + +func apiStreamsDOT(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + + dot := make([]byte, 0, 1024) + dot = append(dot, "digraph {\n"...) + if query.Has("src") { + for _, name := range query["src"] { + if stream := streams[name]; stream != nil { + dot = AppendDOT(dot, stream) + } + } + } else { + for _, stream := range streams { + dot = AppendDOT(dot, stream) + } + } + dot = append(dot, '}') + + api.Response(w, dot, "text/vnd.graphviz") +} diff --git a/internal/streams/dot.go b/internal/streams/dot.go new file mode 100644 index 00000000..c54a733a --- /dev/null +++ b/internal/streams/dot.go @@ -0,0 +1,175 @@ +package streams + +import ( + "encoding/json" + "fmt" + "strings" +) + +func AppendDOT(dot []byte, stream *Stream) []byte { + for _, prod := range stream.producers { + if prod.conn == nil { + continue + } + c, err := marshalConn(prod.conn) + if err != nil { + continue + } + dot = c.appendDOT(dot, "producer") + } + for _, cons := range stream.consumers { + c, err := marshalConn(cons) + if err != nil { + continue + } + dot = c.appendDOT(dot, "consumer") + } + return dot +} + +func marshalConn(v any) (*conn, error) { + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + var c conn + if err = json.Unmarshal(b, &c); err != nil { + return nil, err + } + return &c, nil +} + +const bytesK = "KMGTP" + +func humanBytes(i int) string { + if i < 1000 { + return fmt.Sprintf("%d B", i) + } + + f := float64(i) / 1000 + var n uint8 + for f >= 1000 && n < 5 { + f /= 1000 + n++ + } + return fmt.Sprintf("%.2f %cB", f, bytesK[n]) +} + +type node struct { + ID uint32 `json:"id"` + Codec map[string]any `json:"codec"` + Parent uint32 `json:"parent"` + Childs []uint32 `json:"childs"` + Bytes int `json:"bytes"` + //Packets uint32 `json:"packets"` + //Drops uint32 `json:"drops"` +} + +var codecKeys = []string{"codec_name", "sample_rate", "channels", "profile", "level"} + +func (n *node) name() string { + if name, ok := n.Codec["codec_name"].(string); ok { + return name + } + return "unknown" +} + +func (n *node) codec() []byte { + b := make([]byte, 0, 128) + for _, k := range codecKeys { + if v := n.Codec[k]; v != nil { + b = fmt.Appendf(b, "%s=%v\n", k, v) + } + } + if l := len(b); l > 0 { + return b[:l-1] + } + return b +} + +func (n *node) appendDOT(dot []byte, group string) []byte { + dot = fmt.Appendf(dot, "%d [group=%s, label=%q, title=%q];\n", n.ID, group, n.name(), n.codec()) + //for _, sink := range n.Childs { + // dot = fmt.Appendf(dot, "%d -> %d;\n", n.ID, sink) + //} + return dot +} + +type conn struct { + ID uint32 `json:"id"` + FormatName string `json:"format_name"` + Protocol string `json:"protocol"` + RemoteAddr string `json:"remote_addr"` + Source string `json:"source"` + URL string `json:"url"` + UserAgent string `json:"user_agent"` + Receivers []node `json:"receivers"` + Senders []node `json:"senders"` + BytesRecv int `json:"bytes_recv"` + BytesSend int `json:"bytes_send"` +} + +func (c *conn) appendDOT(dot []byte, group string) []byte { + host := c.host() + dot = fmt.Appendf(dot, "%s [group=host];\n", host) + dot = fmt.Appendf(dot, "%d [group=%s, label=%q, title=%q];\n", c.ID, group, c.FormatName, c.label()) + if group == "producer" { + dot = fmt.Appendf(dot, "%s -> %d [label=%q];\n", host, c.ID, humanBytes(c.BytesRecv)) + } else { + dot = fmt.Appendf(dot, "%d -> %s [label=%q];\n", c.ID, host, humanBytes(c.BytesSend)) + } + + for _, recv := range c.Receivers { + dot = fmt.Appendf(dot, "%d -> %d [label=%q];\n", c.ID, recv.ID, humanBytes(recv.Bytes)) + dot = recv.appendDOT(dot, "node") + } + for _, send := range c.Senders { + dot = fmt.Appendf(dot, "%d -> %d [label=%q];\n", send.Parent, c.ID, humanBytes(send.Bytes)) + //dot = fmt.Appendf(dot, "%d -> %d [label=%q];\n", send.ID, c.ID, humanBytes(send.Bytes)) + //dot = send.appendDOT(dot, "node") + } + return dot +} + +func (c *conn) host() (s string) { + if c.Protocol == "pipe" { + return "127.0.0.1" + } + + if s = c.RemoteAddr; s == "" { + return "unknown" + } + + if i := strings.Index(s, "forwarded"); i > 0 { + s = s[i+10:] + } + + if s[0] == '[' { + if i := strings.Index(s, "]"); i > 0 { + return s[1:i] + } + } + + if i := strings.IndexAny(s, " ,:"); i > 0 { + return s[:i] + } + return +} + +func (c *conn) label() string { + var sb strings.Builder + sb.WriteString("format_name=" + c.FormatName) + if c.Protocol != "" { + sb.WriteString("\nprotocol=" + c.Protocol) + } + if c.Source != "" { + sb.WriteString("\nsource=" + c.Source) + } + if c.URL != "" { + sb.WriteString("\nurl=" + c.URL) + } + if c.UserAgent != "" { + sb.WriteString("\nuser_agent=" + c.UserAgent) + } + return sb.String() +} diff --git a/internal/streams/handlers.go b/internal/streams/handlers.go index 3009dd66..3240abb5 100644 --- a/internal/streams/handlers.go +++ b/internal/streams/handlers.go @@ -7,7 +7,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" ) -type Handler func(url string) (core.Producer, error) +type Handler func(source string) (core.Producer, error) var handlers = map[string]Handler{} diff --git a/internal/streams/play.go b/internal/streams/play.go index 7ada66e6..9bec7258 100644 --- a/internal/streams/play.go +++ b/internal/streams/play.go @@ -103,7 +103,7 @@ func (s *Stream) Play(source string) error { } func (s *Stream) AddInternalProducer(conn core.Producer) { - producer := &Producer{conn: conn, state: stateInternal} + producer := &Producer{conn: conn, state: stateInternal, url: "internal"} s.mu.Lock() s.producers = append(s.producers, producer) s.mu.Unlock() diff --git a/internal/streams/producer.go b/internal/streams/producer.go index 5a25dba5..09e2dcc5 100644 --- a/internal/streams/producer.go +++ b/internal/streams/producer.go @@ -132,11 +132,10 @@ func (p *Producer) AddTrack(media *core.Media, codec *core.Codec, track *core.Re } func (p *Producer) MarshalJSON() ([]byte, error) { - if p.conn != nil { - return json.Marshal(p.conn) + if conn := p.conn; conn != nil { + return json.Marshal(conn) } - - info := core.Info{URL: p.url} + info := map[string]string{"url": p.url} return json.Marshal(info) } @@ -207,7 +206,7 @@ func (p *Producer) reconnect(workerID, retry int) { for _, media := range conn.GetMedias() { switch media.Direction { case core.DirectionRecvonly: - for _, receiver := range p.receivers { + for i, receiver := range p.receivers { codec := media.MatchCodec(receiver.Codec) if codec == nil { continue @@ -219,6 +218,7 @@ func (p *Producer) reconnect(workerID, retry int) { } receiver.Replace(track) + p.receivers[i] = track break } @@ -234,6 +234,9 @@ func (p *Producer) reconnect(workerID, retry int) { } } + // stop previous connection after moving tracks (fix ghost exec/ffmpeg) + _ = p.conn.Stop() + // swap connections p.conn = conn go p.worker(conn, workerID) diff --git a/internal/streams/stream.go b/internal/streams/stream.go index 5dacf991..569e63ee 100644 --- a/internal/streams/stream.go +++ b/internal/streams/stream.go @@ -21,6 +21,12 @@ func NewStream(source any) *Stream { return &Stream{ producers: []*Producer{NewProducer(source)}, } + case []string: + s := new(Stream) + for _, str := range source { + s.producers = append(s.producers, NewProducer(str)) + } + return s case []any: s := new(Stream) for _, src := range source { @@ -70,7 +76,7 @@ func (s *Stream) RemoveConsumer(cons core.Consumer) { } func (s *Stream) AddProducer(prod core.Producer) { - producer := &Producer{conn: prod, state: stateExternal} + producer := &Producer{conn: prod, state: stateExternal, url: "external"} s.mu.Lock() s.producers = append(s.producers, producer) s.mu.Unlock() @@ -112,19 +118,12 @@ producers: } func (s *Stream) MarshalJSON() ([]byte, error) { - if !s.mu.TryLock() { - log.Warn().Msgf("[streams] json locked") - return json.Marshal(nil) - } - - var info struct { + var info = struct { Producers []*Producer `json:"producers"` Consumers []core.Consumer `json:"consumers"` + }{ + Producers: s.producers, + Consumers: s.consumers, } - info.Producers = s.producers - info.Consumers = s.consumers - - s.mu.Unlock() - return json.Marshal(info) } diff --git a/internal/streams/streams.go b/internal/streams/streams.go index c676fe09..b1038423 100644 --- a/internal/streams/streams.go +++ b/internal/streams/streams.go @@ -2,7 +2,6 @@ package streams import ( "errors" - "net/http" "net/url" "regexp" "sync" @@ -10,8 +9,6 @@ import ( "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/app" - "github.com/AlexxIT/go2rtc/pkg/probe" - "github.com/AlexxIT/go2rtc/pkg/tcp" "github.com/rs/zerolog" ) @@ -29,7 +26,8 @@ func Init() { streams[name] = NewStream(item) } - api.HandleFunc("api/streams", streamsHandler) + api.HandleFunc("api/streams", apiStreams) + api.HandleFunc("api/streams.dot", apiStreamsDOT) if cfg.Publish == nil { return @@ -58,13 +56,18 @@ func Validate(source string) error { return nil } -func New(name string, source string) *Stream { - if Validate(source) != nil { - return nil +func New(name string, sources ...string) *Stream { + for _, source := range sources { + if Validate(source) != nil { + return nil + } } - stream := NewStream(source) + stream := NewStream(sources) + + streamsMu.Lock() streams[name] = stream + streamsMu.Unlock() return stream } @@ -97,6 +100,10 @@ func Patch(name string, source string) *Stream { return nil } + if Validate(source) != nil { + return nil + } + // check an existing stream with this name if stream, ok := streams[name]; ok { stream.SetSource(source) @@ -104,7 +111,9 @@ func Patch(name string, source string) *Stream { } // create new stream with this name - return New(name, source) + stream := NewStream(source) + streams[name] = stream + return stream } func GetOrPatch(query url.Values) *Stream { @@ -145,101 +154,6 @@ func Delete(id string) { delete(streams, id) } -func streamsHandler(w http.ResponseWriter, r *http.Request) { - query := r.URL.Query() - src := query.Get("src") - - // without source - return all streams list - if src == "" && r.Method != "POST" { - api.ResponseJSON(w, streams) - return - } - - // Not sure about all this API. Should be rewrited... - switch r.Method { - case "GET": - stream := Get(src) - if stream == nil { - http.Error(w, "", http.StatusNotFound) - return - } - - cons := probe.NewProbe(query) - if len(cons.Medias) != 0 { - cons.RemoteAddr = tcp.RemoteAddr(r) - cons.UserAgent = r.UserAgent() - 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) - } - } -} - var log zerolog.Logger var streams = map[string]*Stream{} var streamsMu sync.Mutex diff --git a/internal/tapo/tapo.go b/internal/tapo/tapo.go index a54c8c5e..88eff5c4 100644 --- a/internal/tapo/tapo.go +++ b/internal/tapo/tapo.go @@ -8,11 +8,15 @@ import ( ) func Init() { - streams.HandleFunc("kasa", func(url string) (core.Producer, error) { - return kasa.Dial(url) + streams.HandleFunc("kasa", func(source string) (core.Producer, error) { + return kasa.Dial(source) }) - streams.HandleFunc("tapo", func(url string) (core.Producer, error) { - return tapo.Dial(url) + streams.HandleFunc("tapo", func(source string) (core.Producer, error) { + return tapo.Dial(source) + }) + + streams.HandleFunc("vigi", func(source string) (core.Producer, error) { + return tapo.Dial(source) }) } diff --git a/internal/v4l2/README.md b/internal/v4l2/README.md new file mode 100644 index 00000000..1c5dd390 --- /dev/null +++ b/internal/v4l2/README.md @@ -0,0 +1,39 @@ +# V4L2 + +What you should to know about [V4L2](https://en.wikipedia.org/wiki/Video4Linux): + +- V4L2 (Video for Linux API version 2) works only in Linux +- supports USB cameras and other similar devices +- one device can only be connected to one software simultaneously +- cameras support a fixed list of formats, resolutions and frame rates +- basic cameras supports only RAW (non-compressed) pixel formats +- regular cameras supports MJPEG format (series of JPEG frames) +- advances cameras support H264 format (MSE/MP4, WebRTC compatible) +- using MJPEG and H264 formats (if the camera supports them) won't cost you the CPU usage +- transcoding RAW format to MJPEG or H264 - will cost you a significant CPU usage +- H265 (HEVC) format is also supported (if the camera supports it) + +Tests show that the basic Keenetic router with MIPS processor can broadcast three MJPEG cameras in the following resolutions: 1600ั…1200 + 640ั…480 + 640ั…480. The USB bus bandwidth is no more enough for larger resolutions. CPU consumption is no more than 5%. + +Supported formats for your camera can be found here: **Go2rtc > WebUI > Add > V4L2**. + +## RAW format + +Example: + +```yaml +streams: + camera1: v4l2:device?video=/dev/video0&input_format=yuyv422&video_size=1280x720&framerate=10 +``` + +Go2rtc supports built-in transcoding of RAW to MJPEG format. This does not need to be additionally configured. + +``` +ffplay http://localhost:1984/api/stream.mjpeg?src=camera1 +``` + +**Important.** You don't have to transcode the RAW format to transmit it over the network. You can stream it in `y4m` format, which is perfectly supported by ffmpeg. It won't cost you a CPU usage. But will require high network bandwidth. + +``` +ffplay http://localhost:1984/api/stream.y4m?src=camera1 +``` diff --git a/internal/v4l2/v4l2.go b/internal/v4l2/v4l2.go new file mode 100644 index 00000000..9cef99a5 --- /dev/null +++ b/internal/v4l2/v4l2.go @@ -0,0 +1,7 @@ +//go:build !linux + +package v4l2 + +func Init() { + // not supported +} diff --git a/internal/v4l2/v4l2_linux.go b/internal/v4l2/v4l2_linux.go new file mode 100644 index 00000000..2cd60692 --- /dev/null +++ b/internal/v4l2/v4l2_linux.go @@ -0,0 +1,89 @@ +package v4l2 + +import ( + "encoding/binary" + "fmt" + "net/http" + "os" + "strings" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/v4l2" + "github.com/AlexxIT/go2rtc/pkg/v4l2/device" +) + +func Init() { + streams.HandleFunc("v4l2", func(source string) (core.Producer, error) { + return v4l2.Open(source) + }) + + api.HandleFunc("api/v4l2", apiV4L2) +} + +func apiV4L2(w http.ResponseWriter, r *http.Request) { + files, err := os.ReadDir("/dev") + if err != nil { + return + } + + var sources []*api.Source + + for _, file := range files { + if !strings.HasPrefix(file.Name(), core.KindVideo) { + continue + } + + path := "/dev/" + file.Name() + + dev, err := device.Open(path) + if err != nil { + continue + } + + formats, _ := dev.ListFormats() + for _, fourCC := range formats { + name, ffmpeg := findFormat(fourCC) + source := &api.Source{Name: name} + + sizes, _ := dev.ListSizes(fourCC) + for _, wh := range sizes { + if source.Info != "" { + source.Info += " " + } + + source.Info += fmt.Sprintf("%dx%d", wh[0], wh[1]) + + frameRates, _ := dev.ListFrameRates(fourCC, wh[0], wh[1]) + for _, fr := range frameRates { + source.Info += fmt.Sprintf("@%d", fr) + + if source.URL == "" && ffmpeg != "" { + source.URL = fmt.Sprintf( + "v4l2:device?video=%s&input_format=%s&video_size=%dx%d&framerate=%d", + path, ffmpeg, wh[0], wh[1], fr, + ) + } + } + } + + if source.Info != "" { + sources = append(sources, source) + } + } + + _ = dev.Close() + } + + api.ResponseSources(w, sources) +} + +func findFormat(fourCC uint32) (name, ffmpeg string) { + for _, format := range device.Formats { + if format.FourCC == fourCC { + return format.Name, format.FFmpeg + } + } + return string(binary.LittleEndian.AppendUint32(nil, fourCC)), "" +} diff --git a/internal/webrtc/candidates.go b/internal/webrtc/candidates.go index b92c4656..adbfb4a7 100644 --- a/internal/webrtc/candidates.go +++ b/internal/webrtc/candidates.go @@ -2,10 +2,10 @@ package webrtc import ( "net" - "slices" "strings" "github.com/AlexxIT/go2rtc/internal/api/ws" + "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/webrtc" pion "github.com/pion/webrtc/v3" ) @@ -75,14 +75,14 @@ func FilterCandidate(candidate *pion.ICECandidate) bool { // host candidate should be in the hosts list if candidate.Typ == pion.ICECandidateTypeHost && filters.Candidates != nil { - if !slices.Contains(filters.Candidates, candidate.Address) { + if !core.Contains(filters.Candidates, candidate.Address) { return false } } if filters.Networks != nil { networkType := NetworkType(candidate.Protocol.String(), candidate.Address) - if !slices.Contains(filters.Networks, networkType) { + if !core.Contains(filters.Networks, networkType) { return false } } diff --git a/internal/webrtc/client.go b/internal/webrtc/client.go index ae1a455b..d42c51dd 100644 --- a/internal/webrtc/client.go +++ b/internal/webrtc/client.go @@ -41,7 +41,7 @@ func streamsHandler(rawURL string) (core.Producer, error) { // https://aws.amazon.com/kinesis/video-streams/ // https://docs.aws.amazon.com/kinesisvideostreams-webrtc-dg/latest/devguide/what-is-kvswebrtc.html // https://github.com/orgs/awslabs/repositories?q=kinesis+webrtc - return kinesisClient(rawURL, query, "WebRTC/Kinesis") + return kinesisClient(rawURL, query, "webrtc/kinesis") } else if format == "openipc" { return openIPCClient(rawURL, query) } else { @@ -77,17 +77,23 @@ func go2rtcClient(url string) (core.Producer, error) { // 2. Create PeerConnection pc, err := PeerConnection(true) if err != nil { - log.Error().Err(err).Caller().Send() return nil, err } + defer func() { + if err != nil { + _ = pc.Close() + } + }() + // waiter will wait PC error or WS error or nil (connection OK) var connState core.Waiter var connMu sync.Mutex prod := webrtc.NewConn(pc) - prod.Desc = "WebRTC/WebSocket async" prod.Mode = core.ModeActiveProducer + prod.Protocol = "ws" + prod.URL = url prod.Listen(func(msg any) { switch msg := msg.(type) { case *pion.ICECandidate: @@ -132,7 +138,8 @@ func go2rtcClient(url string) (core.Producer, error) { } if msg.Type != "webrtc/answer" { - return nil, errors.New("wrong answer: " + msg.Type) + err = errors.New("wrong answer: " + msg.String()) + return nil, err } answer := msg.String() @@ -180,8 +187,9 @@ func whepClient(url string) (core.Producer, error) { } prod := webrtc.NewConn(pc) - prod.Desc = "WebRTC/WHEP sync" prod.Mode = core.ModeActiveProducer + prod.Protocol = "http" + prod.URL = url medias := []*core.Media{ {Kind: core.KindVideo, Direction: core.DirectionRecvonly}, diff --git a/internal/webrtc/kinesis.go b/internal/webrtc/kinesis.go index 7ef9d9bb..2ea1cf7a 100644 --- a/internal/webrtc/kinesis.go +++ b/internal/webrtc/kinesis.go @@ -34,7 +34,7 @@ func (k kinesisResponse) String() string { return fmt.Sprintf("type=%s, payload=%s", k.Type, k.Payload) } -func kinesisClient(rawURL string, query url.Values, desc string) (core.Producer, error) { +func kinesisClient(rawURL string, query url.Values, format string) (core.Producer, error) { // 1. Connect to signalign server conn, _, err := websocket.DefaultDialer.Dial(rawURL, nil) if err != nil { @@ -79,8 +79,10 @@ func kinesisClient(rawURL string, query url.Values, desc string) (core.Producer, } prod := webrtc.NewConn(pc) - prod.Desc = desc + prod.FormatName = format prod.Mode = core.ModeActiveProducer + prod.Protocol = "ws" + prod.URL = rawURL prod.Listen(func(msg any) { switch msg := msg.(type) { case *pion.ICECandidate: @@ -216,5 +218,5 @@ func wyzeClient(rawURL string) (core.Producer, error) { "ice_servers": []string{string(kvs.Servers)}, } - return kinesisClient(kvs.URL, query, "WebRTC/Wyze") + return kinesisClient(kvs.URL, query, "webrtc/wyze") } diff --git a/internal/webrtc/milestone.go b/internal/webrtc/milestone.go index b4e695c9..6a696cb0 100644 --- a/internal/webrtc/milestone.go +++ b/internal/webrtc/milestone.go @@ -193,8 +193,10 @@ func milestoneClient(rawURL string, query url.Values) (core.Producer, error) { } prod := webrtc.NewConn(pc) - prod.Desc = "WebRTC/Milestone" + prod.FormatName = "webrtc/milestone" prod.Mode = core.ModeActiveProducer + prod.Protocol = "http" + prod.URL = rawURL offer, err := mc.GetOffer() if err != nil { diff --git a/internal/webrtc/openipc.go b/internal/webrtc/openipc.go index 8055ea91..8a951d04 100644 --- a/internal/webrtc/openipc.go +++ b/internal/webrtc/openipc.go @@ -53,8 +53,10 @@ func openIPCClient(rawURL string, query url.Values) (core.Producer, error) { var connState core.Waiter prod := webrtc.NewConn(pc) - prod.Desc = "WebRTC/OpenIPC" + prod.FormatName = "webrtc/openipc" prod.Mode = core.ModeActiveProducer + prod.Protocol = "ws" + prod.URL = rawURL prod.Listen(func(msg any) { switch msg := msg.(type) { case *pion.ICECandidate: diff --git a/internal/webrtc/server.go b/internal/webrtc/server.go index fcb72b85..f7365afa 100644 --- a/internal/webrtc/server.go +++ b/internal/webrtc/server.go @@ -65,6 +65,7 @@ func outputWebRTC(w http.ResponseWriter, r *http.Request) { url := r.URL.Query().Get("src") stream := streams.Get(url) if stream == nil { + http.Error(w, api.StreamNotFound, http.StatusNotFound) return } @@ -100,11 +101,11 @@ func outputWebRTC(w http.ResponseWriter, r *http.Request) { switch mediaType { case "application/json": - desc = "WebRTC/JSON sync" + desc = "webrtc/json" case MimeSDP: - desc = "WebRTC/WHEP sync" + desc = "webrtc/whep" default: - desc = "WebRTC/HTTP sync" + desc = "webrtc/post" } answer, err := ExchangeSDP(stream, offer, desc, r.UserAgent()) @@ -168,8 +169,8 @@ func inputWebRTC(w http.ResponseWriter, r *http.Request) { // create new webrtc instance prod := webrtc.NewConn(pc) - prod.Desc = "WebRTC/WHIP sync" prod.Mode = core.ModePassiveProducer + prod.Protocol = "http" prod.UserAgent = r.UserAgent() if err = prod.SetOffer(string(offer)); err != nil { diff --git a/internal/webrtc/webrtc.go b/internal/webrtc/webrtc.go index cabd88b7..fe25c919 100644 --- a/internal/webrtc/webrtc.go +++ b/internal/webrtc/webrtc.go @@ -40,15 +40,17 @@ func Init() { AddCandidate(network, candidate) } + var err error + // create pionAPI with custom codecs list and custom network settings - serverAPI, err := webrtc.NewServerAPI(network, address, &filters) + serverAPI, err = webrtc.NewServerAPI(network, address, &filters) if err != nil { log.Error().Err(err).Caller().Send() return } // use same API for WebRTC server and client if no address - clientAPI := serverAPI + clientAPI = serverAPI if address != "" { log.Info().Str("addr", cfg.Mod.Listen).Msg("[webrtc] listen") @@ -81,11 +83,13 @@ func Init() { streams.HandleFunc("webrtc", streamsHandler) } +var serverAPI, clientAPI *pion.API + var log zerolog.Logger var PeerConnection func(active bool) (*pion.PeerConnection, error) -func asyncHandler(tr *ws.Transport, msg *ws.Message) error { +func asyncHandler(tr *ws.Transport, msg *ws.Message) (err error) { var stream *streams.Stream var mode core.Mode @@ -104,8 +108,30 @@ func asyncHandler(tr *ws.Transport, msg *ws.Message) error { return errors.New(api.StreamNotFound) } + var offer struct { + Type string `json:"type"` + SDP string `json:"sdp"` + ICEServers []pion.ICEServer `json:"ice_servers"` + } + + // V2 - json/object exchange, V1 - raw SDP exchange + apiV2 := msg.Type == "webrtc" + + if apiV2 { + if err = msg.Unmarshal(&offer); err != nil { + return err + } + } else { + offer.SDP = msg.String() + } + // create new PeerConnection instance - pc, err := PeerConnection(false) + var pc *pion.PeerConnection + if offer.ICEServers == nil { + pc, err = PeerConnection(false) + } else { + pc, err = serverAPI.NewPeerConnection(pion.Configuration{ICEServers: offer.ICEServers}) + } if err != nil { log.Error().Err(err).Caller().Send() return err @@ -117,8 +143,8 @@ func asyncHandler(tr *ws.Transport, msg *ws.Message) error { defer sendAnswer.Done(nil) conn := webrtc.NewConn(pc) - conn.Desc = "WebRTC/WebSocket async" conn.Mode = mode + conn.Protocol = "ws" conn.UserAgent = tr.Request.UserAgent() conn.Listen(func(msg any) { switch msg := msg.(type) { @@ -145,20 +171,10 @@ func asyncHandler(tr *ws.Transport, msg *ws.Message) error { } }) - // V2 - json/object exchange, V1 - raw SDP exchange - apiV2 := msg.Type == "webrtc" + log.Trace().Msgf("[webrtc] offer:\n%s", offer.SDP) // 1. SetOffer, so we can get remote client codecs - var offer string - if apiV2 { - offer = msg.GetString("sdp") - } else { - offer = msg.String() - } - - log.Trace().Msgf("[webrtc] offer:\n%s", offer) - - if err = conn.SetOffer(offer); err != nil { + if err = conn.SetOffer(offer.SDP); err != nil { log.Warn().Err(err).Caller().Send() return err } @@ -207,8 +223,9 @@ func ExchangeSDP(stream *streams.Stream, offer, desc, userAgent string) (answer // create new webrtc instance conn := webrtc.NewConn(pc) - conn.Desc = desc + conn.FormatName = desc conn.UserAgent = userAgent + conn.Protocol = "http" conn.Listen(func(msg any) { switch msg := msg.(type) { case pion.PeerConnectionState: diff --git a/internal/webrtc/webrtc_test.go b/internal/webrtc/webrtc_test.go new file mode 100644 index 00000000..e014c31c --- /dev/null +++ b/internal/webrtc/webrtc_test.go @@ -0,0 +1,38 @@ +package webrtc + +import ( + "encoding/json" + "testing" + + "github.com/AlexxIT/go2rtc/internal/api/ws" + pion "github.com/pion/webrtc/v3" + "github.com/stretchr/testify/require" +) + +func TestWebRTCAPIv1(t *testing.T) { + raw := `{"type":"webrtc/offer","value":"v=0\n..."}` + msg := new(ws.Message) + err := json.Unmarshal([]byte(raw), msg) + require.Nil(t, err) + + require.Equal(t, "v=0\n...", msg.String()) +} + +func TestWebRTCAPIv2(t *testing.T) { + raw := `{"type":"webrtc","value":{"type":"offer","sdp":"v=0\n...","ice_servers":[{"urls":["stun:stun.l.google.com:19302"]}]}}` + msg := new(ws.Message) + err := json.Unmarshal([]byte(raw), msg) + require.Nil(t, err) + + var offer struct { + Type string `json:"type"` + SDP string `json:"sdp"` + ICEServers []pion.ICEServer `json:"ice_servers"` + } + err = msg.Unmarshal(&offer) + require.Nil(t, err) + + require.Equal(t, "offer", offer.Type) + require.Equal(t, "v=0\n...", offer.SDP) + require.Equal(t, "stun:stun.l.google.com:19302", offer.ICEServers[0].URLs[0]) +} diff --git a/internal/webtorrent/init.go b/internal/webtorrent/init.go index 25b7ef9b..b1c25c76 100644 --- a/internal/webtorrent/init.go +++ b/internal/webtorrent/init.go @@ -47,7 +47,7 @@ func Init() { if stream == nil { return "", errors.New(api.StreamNotFound) } - return webrtc.ExchangeSDP(stream, offer, "WebRTC/WebTorrent sync", "") + return webrtc.ExchangeSDP(stream, offer, "webtorrent", "") }, } diff --git a/main.go b/main.go index 91bc9938..db3983cc 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/internal/bubble" "github.com/AlexxIT/go2rtc/internal/debug" + "github.com/AlexxIT/go2rtc/internal/doorbird" "github.com/AlexxIT/go2rtc/internal/dvrip" "github.com/AlexxIT/go2rtc/internal/echo" "github.com/AlexxIT/go2rtc/internal/exec" @@ -24,18 +25,22 @@ import ( "github.com/AlexxIT/go2rtc/internal/nest" "github.com/AlexxIT/go2rtc/internal/ngrok" "github.com/AlexxIT/go2rtc/internal/onvif" + "github.com/AlexxIT/go2rtc/internal/ring" "github.com/AlexxIT/go2rtc/internal/roborock" "github.com/AlexxIT/go2rtc/internal/rtmp" "github.com/AlexxIT/go2rtc/internal/rtsp" "github.com/AlexxIT/go2rtc/internal/srtp" "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/internal/tapo" + "github.com/AlexxIT/go2rtc/internal/v4l2" "github.com/AlexxIT/go2rtc/internal/webrtc" "github.com/AlexxIT/go2rtc/internal/webtorrent" "github.com/AlexxIT/go2rtc/pkg/shell" ) func main() { + app.Version = "1.9.8" + // 1. Core modules: app, api/ws, streams app.Init() // init config and logs @@ -76,10 +81,13 @@ func main() { mpegts.Init() // mpegts passive source roborock.Init() // roborock source homekit.Init() // homekit source + ring.Init() // ring source nest.Init() // nest source bubble.Init() // bubble source expr.Init() // expr source gopro.Init() // gopro source + doorbird.Init() // doorbird source + v4l2.Init() // v4l2 source // 6. Helper modules diff --git a/pkg/README.md b/pkg/README.md index c875dc35..b12f0a70 100644 --- a/pkg/README.md +++ b/pkg/README.md @@ -1,3 +1,85 @@ +# Notes + +go2rtc tries to name formats, protocols and codecs the same way they are named in FFmpeg. +Some formats and protocols go2rtc supports exclusively. They have no equivalent in FFmpeg. + +## Producers (input) + +- The initiator of the connection can be go2rtc - **Source protocols** +- The initiator of the connection can be an external program - **Ingress protocols** +- Codecs can be incoming - **Recevers codecs** +- Codecs can be outgoing (two way audio) - **Senders codecs** + +| Format | Source protocols | Ingress protocols | Recevers codecs | Senders codecs | Example | +|--------------|------------------|-------------------|------------------------------|--------------------|---------------| +| adts | http,tcp,pipe | http | aac | | `http:` | +| bubble | http | | h264,hevc,pcm_alaw | | `bubble:` | +| dvrip | tcp | | h264,hevc,pcm_alaw,pcm_mulaw | pcm_alaw | `dvrip:` | +| flv | http,tcp,pipe | http | h264,aac | | `http:` | +| gopro | http+udp | | TODO | | `gopro:` | +| hass/webrtc | ws+udp,tcp | | TODO | | `hass:` | +| hls/mpegts | http | | h264,h265,aac,opus | | `http:` | +| homekit | homekit+udp | | h264,eld* | | `homekit:` | +| isapi | http | | | pcm_alaw,pcm_mulaw | `isapi:` | +| ivideon | ws | | h264 | | `ivideon:` | +| kasa | http | | h264,pcm_mulaw | | `kasa:` | +| h264 | http,tcp,pipe | http | h264 | | `http:` | +| hevc | http,tcp,pipe | http | hevc | | `http:` | +| mjpeg | http,tcp,pipe | http | mjpeg | | `http:` | +| mpjpeg | http,tcp,pipe | http | mjpeg | | `http:` | +| mpegts | http,tcp,pipe | http | h264,hevc,aac,opus | | `http:` | +| nest/webrtc | http+udp | | TODO | | `nest:` | +| roborock | mqtt+udp | | h264,opus | opus | `roborock:` | +| rtmp | rtmp | rtmp | h264,aac | | `rtmp:` | +| rtsp | rtsp+tcp,ws | rtsp+tcp | h264,hevc,aac,pcm*,opus | pcm*,opus | `rtsp:` | +| stdin | pipe | | | pcm_alaw,pcm_mulaw | `stdin:` | +| tapo | http | | h264,pcma | pcm_alaw | `tapo:` | +| wav | http,tcp,pipe | http | pcm_alaw,pcm_mulaw | | `http:` | +| webrtc* | TODO | TODO | h264,pcm_alaw,pcm_mulaw,opus | pcm_alaw,pcm_mulaw | `webrtc:` | +| webtorrent | TODO | TODO | TODO | TODO | `webtorrent:` | +| yuv4mpegpipe | http,tcp,pipe | http | rawvideo | | `http:` | + +- **eld** - rare variant of aac codec +- **pcm** - pcm_alaw pcm_mulaw pcm_s16be pcm_s16le +- **webrtc** - webrtc/kinesis, webrtc/openipc, webrtc/milestone, webrtc/wyze, webrtc/whep + +## Consumers (output) + +| Format | Protocol | Send codecs | Recv codecs | Example | +|--------------|-------------|------------------------------|-------------------------|---------------------------------------| +| adts | http | aac | | `GET /api/stream.adts` | +| ascii | http | mjpeg | | `GET /api/stream.ascii` | +| flv | http | h264,aac | | `GET /api/stream.flv` | +| hls/mpegts | http | h264,hevc,aac | | `GET /api/stream.m3u8` | +| hls/fmp4 | http | h264,hevc,aac,pcm*,opus | | `GET /api/stream.m3u8?mp4` | +| homekit | homekit+udp | h264,opus | | Apple HomeKit app | +| mjpeg | ws | mjpeg | | `{"type":"mjpeg"}` -> `/api/ws` | +| mpjpeg | http | mjpeg | | `GET /api/stream.mjpeg` | +| mp4 | http | h264,hevc,aac,pcm*,opus | | `GET /api/stream.mp4` | +| mse/fmp4 | ws | h264,hevc,aac,pcm*,opus | | `{"type":"mse"}` -> `/api/ws` | +| mpegts | http | h264,hevc,aac | | `GET /api/stream.ts` | +| rtmp | rtmp | h264,aac | | `rtmp://localhost:1935/{stream_name}` | +| rtsp | rtsp+tcp | h264,hevc,aac,pcm*,opus | | `rtsp://localhost:8554/{stream_name}` | +| webrtc | TODO | h264,pcm_alaw,pcm_mulaw,opus | pcm_alaw,pcm_mulaw,opus | `{"type":"webrtc"}` -> `/api/ws` | +| yuv4mpegpipe | http | rawvideo | | `GET /api/stream.y4m` | + +- **pcm** - pcm_alaw pcm_mulaw pcm_s16be pcm_s16le + +## Snapshots + +| Format | Protocol | Send codecs | Example | +|--------|----------|-------------|-----------------------| +| jpeg | http | mjpeg | `GET /api/frame.jpeg` | +| mp4 | http | h264,hevc | `GET /api/frame.mp4` | + +## Developers + +File naming: + +- `pkg/{format}/producer.go` - producer for this format (also if support backchannel) +- `pkg/{format}/consumer.go` - consumer for this format +- `pkg/{format}/backchanel.go` - producer with only backchannel func + ## Useful links - https://www.wowza.com/blog/streaming-protocols diff --git a/pkg/aac/adts.go b/pkg/aac/adts.go index d5e7828e..94a13ad7 100644 --- a/pkg/aac/adts.go +++ b/pkg/aac/adts.go @@ -9,7 +9,6 @@ import ( ) func IsADTS(b []byte) bool { - _ = b[1] return len(b) > 7 && b[0] == 0xFF && b[1]&0xF6 == 0xF0 } diff --git a/pkg/aac/consumer.go b/pkg/aac/consumer.go index e785adc5..fc67d2a4 100644 --- a/pkg/aac/consumer.go +++ b/pkg/aac/consumer.go @@ -8,15 +8,12 @@ import ( ) type Consumer struct { - core.SuperConsumer + core.Connection wr *core.WriteBuffer } func NewConsumer() *Consumer { - cons := &Consumer{ - wr: core.NewWriteBuffer(nil), - } - cons.Medias = []*core.Media{ + medias := []*core.Media{ { Kind: core.KindAudio, Direction: core.DirectionSendonly, @@ -25,7 +22,16 @@ func NewConsumer() *Consumer { }, }, } - return cons + wr := core.NewWriteBuffer(nil) + return &Consumer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "adts", + Medias: medias, + Transport: wr, + }, + wr: wr, + } } func (c *Consumer) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { @@ -51,8 +57,3 @@ func (c *Consumer) AddTrack(media *core.Media, codec *core.Codec, track *core.Re func (c *Consumer) WriteTo(wr io.Writer) (int64, error) { return c.wr.WriteTo(wr) } - -func (c *Consumer) Stop() error { - _ = c.SuperConsumer.Close() - return c.wr.Close() -} diff --git a/pkg/aac/producer.go b/pkg/aac/producer.go index e9be71fd..efd2d175 100644 --- a/pkg/aac/producer.go +++ b/pkg/aac/producer.go @@ -10,9 +10,8 @@ import ( ) type Producer struct { - core.SuperProducer + core.Connection rd *bufio.Reader - cl io.Closer } func Open(r io.Reader) (*Producer, error) { @@ -23,18 +22,22 @@ func Open(r io.Reader) (*Producer, error) { return nil, err } - codec := ADTSToCodec(b) - - prod := &Producer{rd: rd, cl: r.(io.Closer)} - prod.Type = "ADTS producer" - prod.Medias = []*core.Media{ + medias := []*core.Media{ { Kind: core.KindAudio, Direction: core.DirectionRecvonly, - Codecs: []*core.Codec{codec}, + Codecs: []*core.Codec{ADTSToCodec(b)}, }, } - return prod, nil + return &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "adts", + Medias: medias, + Transport: r, + }, + rd: rd, + }, nil } func (c *Producer) Start() error { @@ -66,8 +69,3 @@ func (c *Producer) Start() error { c.Receivers[0].WriteRTP(pkt) } } - -func (c *Producer) Stop() error { - _ = c.SuperProducer.Close() - return c.cl.Close() -} diff --git a/pkg/aac/rtp.go b/pkg/aac/rtp.go index b5ae4a10..1faa2e27 100644 --- a/pkg/aac/rtp.go +++ b/pkg/aac/rtp.go @@ -22,6 +22,15 @@ func RTPDepay(handler core.HandlerFunc) core.HandlerFunc { //log.Printf("[RTP/AAC] units: %d, size: %4d, ts: %10d, %t", headersSize/2, len(packet.Payload), packet.Timestamp, packet.Marker) if len(packet.Payload) < int(2+headersSize) { + // In very rare cases noname cameras may send data not according to the standard + // https://github.com/AlexxIT/go2rtc/issues/1328 + if IsADTS(packet.Payload) { + clone := *packet + clone.Version = RTPPacketVersionAAC + clone.Timestamp = timestamp + clone.Payload = clone.Payload[ADTSHeaderSize:] + handler(&clone) + } return } diff --git a/pkg/aac/rtp_test.go b/pkg/aac/rtp_test.go new file mode 100644 index 00000000..c541b255 --- /dev/null +++ b/pkg/aac/rtp_test.go @@ -0,0 +1,33 @@ +package aac + +import ( + "encoding/hex" + "testing" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" + "github.com/stretchr/testify/require" +) + +func TestBuggy_RTSP_AAC(t *testing.T) { + // https: //github.com/AlexxIT/go2rtc/issues/1328 + payload, _ := hex.DecodeString("fff16080431ffc211ad4458aa309a1c0a8761a230502b7c74b2b5499252a010555e32e460128303c8ace4fd3260d654a424f7e7c65eddc96735fc6f1ac0edf94fdefa0e0bd6370da1c07b9c0e77a9d6e86b196a1ac7439dcafadcffcf6d89f60ac67f8884868e931383ad3e40cf5495470d1f606ef6f7624d285b951ebfa0e42641ab98f1371182b237d14f1bd16ad714fa2f1c6a7d23ebde7a0e34a2eca156a608a4caec49d9dca4b6fe2a09e9cdbf762c5b4148a3914abb7959c991228b0837b5988334b9fc18b8fac689b5ca1e4661573bbb8b253a86cae7ec14ace49969a9a76fd571ab6e650764cb59114d61dcedf07ac61b39e4ac66adebfd0d0ab45d518dd3c161049823f150864d977cf0855172ac8482e4b25fe911325d19617558c5405af74aff5492e4599bee53f2dbdf0503730af37078550f84c956b7ee89aae83c154fa2fa6e6792c5ddd5cd5cf6bb96bf055fee7f93bed59ffb039daee5ea7e5593cb194e9091e417c67d8f73026a6a6ae056e808f7c65c03d1b9197d3709ceb63bc7b979f7ba71df5e7c6395d99d6ea229000a6bc16fb4346d6b27d32f5d8d1200736d9366d59c0c9547210813b602473da9c46f9015bbb37594c1dd90cd6a36e96bd5d6a1445ab93c9e65505ec2c722bb4cc27a10600139a48c83594dde145253c386f6627d8c6e5102fe3828a590c709bc87f55b37e97d1ae72b017b09c6bb2c13299817bb45cc67318e10b6822075b97c6a03ec1c0") + packet := &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, + SequenceNumber: 36944, + Timestamp: 4217191328, + SSRC: 12892774, + }, + Payload: payload, + } + + var size int + + RTPDepay(func(packet *core.Packet) { + size = len(packet.Payload) + })(packet) + + require.Equal(t, len(payload), size+ADTSHeaderSize) +} diff --git a/pkg/bubble/client.go b/pkg/bubble/client.go index b8b77ae9..7a71d555 100644 --- a/pkg/bubble/client.go +++ b/pkg/bubble/client.go @@ -22,6 +22,7 @@ import ( "github.com/pion/rtp" ) +// Deprecated: should be rewritten to core.Connection type Client struct { core.Listener @@ -43,8 +44,12 @@ type Client struct { recv int } -func NewClient(url string) *Client { - return &Client{url: url} +func Dial(rawURL string) (*Client, error) { + client := &Client{url: rawURL} + if err := client.Dial(); err != nil { + return nil, err + } + return client, nil } const ( @@ -226,7 +231,7 @@ func (c *Client) Handle() error { Header: rtp.Header{ Timestamp: core.Now90000(), }, - Payload: annexb.EncodeToAVCC(b[6:], false), + Payload: annexb.EncodeToAVCC(b[6:]), } c.videoTrack.WriteRTP(pkt) } else { diff --git a/pkg/bubble/producer.go b/pkg/bubble/producer.go index a7aaa56e..9fa18f25 100644 --- a/pkg/bubble/producer.go +++ b/pkg/bubble/producer.go @@ -65,11 +65,16 @@ func (c *Client) Stop() error { } func (c *Client) MarshalJSON() ([]byte, error) { - info := &core.Info{ - Type: "Bubble active producer", - Medias: c.medias, - Recv: c.recv, - Receivers: c.receivers, + info := &core.Connection{ + ID: core.ID(c), + FormatName: "bubble", + Protocol: "http", + Medias: c.medias, + Recv: c.recv, + Receivers: c.receivers, + } + if c.conn != nil { + info.RemoteAddr = c.conn.RemoteAddr().String() } return json.Marshal(info) } diff --git a/pkg/core/codec.go b/pkg/core/codec.go index f38d7965..b138df28 100644 --- a/pkg/core/codec.go +++ b/pkg/core/codec.go @@ -2,8 +2,8 @@ package core import ( "encoding/base64" + "encoding/json" "fmt" - "strconv" "strings" "unicode" @@ -18,34 +18,76 @@ type Codec struct { PayloadType uint8 } -func (c *Codec) String() string { - s := fmt.Sprintf("%d %s", c.PayloadType, c.Name) +// MarshalJSON - return FFprobe compatible output +func (c *Codec) MarshalJSON() ([]byte, error) { + info := map[string]any{} + if name := FFmpegCodecName(c.Name); name != "" { + info["codec_name"] = name + info["codec_type"] = c.Kind() + } + if c.Name == CodecH264 { + profile, level := DecodeH264(c.FmtpLine) + if profile != "" { + info["profile"] = profile + info["level"] = level + } + } if c.ClockRate != 0 && c.ClockRate != 90000 { - s = fmt.Sprintf("%s/%d", s, c.ClockRate) + info["sample_rate"] = c.ClockRate } if c.Channels > 0 { - s = fmt.Sprintf("%s/%d", s, c.Channels) + info["channels"] = c.Channels } - return s + return json.Marshal(info) } -func (c *Codec) Text() string { - switch c.Name { +func FFmpegCodecName(name string) string { + switch name { case CodecH264: - if profile := DecodeH264(c.FmtpLine); profile != "" { - return "H.264 " + profile - } - return c.Name + return "h264" + case CodecH265: + return "hevc" + case CodecJPEG: + return "mjpeg" + case CodecRAW: + return "rawvideo" + case CodecPCMA: + return "pcm_alaw" + case CodecPCMU: + return "pcm_mulaw" + case CodecPCM: + return "pcm_s16be" + case CodecPCML: + return "pcm_s16le" + case CodecAAC: + return "aac" + case CodecOpus: + return "opus" + case CodecVP8: + return "vp8" + case CodecVP9: + return "vp9" + case CodecAV1: + return "av1" + case CodecELD: + return "aac/eld" + case CodecFLAC: + return "flac" + case CodecMP3: + return "mp3" } + return name +} - s := c.Name +func (c *Codec) String() (s string) { + s = c.Name if c.ClockRate != 0 && c.ClockRate != 90000 { - s += "/" + strconv.Itoa(int(c.ClockRate)) + s += fmt.Sprintf("/%d", c.ClockRate) } if c.Channels > 0 { - s += "/" + strconv.Itoa(int(c.Channels)) + s += fmt.Sprintf("/%d", c.Channels) } - return s + return } func (c *Codec) IsRTP() bool { @@ -115,7 +157,12 @@ func UnmarshalCodec(md *sdp.MediaDescription, payloadType string) *Codec { } } - if c.Name == "" { + switch c.Name { + case "PCM": + // https://www.reddit.com/r/Hikvision/comments/17elxex/comment/k642g2r/ + // check pkg/rtsp/rtsp_test.go TestHikvisionPCM + c.Name = CodecPCML + case "": // https://en.wikipedia.org/wiki/RTP_payload_formats switch payloadType { case "0": @@ -181,10 +228,9 @@ func UnmarshalCodec(md *sdp.MediaDescription, payloadType string) *Codec { return c } -func DecodeH264(fmtp string) string { +func DecodeH264(fmtp string) (profile string, level byte) { if ps := Between(fmtp, "sprop-parameter-sets=", ","); ps != "" { if sps, _ := base64.StdEncoding.DecodeString(ps); len(sps) >= 4 { - var profile string switch sps[1] { case 0x42: profile = "Baseline" @@ -198,8 +244,8 @@ func DecodeH264(fmtp string) string { profile = fmt.Sprintf("0x%02X", sps[1]) } - return fmt.Sprintf("%s %d.%d", profile, sps[3]/10, sps[3]%10) + level = sps[3] } } - return "" + return } diff --git a/pkg/core/connection.go b/pkg/core/connection.go new file mode 100644 index 00000000..cc0f43e4 --- /dev/null +++ b/pkg/core/connection.go @@ -0,0 +1,144 @@ +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) + GetSource() string +} + +// 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() +} + +func (c *Connection) GetSource() string { + return c.Source +} + +// Create like os.Create, init Consumer with existing Transport +func Create(w io.Writer) (*Connection, error) { + return &Connection{Transport: w}, nil +} + +// Open like os.Open, init Producer from existing Transport +func Open(r io.Reader) (*Connection, error) { + return &Connection{Transport: r}, nil +} + +// Dial like net.Dial, init Producer via Dialing +func Dial(rawURL string) (*Connection, error) { + return &Connection{}, nil +} diff --git a/pkg/core/core.go b/pkg/core/core.go index bc855ccc..9555ecfa 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -1,5 +1,7 @@ package core +import "encoding/json" + const ( DirectionRecvonly = "recvonly" DirectionSendonly = "sendonly" @@ -90,89 +92,6 @@ func (m Mode) String() string { return "unknown" } -type Info struct { - Type string `json:"type,omitempty"` - URL string `json:"url,omitempty"` - RemoteAddr string `json:"remote_addr,omitempty"` - UserAgent string `json:"user_agent,omitempty"` - SDP string `json:"sdp,omitempty"` - Medias []*Media `json:"medias,omitempty"` - Receivers []*Receiver `json:"receivers,omitempty"` - Senders []*Sender `json:"senders,omitempty"` - Recv int `json:"recv,omitempty"` - Send int `json:"send,omitempty"` -} - -const ( - UnsupportedCodec = "unsupported codec" - WrongMediaDirection = "wrong media direction" -) - -type SuperProducer struct { - Type string `json:"type,omitempty"` - URL string `json:"url,omitempty"` - SDP string `json:"sdp,omitempty"` - Medias []*Media `json:"medias,omitempty"` - Receivers []*Receiver `json:"receivers,omitempty"` - Recv int `json:"recv,omitempty"` -} - -func (s *SuperProducer) GetMedias() []*Media { - return s.Medias -} - -func (s *SuperProducer) GetTrack(media *Media, codec *Codec) (*Receiver, error) { - for _, receiver := range s.Receivers { - if receiver.Codec == codec { - return receiver, nil - } - } - receiver := NewReceiver(media, codec) - s.Receivers = append(s.Receivers, receiver) - return receiver, nil -} - -func (s *SuperProducer) Close() error { - for _, receiver := range s.Receivers { - receiver.Close() - } - return nil -} - -type SuperConsumer struct { - Type string `json:"type,omitempty"` - URL string `json:"url,omitempty"` - RemoteAddr string `json:"remote_addr,omitempty"` - UserAgent string `json:"user_agent,omitempty"` - SDP string `json:"sdp,omitempty"` - Medias []*Media `json:"medias,omitempty"` - Senders []*Sender `json:"senders,omitempty"` - Send int `json:"send,omitempty"` -} - -func (s *SuperConsumer) GetMedias() []*Media { - return s.Medias -} - -func (s *SuperConsumer) AddTrack(media *Media, codec *Codec, track *Receiver) error { - return nil -} - -//func (b *SuperConsumer) WriteTo(w io.Writer) (n int64, err error) { -// return 0, nil -//} - -func (s *SuperConsumer) Close() error { - for _, sender := range s.Senders { - sender.Close() - } - return nil -} - -func (s *SuperConsumer) Codecs() []*Codec { - codecs := make([]*Codec, len(s.Senders)) - for i, sender := range s.Senders { - codecs[i] = sender.Codec - } - return codecs +func (m Mode) MarshalJSON() ([]byte, error) { + return json.Marshal(m.String()) } diff --git a/pkg/core/core_test.go b/pkg/core/core_test.go new file mode 100644 index 00000000..4a05380a --- /dev/null +++ b/pkg/core/core_test.go @@ -0,0 +1,120 @@ +package core + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +type producer struct { + Medias []*Media + Receivers []*Receiver + + id byte +} + +func (p *producer) GetMedias() []*Media { + return p.Medias +} + +func (p *producer) GetTrack(_ *Media, codec *Codec) (*Receiver, error) { + for _, receiver := range p.Receivers { + if receiver.Codec == codec { + return receiver, nil + } + } + receiver := NewReceiver(nil, codec) + p.Receivers = append(p.Receivers, receiver) + return receiver, nil +} + +func (p *producer) Start() error { + pkt := &Packet{Payload: []byte{p.id}} + p.Receivers[0].Input(pkt) + return nil +} + +func (p *producer) Stop() error { + for _, receiver := range p.Receivers { + receiver.Close() + } + return nil +} + +type consumer struct { + Medias []*Media + Senders []*Sender + + cache chan byte +} + +func (c *consumer) GetMedias() []*Media { + return c.Medias +} + +func (c *consumer) AddTrack(_ *Media, _ *Codec, track *Receiver) error { + c.cache = make(chan byte, 1) + sender := NewSender(nil, track.Codec) + sender.Output = func(packet *Packet) { + c.cache <- packet.Payload[0] + } + sender.HandleRTP(track) + c.Senders = append(c.Senders, sender) + return nil +} + +func (c *consumer) Stop() error { + for _, sender := range c.Senders { + sender.Close() + } + return nil +} + +func (c *consumer) read() byte { + return <-c.cache +} + +func TestName(t *testing.T) { + GetProducer := func(b byte) Producer { + return &producer{ + Medias: []*Media{ + { + Kind: KindVideo, + Direction: DirectionRecvonly, + Codecs: []*Codec{ + {Name: CodecH264}, + }, + }, + }, + id: b, + } + } + + // stage1 + prod1 := GetProducer(1) + cons2 := &consumer{} + + media1 := prod1.GetMedias()[0] + track1, _ := prod1.GetTrack(media1, media1.Codecs[0]) + + _ = cons2.AddTrack(nil, nil, track1) + + _ = prod1.Start() + require.Equal(t, byte(1), cons2.read()) + + // stage2 + prod2 := GetProducer(2) + media2 := prod2.GetMedias()[0] + require.NotEqual(t, fmt.Sprintf("%p", media1), fmt.Sprintf("%p", media2)) + track2, _ := prod2.GetTrack(media2, media2.Codecs[0]) + track1.Replace(track2) + + _ = prod1.Stop() + + _ = prod2.Start() + require.Equal(t, byte(2), cons2.read()) + + // stage3 + _ = prod2.Stop() +} diff --git a/pkg/core/media.go b/pkg/core/media.go index fe58cfd6..a700bb62 100644 --- a/pkg/core/media.go +++ b/pkg/core/media.go @@ -22,7 +22,7 @@ type Media struct { func (m *Media) String() string { s := fmt.Sprintf("%s, %s", m.Kind, m.Direction) for _, codec := range m.Codecs { - name := codec.Text() + name := codec.String() if strings.Contains(s, name) { continue @@ -92,7 +92,7 @@ func (m *Media) Equal(media *Media) bool { func GetKind(name string) string { switch name { - case CodecH264, CodecH265, CodecVP8, CodecVP9, CodecAV1, CodecJPEG: + case CodecH264, CodecH265, CodecVP8, CodecVP9, CodecAV1, CodecJPEG, CodecRAW: return KindVideo case CodecPCMU, CodecPCMA, CodecAAC, CodecOpus, CodecG722, CodecMP3, CodecPCM, CodecPCML, CodecELD, CodecFLAC: return KindAudio @@ -124,9 +124,13 @@ func MarshalSDP(name string, medias []*Media) ([]byte, error) { codec := media.Codecs[0] - name := codec.Name - if name == CodecELD { + switch codec.Name { + case CodecELD: name = CodecAAC + case CodecPCML: + name = CodecPCM // beacuse we using pcm.LittleToBig for RTSP server + default: + name = codec.Name } md := &sdp.MediaDescription{ @@ -137,6 +141,10 @@ func MarshalSDP(name string, medias []*Media) ([]byte, error) { } md.WithCodec(codec.PayloadType, name, codec.ClockRate, codec.Channels, codec.FmtpLine) + if media.Direction != "" { + md.WithPropertyAttribute(media.Direction) + } + if media.ID != "" { md.WithValueAttribute("control", media.ID) } diff --git a/pkg/core/node.go b/pkg/core/node.go new file mode 100644 index 00000000..a9959c3d --- /dev/null +++ b/pkg/core/node.go @@ -0,0 +1,88 @@ +package core + +import ( + "sync" + + "github.com/pion/rtp" +) + +//type Packet struct { +// Payload []byte +// Timestamp uint32 // PTS if DTS == 0 else DTS +// Composition uint32 // CTS = PTS-DTS (for support B-frames) +// Sequence uint16 +//} + +type Packet = rtp.Packet + +// HandlerFunc - process input packets (just like http.HandlerFunc) +type HandlerFunc func(packet *Packet) + +// Filter - a decorator for any HandlerFunc +type Filter func(handler HandlerFunc) HandlerFunc + +// Node - Receiver or Sender or Filter (transform) +type Node struct { + Codec *Codec + Input HandlerFunc + Output HandlerFunc + + id uint32 + childs []*Node + parent *Node + + mu sync.Mutex +} + +func (n *Node) WithParent(parent *Node) *Node { + parent.AppendChild(n) + return n +} + +func (n *Node) AppendChild(child *Node) { + n.mu.Lock() + n.childs = append(n.childs, child) + n.mu.Unlock() + + child.parent = n +} + +func (n *Node) RemoveChild(child *Node) { + n.mu.Lock() + for i, ch := range n.childs { + if ch == child { + n.childs = append(n.childs[:i], n.childs[i+1:]...) + break + } + } + n.mu.Unlock() +} + +func (n *Node) Close() { + if parent := n.parent; parent != nil { + parent.RemoveChild(n) + + if len(parent.childs) == 0 { + parent.Close() + } + } else { + for _, childs := range n.childs { + childs.Close() + } + } +} + +func MoveNode(dst, src *Node) { + src.mu.Lock() + childs := src.childs + src.childs = nil + src.mu.Unlock() + + dst.mu.Lock() + dst.childs = childs + dst.mu.Unlock() + + for _, child := range childs { + child.parent = dst + } +} diff --git a/pkg/core/slices.go b/pkg/core/slices.go new file mode 100644 index 00000000..747d813f --- /dev/null +++ b/pkg/core/slices.go @@ -0,0 +1,43 @@ +package core + +// This code copied from go1.21 for backward support in go1.20. +// We need to support go1.20 for Windows 7 + +// Index returns the index of the first occurrence of v in s, +// or -1 if not present. +func Index[S ~[]E, E comparable](s S, v E) int { + for i := range s { + if v == s[i] { + return i + } + } + return -1 +} + +// Contains reports whether v is present in s. +func Contains[S ~[]E, E comparable](s S, v E) bool { + return Index(s, v) >= 0 +} + +type Ordered interface { + ~int | ~int8 | ~int16 | ~int32 | ~int64 | + ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr | + ~float32 | ~float64 | + ~string +} + +// Max returns the maximal value in x. It panics if x is empty. +// For floating-point E, Max propagates NaNs (any NaN value in x +// forces the output to be NaN). +func Max[S ~[]E, E Ordered](x S) E { + if len(x) < 1 { + panic("slices.Max: empty list") + } + m := x[0] + for i := 1; i < len(x); i++ { + if x[i] > m { + m = x[i] + } + } + return m +} diff --git a/pkg/core/track.go b/pkg/core/track.go index 72e47074..8bc65374 100644 --- a/pkg/core/track.go +++ b/pkg/core/track.go @@ -3,223 +3,212 @@ package core import ( "encoding/json" "errors" - "fmt" - "strconv" - "sync" "github.com/pion/rtp" ) -type Packet struct { - PayloadType uint8 - Sequence uint16 - Timestamp uint32 // PTS if DTS == 0 else DTS - Composition uint32 // CTS = PTS-DTS (for support B-frames) - Payload []byte -} - var ErrCantGetTrack = errors.New("can't get track") type Receiver struct { - Codec *Codec - Media *Media + Node - ID byte // Channel for RTSP, PayloadType for MPEG-TS + // Deprecated: should be removed + Media *Media `json:"-"` + // Deprecated: should be removed + ID byte `json:"-"` // Channel for RTSP, PayloadType for MPEG-TS - senders map[*Sender]chan *rtp.Packet - mu sync.RWMutex - bytes int + Bytes int `json:"bytes,omitempty"` + Packets int `json:"packets,omitempty"` } func NewReceiver(media *Media, codec *Codec) *Receiver { - Assert(codec != nil) - return &Receiver{Codec: codec, Media: media} -} - -// WriteRTP - fast and non blocking write to all readers buffers -func (t *Receiver) WriteRTP(packet *rtp.Packet) { - t.mu.Lock() - t.bytes += len(packet.Payload) - for sender, buffer := range t.senders { - select { - case buffer <- packet: - default: - sender.overflow++ + r := &Receiver{ + Node: Node{id: NewID(), Codec: codec}, + Media: media, + } + r.Input = func(packet *Packet) { + r.Bytes += len(packet.Payload) + r.Packets++ + for _, child := range r.childs { + child.Input(packet) } } - t.mu.Unlock() + return r } -func (t *Receiver) Senders() (senders []*Sender) { - t.mu.RLock() - for sender := range t.senders { - senders = append(senders, sender) +// Deprecated: should be removed +func (r *Receiver) WriteRTP(packet *rtp.Packet) { + r.Input(packet) +} + +// Deprecated: should be removed +func (r *Receiver) Senders() []*Sender { + if len(r.childs) > 0 { + return []*Sender{{}} + } else { + return nil } - t.mu.RUnlock() - return } -func (t *Receiver) Close() { - t.mu.Lock() - // close all sender channel buffers and erase senders list - for _, buffer := range t.senders { - close(buffer) - } - t.senders = nil - t.mu.Unlock() +// Deprecated: should be removed +func (r *Receiver) Replace(target *Receiver) { + MoveNode(&target.Node, &r.Node) } -func (t *Receiver) Replace(target *Receiver) { - // move this receiver senders to new receiver - t.mu.Lock() - senders := t.senders - t.mu.Unlock() - - target.mu.Lock() - target.senders = senders - target.mu.Unlock() -} - -func (t *Receiver) String() string { - s := t.Codec.String() + ", bytes=" + strconv.Itoa(t.bytes) - t.mu.RLock() - s += fmt.Sprintf(", senders=%d", len(t.senders)) - t.mu.RUnlock() - return s -} - -func (t *Receiver) MarshalJSON() ([]byte, error) { - return json.Marshal(t.String()) +func (r *Receiver) Close() { + r.Node.Close() } type Sender struct { - Codec *Codec - Media *Media + Node - Handler HandlerFunc + // Deprecated: + Media *Media `json:"-"` + // Deprecated: + Handler HandlerFunc `json:"-"` - receivers []*Receiver - mu sync.RWMutex - bytes int + Bytes int `json:"bytes,omitempty"` + Packets int `json:"packets,omitempty"` + Drops int `json:"drops,omitempty"` - overflow int + buf chan *Packet + done chan struct{} } func NewSender(media *Media, codec *Codec) *Sender { - return &Sender{Codec: codec, Media: media} -} + var bufSize uint16 -// HandlerFunc like http.HandlerFunc -type HandlerFunc func(packet *rtp.Packet) - -func (s *Sender) HandleRTP(track *Receiver) { - s.Bind(track) - go s.worker(track) -} - -func (s *Sender) Bind(track *Receiver) { - var bufferSize uint16 - - if GetKind(track.Codec.Name) == KindVideo { - if track.Codec.IsRTP() { + if GetKind(codec.Name) == KindVideo { + if codec.IsRTP() { // in my tests 40Mbit/s 4K-video can generate up to 1500 items // for the h264.RTPDepay => RTPPay queue - bufferSize = 5000 + bufSize = 4096 } else { - bufferSize = 50 + bufSize = 64 } } else { - bufferSize = 100 + bufSize = 128 } - buffer := make(chan *rtp.Packet, bufferSize) - - track.mu.Lock() - if track.senders == nil { - track.senders = map[*Sender]chan *rtp.Packet{} + buf := make(chan *Packet, bufSize) + s := &Sender{ + Node: Node{id: NewID(), Codec: codec}, + Media: media, + buf: buf, } - track.senders[s] = buffer - track.mu.Unlock() - - s.mu.Lock() - s.receivers = append(s.receivers, track) - s.mu.Unlock() + s.Input = func(packet *Packet) { + // writing to nil chan - OK, writing to closed chan - panic + s.mu.Lock() + select { + case s.buf <- packet: + s.Bytes += len(packet.Payload) + s.Packets++ + default: + s.Drops++ + } + s.mu.Unlock() + } + s.Output = func(packet *Packet) { + s.Handler(packet) + } + return s } -func (s *Sender) worker(track *Receiver) { - track.mu.Lock() - buffer := track.senders[s] - track.mu.Unlock() +// Deprecated: should be removed +func (s *Sender) HandleRTP(parent *Receiver) { + s.WithParent(parent) + s.Start() +} - // read packets from buffer channel until it will be closed - if buffer != nil { - for packet := range buffer { - s.bytes += len(packet.Payload) - s.Handler(packet) - } - } +// Deprecated: should be removed +func (s *Sender) Bind(parent *Receiver) { + s.WithParent(parent) +} - // remove current receiver from list - // it can only happen when receiver close buffer channel - s.mu.Lock() - for i, receiver := range s.receivers { - if receiver == track { - s.receivers = append(s.receivers[:i], s.receivers[i+1:]...) - break - } - } - s.mu.Unlock() +func (s *Sender) WithParent(parent *Receiver) *Sender { + s.Node.WithParent(&parent.Node) + return s } func (s *Sender) Start() { s.mu.Lock() - for _, track := range s.receivers { - go s.worker(track) + defer s.mu.Unlock() + + if s.buf == nil || s.done != nil { + return } - s.mu.Unlock() + s.done = make(chan struct{}) + + go func() { + for packet := range s.buf { + s.Output(packet) + } + close(s.done) + }() +} + +func (s *Sender) Wait() { + if done := s.done; s.done != nil { + <-done + } +} + +func (s *Sender) State() string { + if s.buf == nil { + return "closed" + } + if s.done == nil { + return "new" + } + return "connected" } func (s *Sender) Close() { - s.mu.Lock() - // remove this sender from all receivers list - for _, receiver := range s.receivers { - receiver.mu.Lock() - if buffer := receiver.senders[s]; buffer != nil { - // remove channel from list - delete(receiver.senders, s) - // close channel - close(buffer) - } - receiver.mu.Unlock() + // close buffer if exists + if buf := s.buf; buf != nil { + s.buf = nil + defer close(buf) } - s.receivers = nil - s.mu.Unlock() + + s.Node.Close() } -func (s *Sender) String() string { - info := s.Codec.String() + ", bytes=" + strconv.Itoa(s.bytes) - s.mu.RLock() - info += ", receivers=" + strconv.Itoa(len(s.receivers)) - s.mu.RUnlock() - if s.overflow > 0 { - info += ", overflow=" + strconv.Itoa(s.overflow) +func (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, } - return info + for _, child := range r.childs { + v.Childs = append(v.Childs, child.id) + } + return json.Marshal(v) } func (s *Sender) MarshalJSON() ([]byte, error) { - return json.Marshal(s.String()) -} - -// VA - helper, for extract video and audio receivers from list -func VA(receivers []*Receiver) (video, audio *Receiver) { - for _, receiver := range receivers { - switch GetKind(receiver.Codec.Name) { - case KindVideo: - video = receiver - case KindAudio: - audio = receiver - } + v := struct { + ID uint32 `json:"id"` + Codec *Codec `json:"codec"` + Parent uint32 `json:"parent,omitempty"` + Bytes int `json:"bytes,omitempty"` + Packets int `json:"packets,omitempty"` + Drops int `json:"drops,omitempty"` + }{ + ID: s.Node.id, + Codec: s.Node.Codec, + Bytes: s.Bytes, + Packets: s.Packets, + Drops: s.Drops, } - return + if s.parent != nil { + v.Parent = s.parent.id + } + return json.Marshal(v) } diff --git a/pkg/doorbird/backchannel.go b/pkg/doorbird/backchannel.go new file mode 100644 index 00000000..82379383 --- /dev/null +++ b/pkg/doorbird/backchannel.go @@ -0,0 +1,93 @@ +package doorbird + +import ( + "fmt" + "net" + "net/url" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +type Client struct { + core.Connection + conn net.Conn +} + +func Dial(rawURL string) (*Client, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + user := u.User.Username() + pass, _ := u.User.Password() + + if u.Port() == "" { + u.Host += ":80" + } + + conn, err := net.DialTimeout("tcp", u.Host, core.ConnDialTimeout) + if err != nil { + return nil, err + } + + s := fmt.Sprintf("POST /bha-api/audio-transmit.cgi?http-user=%s&http-password=%s HTTP/1.0\r\n", user, pass) + + "Content-Type: audio/basic\r\n" + + "Content-Length: 9999999\r\n" + + "Connection: Keep-Alive\r\n" + + "Cache-Control: no-cache\r\n" + + "\r\n" + + _ = conn.SetWriteDeadline(time.Now().Add(core.ConnDeadline)) + if _, err = conn.Write([]byte(s)); err != nil { + return nil, err + } + + medias := []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecPCMU, ClockRate: 8000}, + }, + }, + } + + return &Client{ + core.Connection{ + ID: core.NewID(), + FormatName: "doorbird", + Protocol: "http", + URL: rawURL, + Medias: medias, + Transport: conn, + }, + conn, + }, nil +} + +func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return nil, core.ErrCantGetTrack +} + +func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + sender := core.NewSender(media, track.Codec) + + sender.Handler = func(pkt *rtp.Packet) { + _ = c.conn.SetWriteDeadline(time.Now().Add(core.ConnDeadline)) + if n, err := c.conn.Write(pkt.Payload); err == nil { + c.Send += n + } + } + + sender.HandleRTP(track) + c.Senders = append(c.Senders, sender) + return nil +} + +func (c *Client) Start() (err error) { + _, err = c.conn.Read(nil) + return +} diff --git a/pkg/dvrip/consumer.go b/pkg/dvrip/backchannel.go similarity index 78% rename from pkg/dvrip/consumer.go rename to pkg/dvrip/backchannel.go index 7652c079..0424e965 100644 --- a/pkg/dvrip/consumer.go +++ b/pkg/dvrip/backchannel.go @@ -8,16 +8,16 @@ import ( "github.com/pion/rtp" ) -type Consumer struct { - core.SuperConsumer +type Backchannel struct { + core.Connection client *Client } -func (c *Consumer) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { +func (c *Backchannel) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { return nil, core.ErrCantGetTrack } -func (c *Consumer) Start() error { +func (c *Backchannel) Start() error { if err := c.client.conn.SetReadDeadline(time.Time{}); err != nil { return err } @@ -30,12 +30,7 @@ func (c *Consumer) Start() error { } } -func (c *Consumer) Stop() error { - _ = c.SuperConsumer.Close() - return c.client.Close() -} - -func (c *Consumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { +func (c *Backchannel) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { if err := c.client.Talk(); err != nil { return err } diff --git a/pkg/dvrip/dvrip.go b/pkg/dvrip/dvrip.go index 0f914640..c4980a80 100644 --- a/pkg/dvrip/dvrip.go +++ b/pkg/dvrip/dvrip.go @@ -8,17 +8,22 @@ func Dial(url string) (core.Producer, error) { return nil, err } + conn := core.Connection{ + ID: core.NewID(), + FormatName: "dvrip", + Protocol: "tcp", + RemoteAddr: client.conn.RemoteAddr().String(), + Transport: client.conn, + } + if client.stream != "" { - prod := &Producer{client: client} - prod.Type = "DVRIP active producer" + prod := &Producer{Connection: conn, client: client} if err := prod.probe(); err != nil { return nil, err } return prod, nil } else { - cons := &Consumer{client: client} - cons.Type = "DVRIP active consumer" - cons.Medias = []*core.Media{ + conn.Medias = []*core.Media{ { Kind: core.KindAudio, Direction: core.DirectionSendonly, @@ -29,6 +34,6 @@ func Dial(url string) (core.Producer, error) { }, }, } - return cons, nil + return &Backchannel{Connection: conn, client: client}, nil } } diff --git a/pkg/dvrip/producer.go b/pkg/dvrip/producer.go index 412dd0a3..4f49da1e 100644 --- a/pkg/dvrip/producer.go +++ b/pkg/dvrip/producer.go @@ -15,7 +15,7 @@ import ( ) type Producer struct { - core.SuperProducer + core.Connection client *Client @@ -53,7 +53,7 @@ func (c *Producer) Start() error { packet := &rtp.Packet{ Header: rtp.Header{Timestamp: c.videoTS}, - Payload: annexb.EncodeToAVCC(payload, false), + Payload: annexb.EncodeToAVCC(payload), } //log.Printf("[AVC] %v, len: %d, ts: %10d", h265.Types(payload), len(payload), packet.Timestamp) @@ -92,10 +92,6 @@ func (c *Producer) Start() error { } } -func (c *Producer) Stop() error { - return c.client.Close() -} - func (c *Producer) probe() error { if err := c.client.Play(); err != nil { return err @@ -150,7 +146,7 @@ func (c *Producer) probe() error { c.videoTS = binary.LittleEndian.Uint32(ts) c.videoDT = 90000 / uint32(fps) - payload := annexb.EncodeToAVCC(b[16:], false) + payload := annexb.EncodeToAVCC(b[16:]) c.addVideoTrack(b[4], payload) case 0xFA: // audio diff --git a/pkg/flv/consumer.go b/pkg/flv/consumer.go index 59e65d9c..fe966bfc 100644 --- a/pkg/flv/consumer.go +++ b/pkg/flv/consumer.go @@ -10,17 +10,13 @@ import ( ) type Consumer struct { - core.SuperConsumer + core.Connection wr *core.WriteBuffer muxer *Muxer } func NewConsumer() *Consumer { - c := &Consumer{ - wr: core.NewWriteBuffer(nil), - muxer: &Muxer{}, - } - c.Medias = []*core.Media{ + medias := []*core.Media{ { Kind: core.KindVideo, Direction: core.DirectionSendonly, @@ -36,7 +32,17 @@ func NewConsumer() *Consumer { }, }, } - return c + wr := core.NewWriteBuffer(nil) + return &Consumer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "flv", + Medias: medias, + Transport: wr, + }, + wr: wr, + muxer: &Muxer{}, + } } func (c *Consumer) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { @@ -86,8 +92,3 @@ func (c *Consumer) WriteTo(wr io.Writer) (int64, error) { } return c.wr.WriteTo(wr) } - -func (c *Consumer) Stop() error { - _ = c.SuperConsumer.Close() - return c.wr.Close() -} diff --git a/pkg/flv/producer.go b/pkg/flv/producer.go index 3972e666..33762d20 100644 --- a/pkg/flv/producer.go +++ b/pkg/flv/producer.go @@ -15,18 +15,24 @@ import ( ) type Producer struct { - core.SuperProducer + core.Connection rd *core.ReadBuffer video, audio *core.Receiver } func Open(rd io.Reader) (*Producer, error) { - prod := &Producer{rd: core.NewReadBuffer(rd)} + prod := &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "flv", + Transport: rd, + }, + rd: core.NewReadBuffer(rd), + } if err := prod.probe(); err != nil { return nil, err } - prod.Type = "FLV producer" return prod, nil } @@ -57,7 +63,7 @@ const ( ) func (c *Producer) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { - receiver, _ := c.SuperProducer.GetTrack(media, codec) + receiver, _ := c.Connection.GetTrack(media, codec) if media.Kind == core.KindVideo { c.video = receiver } else { @@ -117,11 +123,6 @@ func (c *Producer) Start() error { } } -func (c *Producer) Stop() error { - _ = c.SuperProducer.Close() - return c.rd.Close() -} - func (c *Producer) probe() error { if err := c.readHeader(); err != nil { return err @@ -139,23 +140,29 @@ func (c *Producer) probe() error { // 1. Empty video/audio flag // 2. MedaData without stereo key for AAC // 3. Audio header after Video keyframe tag - waitType := []byte{TagData} - timeout := time.Now().Add(core.ProbeTimeout) - for len(waitType) != 0 && time.Now().Before(timeout) { + // OpenIPC camera (on old firmwares) sends: + // 1. Empty video/audio flag + // 2. No MetaData packet + // 3. Sends a video packet in more than 3 seconds + waitVideo := true + waitAudio := true + timeout := time.Now().Add(time.Second * 5) + + for (waitVideo || waitAudio) && time.Now().Before(timeout) { pkt, err := c.readPacket() if err != nil { return err } - if i := bytes.IndexByte(waitType, pkt.PayloadType); i < 0 { - continue - } else { - waitType = append(waitType[:i], waitType[i+1:]...) - } + //log.Printf("%d %0.20s", pkt.PayloadType, pkt.Payload) switch pkt.PayloadType { case TagAudio: + if !waitAudio { + continue + } + _ = pkt.Payload[1] // bounds codecID := pkt.Payload[0] >> 4 // SoundFormat @@ -178,8 +185,13 @@ func (c *Producer) probe() error { Codecs: []*core.Codec{codec}, } c.Medias = append(c.Medias, media) + waitAudio = false case TagVideo: + if !waitVideo { + continue + } + var codec *core.Codec if isExHeader(pkt.Payload) { @@ -212,19 +224,20 @@ func (c *Producer) probe() error { Codecs: []*core.Codec{codec}, } c.Medias = append(c.Medias, media) + waitVideo = false case TagData: if !bytes.Contains(pkt.Payload, []byte("onMetaData")) { - waitType = append(waitType, TagData) + continue } // Dahua cameras doesn't send videocodecid - if bytes.Contains(pkt.Payload, []byte("videocodecid")) || - bytes.Contains(pkt.Payload, []byte("width")) || - bytes.Contains(pkt.Payload, []byte("framerate")) { - waitType = append(waitType, TagVideo) + if !bytes.Contains(pkt.Payload, []byte("videocodecid")) && + !bytes.Contains(pkt.Payload, []byte("width")) && + !bytes.Contains(pkt.Payload, []byte("framerate")) { + waitVideo = false } - if bytes.Contains(pkt.Payload, []byte("audiocodecid")) { - waitType = append(waitType, TagAudio) + if !bytes.Contains(pkt.Payload, []byte("audiocodecid")) { + waitAudio = false } } } diff --git a/pkg/gopro/gopro.go b/pkg/gopro/producer.go similarity index 90% rename from pkg/gopro/gopro.go rename to pkg/gopro/producer.go index 2d6a098b..1873159f 100644 --- a/pkg/gopro/gopro.go +++ b/pkg/gopro/producer.go @@ -8,11 +8,10 @@ import ( "net/url" "time" - "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/mpegts" ) -func Dial(rawURL string) (core.Producer, error) { +func Dial(rawURL string) (*mpegts.Producer, error) { u, err := url.Parse(rawURL) if err != nil { return nil, err @@ -32,7 +31,15 @@ func Dial(rawURL string) (core.Producer, error) { return nil, err } - return mpegts.Open(r) + prod, err := mpegts.Open(r) + if err != nil { + return nil, err + } + + prod.FormatName = "gopro" + prod.RemoteAddr = u.Host + + return prod, nil } type listener struct { diff --git a/pkg/h264/annexb/annexb.go b/pkg/h264/annexb/annexb.go index 13f06622..26614a82 100644 --- a/pkg/h264/annexb/annexb.go +++ b/pkg/h264/annexb/annexb.go @@ -11,64 +11,60 @@ const startAUD = StartCode + "\x09\xF0" const startAUDstart = startAUD + StartCode // EncodeToAVCC -// will change original slice data! -// safeAppend should be used if original slice has useful data after end (part of other slice) // // FFmpeg MPEG-TS: 00000001 AUD 00000001 SPS 00000001 PPS 000001 IFrame // FFmpeg H264: 00000001 SPS 00000001 PPS 000001 IFrame 00000001 PFrame -func EncodeToAVCC(b []byte, safeAppend bool) []byte { - const minSize = len(StartCode) + 1 - - // 1. Check frist "start code" - if len(b) < len(startAUDstart) || string(b[:len(StartCode)]) != StartCode { - return nil - } - - // 2. Skip Access unit delimiter (AUD) from FFmpeg - if string(b[:len(startAUDstart)]) == startAUDstart { - b = b[6:] - } - +// Reolink: 000001 AUD 000001 VPS 00000001 SPS 00000001 PPS 00000001 IDR 00000001 IDR +func EncodeToAVCC(annexb []byte) (avc []byte) { var start int - for i, n := minSize, len(b)-minSize; i < n; { - // 3. Check "start code" (first 2 bytes) - if b[i] != 0 || b[i+1] != 0 { - i++ - continue - } + avc = make([]byte, 0, len(annexb)+4) // init memory with little overhead - // 4. Check "start code" (3 bytes size or 4 bytes size) - if b[i+2] == 1 { - if safeAppend { - // protect original slice from "damage" - b = bytes.Clone(b) - safeAppend = false + for i := 0; ; i++ { + var offset int + + if i+3 < len(annexb) { + // search next separator + if annexb[i] == 0 && annexb[i+1] == 0 { + if annexb[i+2] == 1 { + offset = 3 // 00 00 01 + } else if annexb[i+2] == 0 && annexb[i+3] == 1 { + offset = 4 // 00 00 00 01 + } else { + continue + } + } else { + continue } - - // convert start code from 3 bytes to 4 bytes - b = append(b, 0) - copy(b[i+1:], b[i:]) - n++ - } else if b[i+2] != 0 || b[i+3] != 1 { - i++ - continue + } else { + i = len(annexb) // move i to data end } - // 5. Set size for previous AU - size := uint32(i - start - len(StartCode)) - binary.BigEndian.PutUint32(b[start:], size) + if start != 0 { + size := uint32(i - start) + avc = binary.BigEndian.AppendUint32(avc, size) + avc = append(avc, annexb[start:i]...) + } - start = i + // sometimes FFmpeg put separator at the end + if i += offset; i == len(annexb) { + break + } - i += minSize + if isAUD(annexb[i]) { + start = 0 // skip this NALU + } else { + start = i // save this position + } } - // 6. Set size for last AU - size := uint32(len(b) - start - len(StartCode)) - binary.BigEndian.PutUint32(b[start:], size) + return +} - return b +func isAUD(b byte) bool { + const h264 = 9 + const h265 = 35 << 1 + return b&0b0001_1111 == h264 || b&0b0111_1110 == h265 } func DecodeAVCC(b []byte, safeClone bool) []byte { diff --git a/pkg/h264/annexb/annexb_test.go b/pkg/h264/annexb/annexb_test.go new file mode 100644 index 00000000..cbc382fe --- /dev/null +++ b/pkg/h264/annexb/annexb_test.go @@ -0,0 +1,97 @@ +package annexb + +import ( + "bytes" + "encoding/binary" + "encoding/hex" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func decode(s string) []byte { + b, _ := hex.DecodeString(strings.ReplaceAll(s, " ", "")) + return b +} + +func naluTypes(avcc []byte) (types []byte) { + for { + types = append(types, avcc[4]) + + size := 4 + binary.BigEndian.Uint32(avcc) + if size < uint32(len(avcc)) { + avcc = avcc[size:] + } else { + break + } + } + return +} + +func TestFFmpegH264(t *testing.T) { + // ffmpeg -re -i bbb.mp4 -c copy -f h264 - + s := "000000016764001fac2484014016ec0440000003004000000c23c60c92 0000000168ee32c8b0 00000165888080033ffef5f8454f32cb1bb4203f854dd69bc2ca91b2bce1fb3527440000030000030000030000030050999841d1afd324aea000000300000f600011c0001b40004e40011f0003b80010800059000238000be0005e000220001100000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300004041 00000001" + b := EncodeToAVCC(decode(s)) + require.True(t, bytes.HasSuffix(b, []byte{0x40, 0x41})) + n := naluTypes(b) + require.Equal(t, []byte{0x67, 0x68, 0x65}, n) +} + +func TestFFmpegMPEGTSH264(t *testing.T) { + // ffmpeg -re -i bbb.mp4 -c copy -f mpegts - + s := "00000001 09f0 000000016764001fac2484014016ec0440000003004000000c23c60c92 0000000168ee32c8b0 00000165888080033ffef5f8454f32cb1bb4203f854dd69bc2ca91b2bce1fb3527440000030000030000030000030050999841d1afd324aea000000300000f600011c0001b40004e40011f0003b80010800059000238000be0005e000220001100000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300004041" + b := EncodeToAVCC(decode(s)) + n := naluTypes(b) + require.Equal(t, []byte{0x67, 0x68, 0x65}, n) +} + +func TestFFmpegHEVC(t *testing.T) { + // ffmpeg -re -i bbb.mp4 -c libx265 -preset superfast -tune zerolatency -f hevc - + s := "0000000140010c01ffff01600000030090000003000003005dba0240 0000000142010101600000030090000003000003005da00280802d165bab930bc05a7080000003008000000c04 000000014401c1718312 0000014e0105ffffffffffffffffff2b2ca2de09b51747dbbb55a4fe7fc2fc4e7832363520286275696c642032303829202d20332e352b3131312d6330616631386464353a5b57696e646f77735d5b4743432031332e322e305d5b3634206269745d20386269742b31306269742b3132626974202d20482e3236352f4845564320636f646563202d20436f7079726967687420323031332d3230313820286329204d756c7469636f7265776172" + b := EncodeToAVCC(decode(s)) + n := naluTypes(b) + require.Equal(t, []byte{0x40, 0x42, 0x44, 0x4e}, n) +} + +func TestFFmpegHEVC2(t *testing.T) { + // ffmpeg -re -i bbb.mp4 -c libx265 -preset superfast -tune zerolatency -f hevc - + s := "0000000140010c01ffff01600000030090000003000003005dba0240 0000000142010101600000030090000003000003005da00280802d165bab930bc05a7080000003008000000c04 000000014401c1718312 0000014e0105ffffffffffffffffff2b2ca2de09b51747dbbb55a4fe7fc2fc4e7832363520286275696c642032303829202d20332e352b3131312d6330616631386464353a5b57696e646f77735d5b4743432031332e322e305d5b3634206269745d20386269742b31306269742b3132626974202d20482e3236352f4845564320636f646563202d20436f7079726967687420323031332d3230313820286329204d756c7469636f7265776172652c20496e63202d20687474703a2f2f783236352e6f7267202d206f7074696f6e733a2063707569643d31303439303731206672616d652d746872656164733d32206e756d612d706f6f6c733d3420777070206e6f2d706d6f6465206e6f2d706d65206e6f2d70736e72206e6f2d7373696d206c6f672d6c6576656c3d322062697464657074683d3820696e7075742d6373703d31206670733d32342f3120696e7075742d7265733d313238307837323020696e7465726c6163653d3020746f74616c2d6672616d65733d30206c6576656c2d6964633d3020686967682d746965723d31207568642d62643d30207265663d31206e6f2d616c6c6f772d6e6f6e2d636f6e666f726d616e6365207265706561742d6865616465727320616e6e657862206e6f2d617564206e6f2d656f62206e6f2d656f73206e6f2d68726420696e666f20686173683d302074656d706f72616c2d6c61796572733d30206f70656e2d676f70206d696e2d6b6579696e743d3234206b6579696e743d32353020676f702d6c6f6f6b61686561643d3020626672616d65733d3020622d61646170743d30206e6f2d622d707972616d696420626672616d652d626961733d302072632d6c6f6f6b61686561643d30206c6f6f6b61686561642d736c696365733d34207363656e656375743d30206e6f2d686973742d7363656e65637574207261646c3d30206e6f2d73706c696365206e6f2d696e7472612d72656672657368206374753d3332206d696e2d63752d73697a653d38206e6f2d72656374206e6f2d616d70206d61782d74752d73697a653d33322074752d696e7465722d64657074683d312074752d696e7472612d64657074683d31206c696d69742d74753d302072646f712d6c6576656c3d302064796e616d69632d72643d302e3030206e6f2d7373696d2d7264207369676e68696465206e6f2d74736b6970206e722d696e7472613d30206e722d696e7465723d30206e6f2d636f6e73747261696e65642d696e747261207374726f6e672d696e7472612d736d6f6f7468696e67206d61782d6d657267653d32206c696d69742d726566733d30206e6f2d6c696d69742d6d6f646573206d653d31207375626d653d31206d6572616e67653d35372074656d706f72616c2d6d7670206e6f2d6672616d652d647570206e6f2d686d65206e6f2d77656967687470206e6f2d77656967687462206e6f2d616e616c797a652d7372632d70696373206465626c6f636b3d303a30206e6f2d73616f206e6f2d73616f2d6e6f6e2d6465626c6f636b2072643d322073656c6563746976652d73616f3d30206561726c792d736b69702072736b697020666173742d696e747261206e6f2d74736b69702d66617374206e6f2d63752d6c6f73736c657373206e6f2d622d696e747261206e6f2d73706c697472642d736b697020726470656e616c74793d30207073792d72643d322e3030207073792d72646f713d302e3030206e6f2d72642d726566696e65206e6f2d6c6f73736c65737320636271706f6666733d3020637271706f6666733d302072633d637266206372663d32382e302071636f6d703d302e3630207170737465703d342073746174732d77726974653d302073746174732d726561643d30206970726174696f3d312e34302061712d6d6f64653d302061712d737472656e6774683d302e3030206e6f2d637574726565207a6f6e652d636f756e743d30206e6f2d7374726963742d6362722071672d73697a653d3332206e6f2d72632d677261696e2071706d61783d36392071706d696e3d30206e6f2d636f6e73742d766276207361723d31206f7665727363616e3d3020766964656f666f726d61743d352072616e67653d3020636f6c6f727072696d3d32207472616e736665723d3220636f6c6f726d61747269783d32206368726f6d616c6f633d31206368726f6d616c6f632d746f703d30206368726f6d616c6f632d626f74746f6d3d3020646973706c61792d77696e646f773d3020636c6c3d302c30206d696e2d6c756d613d30206d61782d6c756d613d323535206c6f67322d6d61782d706f632d6c73623d38207675692d74696d696e672d696e666f207675692d6872642d696e666f20736c696365733d31206e6f2d6f70742d71702d707073206e6f2d6f70742d7265662d6c6973742d6c656e6774682d707073206e6f2d6d756c74692d706173732d6f70742d727073207363656e656375742d626961733d302e3035206e6f2d6f70742d63752d64656c74612d7170206e6f2d61712d6d6f74696f6e206e6f2d6864723130206e6f2d68647231302d6f7074206e6f2d6468647231302d6f7074206e6f2d6964722d7265636f766572792d73656920616e616c797369732d72657573652d6c6576656c3d3020616e616c797369732d736176652d72657573652d6c6576656c3d3020616e616c797369732d6c6f61642d72657573652d6c6576656c3d30207363616c652d666163746f723d3020726566696e652d696e7472613d3020726566696e652d696e7465723d3020726566696e652d6d763d3120726566696e652d6374752d646973746f7274696f6e3d30206e6f2d6c696d69742d73616f206374752d696e666f3d30206e6f2d6c6f77706173732d64637420726566696e652d616e616c797369732d747970653d3020636f70792d7069633d31206d61782d617573697a652d666163746f723d312e30206e6f2d64796e616d69632d726566696e65206e6f2d73696e676c652d736569206e6f2d686576632d6171206e6f2d737674206e6f2d6669656c642071702d61646170746174696f6e2d72616e67653d312e3030207363656e656375742d61776172652d71703d30636f6e666f726d616e63652d77696e646f772d6f6666736574732072696768743d3020626f74746f6d3d30206465636f6465722d6d61782d726174653d30206e6f2d7662762d6c6976652d6d756c74692d70617373206e6f2d6d63737466206e6f2d7362726380 0000012801adc2e5bca307b9ce6b18b5ad6a525294a6d117ffd3917322eebaeda718a0000003000003000003021207706824da718a00000300000300000300044408d5db4e31400000030000030000030012500c2725a000000300000300000300002a600e4880000003000003000003000019301180000003000003000003000007d400000300000300000300000300010b000003000003000003000003001810000003000003000003000003019100000300000300000300000d38000003000003000003000067c000000300000300000300025e000003000003000003000c58000003000003000003002b60000003000003000003007f80000003000003000003016300000300000300000303b2000003000003000006e400000300000300000e18000003000003000018d00000030000030000292000000300000300003ce00000030000030000030000030000030000bb80" + b := EncodeToAVCC(decode(s)) + n := naluTypes(b) + require.Equal(t, []byte{0x40, 0x42, 0x44, 0x4e, 0x28}, n) +} + +func TestFFmpegMPEGTSHEVC(t *testing.T) { + // ffmpeg -re -i bbb.mp4 -c libx265 -preset superfast -tune zerolatency -an -f mpegts - + s := "00000001460150 0000000140010c01ffff01600000030090000003000003005dba0240 0000000142010101600000030090000003000003005da00280802d165bab930bc05a7080000003008000000c04 000000014401c1718312 0000014e0105ffffffffffffffffff2b2ca2de09b51747dbbb55a4fe7fc2fc4e7832363520286275696c642032303829202d20332e352b3131312d6330616631386464353a5b57696e646f77735d5b4743432031332e322e305d5b3634206269745d20386269742b31306269742b3132626974202d20482e3236352f4845564320636f646563202d20436f7079726967687420323031332d3230313820286329204d756c7469636f7265776172652c20496e63202d20687474703a2f2f783236352e6f7267202d206f7074696f6e733a2063707569643d31303439303731206672616d652d746872656164733d32206e756d612d706f6f6c733d3420777070206e6f2d706d6f6465206e6f2d706d65206e6f2d70736e72206e6f2d7373696d206c6f672d6c6576656c3d322062697464657074683d3820696e7075742d6373703d31206670733d32342f3120696e7075742d7265733d313238307837323020696e7465726c6163653d3020746f74616c2d6672616d65733d30206c6576656c2d6964633d3020686967682d746965723d31207568642d62643d30207265663d31206e6f2d616c6c6f772d6e6f6e2d636f6e666f726d616e6365207265706561742d6865616465727320616e6e657862206e6f2d617564206e6f2d656f62206e6f2d656f73206e6f2d68726420696e666f20686173683d302074656d706f72616c2d6c61796572733d30206f70656e2d676f70206d696e2d6b6579696e743d3234206b6579696e743d32353020676f702d6c6f6f6b61686561643d3020626672616d65733d3020622d61646170743d30206e6f2d622d707972616d696420626672616d652d626961733d302072632d6c6f6f6b61686561643d30206c6f6f6b61686561642d736c696365733d34207363656e656375743d30206e6f2d686973742d7363656e65637574207261646c3d30206e6f2d73706c696365206e6f2d696e7472612d72656672657368206374753d3332206d696e2d63752d73697a653d38206e6f2d72656374206e6f2d616d70206d61782d74752d73697a653d33322074752d696e7465722d64657074683d312074752d696e7472612d64657074683d31206c696d69742d74753d302072646f712d6c6576656c3d302064796e616d69632d72643d302e3030206e6f2d7373696d2d7264207369676e68696465206e6f2d74736b6970206e722d696e7472613d30206e722d696e7465723d30206e6f2d636f6e73747261696e65642d696e747261207374726f6e672d696e7472612d736d6f6f7468696e67206d61782d6d657267653d32206c696d69742d726566733d30206e6f2d6c696d69742d6d6f646573206d653d31207375626d653d31206d6572616e67653d35372074656d706f72616c2d6d7670206e6f2d6672616d652d647570206e6f2d686d65206e6f2d77656967687470206e6f2d77656967687462206e6f2d616e616c797a652d7372632d70696373206465626c6f636b3d303a30206e6f2d73616f206e6f2d73616f2d6e6f6e2d6465626c6f636b2072643d322073656c6563746976652d73616f3d30206561726c792d736b69702072736b697020666173742d696e747261206e6f2d74736b69702d66617374206e6f2d63752d6c6f73736c657373206e6f2d622d696e747261206e6f2d73706c697472642d736b697020726470656e616c74793d30207073792d72643d322e3030207073792d72646f713d302e3030206e6f2d72642d726566696e65206e6f2d6c6f73736c65737320636271706f6666733d3020637271706f6666733d302072633d637266206372663d32382e302071636f6d703d302e3630207170737465703d342073746174732d77726974653d302073746174732d726561643d30206970726174696f3d312e34302061712d6d6f64653d302061712d737472656e6774683d302e3030206e6f2d637574726565207a6f6e652d636f756e743d30206e6f2d7374726963742d6362722071672d73697a653d3332206e6f2d72632d677261696e2071706d61783d36392071706d696e3d30206e6f2d636f6e73742d766276207361723d31206f7665727363616e3d3020766964656f666f726d61743d352072616e67653d3020636f6c6f727072696d3d32207472616e736665723d3220636f6c6f726d61747269783d32206368726f6d616c6f633d31206368726f6d616c6f632d746f703d30206368726f6d616c6f632d626f74746f6d3d3020646973706c61792d77696e646f773d3020636c6c3d302c30206d696e2d6c756d613d30206d61782d6c756d613d323535206c6f67322d6d61782d706f632d6c73623d38207675692d74696d696e672d696e666f207675692d6872642d696e666f20736c696365733d31206e6f2d6f70742d71702d707073206e6f2d6f70742d7265662d6c6973742d6c656e6774682d707073206e6f2d6d756c74692d706173732d6f70742d727073207363656e656375742d626961733d302e3035206e6f2d6f70742d63752d64656c74612d7170206e6f2d61712d6d6f74696f6e206e6f2d6864723130206e6f2d68647231302d6f7074206e6f2d6468647231302d6f7074206e6f2d6964722d7265636f766572792d73656920616e616c797369732d72657573652d6c6576656c3d3020616e616c797369732d736176652d72657573652d6c6576656c3d3020616e616c797369732d6c6f61642d72657573652d6c6576656c3d30207363616c652d666163746f723d3020726566696e652d696e7472613d3020726566696e652d696e7465723d3020726566696e652d6d763d3120726566696e652d6374752d646973746f7274696f6e3d30206e6f2d6c696d69742d73616f206374752d696e666f3d30206e6f2d6c6f77706173732d64637420726566696e652d616e616c797369732d747970653d3020636f70792d7069633d31206d61782d617573697a652d666163746f723d312e30206e6f2d64796e616d69632d726566696e65206e6f2d73696e676c652d736569206e6f2d686576632d6171206e6f2d737674206e6f2d6669656c642071702d61646170746174696f6e2d72616e67653d312e3030207363656e656375742d61776172652d71703d30636f6e666f726d616e63652d77696e646f772d6f6666736574732072696768743d3020626f74746f6d3d30206465636f6465722d6d61782d726174653d30206e6f2d7662762d6c6976652d6d756c74692d70617373206e6f2d6d63737466206e6f2d7362726380 0000012801adc2e5bca307b9ce6b18b5ad6a525294a6d117ffd3917322eebaeda718a0000003000003000003021207706824da718a00000300000300000300044408d5db4e31400000030000030000030012500c2725a000000300000300000300002a600e4880000003000003000003000019301180000003000003000003000007d400000300000300000300000300010b000003000003000003000003001810000003000003000003000003019100000300000300000300000d38000003000003000003000067c000000300000300000300025e000003000003000003000c58000003000003000003002b60000003000003000003007f80000003000003000003016300000300000300000303b2000003000003000006e400000300000300000e18000003000003000018d00000030000030000292000000300000300003ce00000030000030000030000030000030000bb80" + b := EncodeToAVCC(decode(s)) + n := naluTypes(b) + require.Equal(t, []byte{0x40, 0x42, 0x44, 0x4e, 0x28}, n) +} + +func TestReolink(t *testing.T) { + s := "000001460150 00000140010C01FFFF01600000030000030000030000030096AC09 0000000142010101600000030000030000030000030096A001E020021C7F8AAD3BA24BB804000013D800018CE008 000000014401C072F0941E3648 000000012601" + b := EncodeToAVCC(decode(s)) + n := naluTypes(b) + require.Equal(t, []byte{0x40, 0x42, 0x44, 0x26}, n) +} + +func TestDahua(t *testing.T) { + s := "00000001460150 00000140010c01ffff01400000030000030000030000030099ac0900 0000000142010101400000030000030000030000030099a001402005a1fe5aee46c1ae550400 000000014401c073c04c9000 000000012601" + b := EncodeToAVCC(decode(s)) + n := naluTypes(b) + require.Equal(t, []byte{0x40, 0x42, 0x44, 0x26}, n) +} + +func TestUSB(t *testing.T) { + s := "00 00 00 01 67 4D 00 1F 8D 8D 40 28 02 DD 37 01 01 01 40 00 01 C2 00 00 57 E4 01 00 00 00 01 68 EE 3C 80 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 01 65 88 80 00" + b := EncodeToAVCC(decode(s)) + n := naluTypes(b) + require.Equal(t, []byte{0x67, 0x68, 0x65}, n) + + s = "00 00 00 00 00 00 00 00 00 00 00 00 00 00 01 41 9A 00 4C" + b = EncodeToAVCC(decode(s)) + n = naluTypes(b) + require.Equal(t, []byte{0x41}, n) +} diff --git a/pkg/h264/rtp.go b/pkg/h264/rtp.go index b4a9dafb..d093254f 100644 --- a/pkg/h264/rtp.go +++ b/pkg/h264/rtp.go @@ -22,7 +22,7 @@ func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { buf := make([]byte, 0, 512*1024) // 512K return func(packet *rtp.Packet) { - //log.Printf("[RTP] codec: %s, nalu: %2d, size: %6d, ts: %10d, pt: %2d, ssrc: %d, seq: %d, %v", track.Codec.Name, packet.Payload[0]&0x1F, len(packet.Payload), packet.Timestamp, packet.PayloadType, packet.SSRC, packet.SequenceNumber, packet.Marker) + //log.Printf("[RTP] codec: %s, nalu: %2d, size: %6d, ts: %10d, pt: %2d, ssrc: %d, seq: %d, %v", codec.Name, packet.Payload[0]&0x1F, len(packet.Payload), packet.Timestamp, packet.PayloadType, packet.SSRC, packet.SequenceNumber, packet.Marker) payload, err := depack.Unmarshal(packet.Payload) if len(payload) == 0 || err != nil { @@ -68,6 +68,9 @@ func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { payload = payload[i:] continue + case NALUTypePFrame, NALUTypeSPS, NALUTypePPS: // pass + default: + return // skip any unknown NAL unit type } break } diff --git a/pkg/hap/camera/stream.go b/pkg/hap/camera/stream.go index b2ef0d9f..23d53c39 100644 --- a/pkg/hap/camera/stream.go +++ b/pkg/hap/camera/stream.go @@ -15,7 +15,8 @@ type Stream struct { } func NewStream( - client *hap.Client, videoCodec *VideoCodec, audioCodec *AudioCodec, videoSession, audioSession *srtp.Session, + client *hap.Client, videoCodec *VideoCodec, audioCodec *AudioCodec, + videoSession, audioSession *srtp.Session, bitrate int, ) (*Stream, error) { stream := &Stream{ id: core.RandString(16, 0), @@ -30,11 +31,17 @@ func NewStream( return nil, err } + if bitrate != 0 { + bitrate /= 1024 // convert bps to kbps + } else { + bitrate = 4096 // default kbps for general FullHD camera + } + videoCodec.RTPParams = []RTPParams{ { PayloadType: 99, SSRC: videoSession.Local.SSRC, - MaxBitrate: 299, + MaxBitrate: uint16(bitrate), // iPhone query 299Kbps, iPad/AppleTV query 802Kbps RTCPInterval: 0.5, MaxMTU: []uint16{1378}, }, @@ -43,7 +50,7 @@ func NewStream( { PayloadType: 110, SSRC: audioSession.Local.SSRC, - MaxBitrate: 24, + MaxBitrate: 24, // any iDevice query 24Kbps (this is OK for 16KHz and 1 channel) RTCPInterval: 5, ComfortNoisePayloadType: []uint8{13}, diff --git a/pkg/hass/client.go b/pkg/hass/client.go index c1ed5b4b..5b236051 100644 --- a/pkg/hass/client.go +++ b/pkg/hass/client.go @@ -61,8 +61,10 @@ func NewClient(rawURL string) (*Client, error) { } conn := webrtc.NewConn(pc) - conn.Desc = "Hass" + conn.FormatName = "hass/webrtc" conn.Mode = core.ModeActiveProducer + conn.Protocol = "ws" + conn.URL = rawURL // https://developers.google.com/nest/device-access/traits/device/camera-live-stream#generatewebrtcstream-request-fields medias := []*core.Media{ diff --git a/pkg/hls/producer.go b/pkg/hls/producer.go index 410e771a..e1c3ed43 100644 --- a/pkg/hls/producer.go +++ b/pkg/hls/producer.go @@ -4,14 +4,19 @@ import ( "io" "net/url" - "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/mpegts" ) -func OpenURL(u *url.URL, body io.ReadCloser) (core.Producer, error) { +func OpenURL(u *url.URL, body io.ReadCloser) (*mpegts.Producer, error) { rd, err := NewReader(u, body) if err != nil { return nil, err } - return mpegts.Open(rd) + prod, err := mpegts.Open(rd) + if err != nil { + return nil, err + } + prod.FormatName = "hls/mpegts" + prod.RemoteAddr = u.Host + return prod, nil } diff --git a/pkg/homekit/consumer.go b/pkg/homekit/consumer.go index 1e04fedf..ea83146f 100644 --- a/pkg/homekit/consumer.go +++ b/pkg/homekit/consumer.go @@ -16,7 +16,7 @@ import ( ) type Consumer struct { - core.SuperConsumer + core.Connection conn net.Conn srtp *srtp.Server @@ -29,28 +29,31 @@ type Consumer struct { } func NewConsumer(conn net.Conn, server *srtp.Server) *Consumer { - return &Consumer{ - SuperConsumer: core.SuperConsumer{ - Type: "HomeKit passive consumer", - RemoteAddr: conn.RemoteAddr().String(), - Medias: []*core.Media{ - { - Kind: core.KindVideo, - Direction: core.DirectionSendonly, - Codecs: []*core.Codec{ - {Name: core.CodecH264}, - }, - }, - { - Kind: core.KindAudio, - Direction: core.DirectionSendonly, - Codecs: []*core.Codec{ - {Name: core.CodecOpus}, - }, - }, + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecH264}, }, }, - + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecOpus}, + }, + }, + } + return &Consumer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "homekit", + Protocol: "udp", + RemoteAddr: conn.RemoteAddr().String(), + Medias: medias, + Transport: conn, + }, conn: conn, srtp: server, } @@ -175,11 +178,10 @@ func (c *Consumer) WriteTo(io.Writer) (int64, error) { } func (c *Consumer) Stop() error { - _ = c.SuperConsumer.Close() if c.deadline != nil { c.deadline.Reset(0) } - return c.SuperConsumer.Close() + return c.Connection.Stop() } func (c *Consumer) srtpEndpoint() *srtp.Endpoint { diff --git a/pkg/homekit/helpers.go b/pkg/homekit/helpers.go index 89c63dc3..a1719671 100644 --- a/pkg/homekit/helpers.go +++ b/pkg/homekit/helpers.go @@ -2,7 +2,6 @@ package homekit import ( "encoding/hex" - "slices" "github.com/AlexxIT/go2rtc/pkg/aac" "github.com/AlexxIT/go2rtc/pkg/core" @@ -22,8 +21,8 @@ func videoToMedia(codecs []camera.VideoCodec) *core.Media { for _, codec := range codecs { for _, param := range codec.CodecParams { // get best profile and level - profileID := slices.Max(param.ProfileID) - level := slices.Max(param.Level) + profileID := core.Max(param.ProfileID) + level := core.Max(param.Level) profile := videoProfiles[profileID] + videoLevels[level] mediaCodec := &core.Codec{ Name: videoCodecs[codec.CodecType], diff --git a/pkg/homekit/client.go b/pkg/homekit/producer.go similarity index 93% rename from pkg/homekit/client.go rename to pkg/homekit/producer.go index c61acea6..451b9882 100644 --- a/pkg/homekit/client.go +++ b/pkg/homekit/producer.go @@ -15,8 +15,9 @@ import ( "github.com/pion/rtp" ) +// Deprecated: rename to Producer type Client struct { - core.SuperProducer + core.Connection hap *hap.Client srtp *srtp.Server @@ -28,6 +29,8 @@ type Client struct { audioSession *srtp.Session stream *camera.Stream + + Bitrate int // in bits/s } func Dial(rawURL string, server *srtp.Server) (*Client, error) { @@ -50,9 +53,13 @@ func Dial(rawURL string, server *srtp.Server) (*Client, error) { } client := &Client{ - SuperProducer: core.SuperProducer{ - Type: "HomeKit active producer", - URL: conn.URL(), + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "homekit", + Protocol: "udp", + RemoteAddr: conn.Conn.RemoteAddr().String(), + Source: rawURL, + Transport: conn, }, hap: conn, srtp: server, @@ -91,7 +98,6 @@ func (c *Client) GetMedias() []*core.Media { return nil } - c.URL = c.hap.URL() c.SDP = fmt.Sprintf("%+v\n%+v", c.videoConfig, c.audioConfig) c.Medias = []*core.Media{ @@ -132,7 +138,7 @@ func (c *Client) Start() error { c.audioSession = &srtp.Session{Local: c.srtpEndpoint()} var err error - c.stream, err = camera.NewStream(c.hap, videoCodec, audioCodec, c.videoSession, c.audioSession) + c.stream, err = camera.NewStream(c.hap, videoCodec, audioCodec, c.videoSession, c.audioSession, c.Bitrate) if err != nil { return err } @@ -173,8 +179,6 @@ func (c *Client) Start() error { } func (c *Client) Stop() error { - _ = c.SuperProducer.Close() - if c.videoSession != nil && c.videoSession.Remote != nil { c.srtp.DelSession(c.videoSession) } @@ -182,7 +186,7 @@ func (c *Client) Stop() error { c.srtp.DelSession(c.audioSession) } - return c.hap.Close() + return c.Connection.Stop() } func (c *Client) trackByKind(kind string) *core.Receiver { diff --git a/pkg/image/producer.go b/pkg/image/producer.go new file mode 100644 index 00000000..2081c048 --- /dev/null +++ b/pkg/image/producer.go @@ -0,0 +1,92 @@ +package image + +import ( + "errors" + "io" + "net/http" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/tcp" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + + closed bool + res *http.Response +} + +func Open(res *http.Response) (*Producer, error) { + return &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "image", + Protocol: "http", + RemoteAddr: res.Request.URL.Host, + Transport: res.Body, + Medias: []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: core.CodecJPEG, + ClockRate: 90000, + PayloadType: core.PayloadTypeRAW, + }, + }, + }, + }, + }, + res: res, + }, nil +} + +func (c *Producer) Start() error { + body, err := io.ReadAll(c.res.Body) + if err != nil { + return err + } + + pkt := &rtp.Packet{ + Header: rtp.Header{Timestamp: core.Now90000()}, + Payload: body, + } + c.Receivers[0].WriteRTP(pkt) + + c.Recv += len(body) + + req := c.res.Request + + for !c.closed { + res, err := tcp.Do(req) + if err != nil { + return err + } + + if res.StatusCode != http.StatusOK { + return errors.New("wrong status: " + res.Status) + } + + body, err = io.ReadAll(res.Body) + if err != nil { + return err + } + + c.Recv += len(body) + + pkt = &rtp.Packet{ + Header: rtp.Header{Timestamp: core.Now90000()}, + Payload: body, + } + c.Receivers[0].WriteRTP(pkt) + } + + return nil +} + +func (c *Producer) Stop() error { + c.closed = true + return c.Connection.Stop() +} diff --git a/pkg/isapi/consumer.go b/pkg/isapi/backchannel.go similarity index 83% rename from pkg/isapi/consumer.go rename to pkg/isapi/backchannel.go index c7b51c9d..ade16255 100644 --- a/pkg/isapi/consumer.go +++ b/pkg/isapi/backchannel.go @@ -2,6 +2,7 @@ package isapi import ( "encoding/json" + "github.com/AlexxIT/go2rtc/pkg/core" "github.com/pion/rtp" ) @@ -51,10 +52,15 @@ func (c *Client) Stop() (err error) { } func (c *Client) MarshalJSON() ([]byte, error) { - info := &core.Info{ - Type: "ISAPI active consumer", - Medias: c.medias, - Send: c.send, + info := &core.Connection{ + ID: core.ID(c), + FormatName: "isapi", + Protocol: "http", + Medias: c.medias, + Send: c.send, + } + if c.conn != nil { + info.RemoteAddr = c.conn.RemoteAddr().String() } if c.sender != nil { info.Senders = []*core.Sender{c.sender} diff --git a/pkg/isapi/client.go b/pkg/isapi/client.go index e5dfafd4..ba3e6887 100644 --- a/pkg/isapi/client.go +++ b/pkg/isapi/client.go @@ -11,6 +11,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/tcp" ) +// Deprecated: should be rewritten to core.Connection type Client struct { core.Listener @@ -23,7 +24,7 @@ type Client struct { send int } -func NewClient(rawURL string) (*Client, error) { +func Dial(rawURL string) (*Client, error) { // check if url is valid url u, err := url.Parse(rawURL) if err != nil { @@ -33,7 +34,11 @@ func NewClient(rawURL string) (*Client, error) { u.Scheme = "http" u.Path = "" - return &Client{url: u.String()}, nil + client := &Client{url: u.String()} + if err = client.Dial(); err != nil { + return nil, err + } + return client, err } func (c *Client) Dial() (err error) { diff --git a/pkg/ivideon/client.go b/pkg/ivideon/client.go index c1b055b8..ef79010e 100644 --- a/pkg/ivideon/client.go +++ b/pkg/ivideon/client.go @@ -26,6 +26,7 @@ const ( StateHandle ) +// Deprecated: should be rewritten to core.Connection type Client struct { core.Listener @@ -46,8 +47,13 @@ type Client struct { recv int } -func NewClient(id string) *Client { - return &Client{ID: id} +func Dial(source string) (*Client, error) { + id := strings.Replace(source[8:], "/", ":", 1) + client := &Client{ID: id} + if err := client.Dial(); err != nil { + return nil, err + } + return client, nil } func (c *Client) Dial() (err error) { diff --git a/pkg/ivideon/producer.go b/pkg/ivideon/producer.go index d0a8fcba..78084123 100644 --- a/pkg/ivideon/producer.go +++ b/pkg/ivideon/producer.go @@ -2,6 +2,7 @@ package ivideon import ( "encoding/json" + "github.com/AlexxIT/go2rtc/pkg/core" ) @@ -32,11 +33,16 @@ func (c *Client) Stop() error { } func (c *Client) MarshalJSON() ([]byte, error) { - info := &core.Info{ - Type: "Ivideon active producer", - URL: c.ID, - Medias: c.medias, - Recv: c.recv, + info := &core.Connection{ + ID: core.ID(c), + FormatName: "ivideon", + Protocol: "ws", + URL: c.ID, + Medias: c.medias, + Recv: c.recv, + } + if c.conn != nil { + info.RemoteAddr = c.conn.RemoteAddr().String() } if c.receiver != nil { info.Receivers = []*core.Receiver{c.receiver} diff --git a/pkg/kasa/producer.go b/pkg/kasa/producer.go index d138cb68..697c19e8 100644 --- a/pkg/kasa/producer.go +++ b/pkg/kasa/producer.go @@ -12,13 +12,13 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/h264" "github.com/AlexxIT/go2rtc/pkg/h264/annexb" - "github.com/AlexxIT/go2rtc/pkg/multipart" + "github.com/AlexxIT/go2rtc/pkg/mpjpeg" "github.com/AlexxIT/go2rtc/pkg/tcp" "github.com/pion/rtp" ) type Producer struct { - core.SuperProducer + core.Connection rd *core.ReadBuffer reader *bufio.Reader @@ -65,11 +65,18 @@ func Dial(url string) (*Producer, error) { rd.Reader = httputil.NewChunkedReader(buf) } - prod := &Producer{rd: core.NewReadBuffer(rd)} + prod := &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "kasa", + Protocol: "http", + Transport: rd, + }, + rd: core.NewReadBuffer(rd), + } if err = prod.probe(); err != nil { return nil, err } - prod.Type = "Kasa producer" return prod, nil } @@ -90,7 +97,7 @@ func (c *Producer) Start() error { } for { - header, body, err := multipart.Next(c.reader) + header, body, err := mpjpeg.Next(c.reader) if err != nil { return err } @@ -106,7 +113,7 @@ func (c *Producer) Start() error { Header: rtp.Header{ Timestamp: uint32(ts * 90000), }, - Payload: annexb.EncodeToAVCC(body, false), + Payload: annexb.EncodeToAVCC(body), } video.WriteRTP(pkt) } @@ -128,11 +135,6 @@ func (c *Producer) Start() error { } } -func (c *Producer) Stop() error { - _ = c.SuperProducer.Close() - return c.rd.Close() -} - const ( MimeVideo = "video/x-h264" MimeG711U = "audio/g711u" @@ -151,7 +153,7 @@ func (c *Producer) probe() error { timeout := time.Now().Add(core.ProbeTimeout) for (waitVideo || waitAudio) && time.Now().Before(timeout) { - header, body, err := multipart.Next(c.reader) + header, body, err := mpjpeg.Next(c.reader) if err != nil { return err } @@ -166,7 +168,7 @@ func (c *Producer) probe() error { } waitVideo = false - body = annexb.EncodeToAVCC(body, false) + body = annexb.EncodeToAVCC(body) codec := h264.AVCCToCodec(body) media = &core.Media{ Kind: core.KindVideo, diff --git a/pkg/magic/bitstream/producer.go b/pkg/magic/bitstream/producer.go index 2ffa964e..5f00f41e 100644 --- a/pkg/magic/bitstream/producer.go +++ b/pkg/magic/bitstream/producer.go @@ -13,7 +13,7 @@ import ( ) type Producer struct { - core.SuperProducer + core.Connection rd *core.ReadBuffer } @@ -25,29 +25,38 @@ func Open(r io.Reader) (*Producer, error) { return nil, err } - buf = annexb.EncodeToAVCC(buf, false) // won't break original buffer + buf = annexb.EncodeToAVCC(buf) // won't break original buffer var codec *core.Codec + var format string switch { case h264.NALUType(buf) == h264.NALUTypeSPS: codec = h264.AVCCToCodec(buf) + format = "h264" case h265.NALUType(buf) == h265.NALUTypeVPS: codec = h265.AVCCToCodec(buf) + format = "hevc" default: return nil, errors.New("bitstream: unsupported header: " + hex.EncodeToString(buf[:8])) } - prod := &Producer{rd: rd} - prod.Type = "Bitstream producer" - prod.Medias = []*core.Media{ + medias := []*core.Media{ { Kind: core.KindVideo, Direction: core.DirectionRecvonly, Codecs: []*core.Codec{codec}, }, } - return prod, nil + return &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: format, + Medias: medias, + Transport: r, + }, + rd: rd, + }, nil } func (c *Producer) Start() error { @@ -73,7 +82,7 @@ func (c *Producer) Start() error { if len(c.Receivers) > 0 { pkt := &rtp.Packet{ Header: rtp.Header{Timestamp: core.Now90000()}, - Payload: annexb.EncodeToAVCC(buf[:i], true), + Payload: annexb.EncodeToAVCC(buf[:i]), } c.Receivers[0].WriteRTP(pkt) @@ -84,8 +93,3 @@ func (c *Producer) Start() error { } } } - -func (c *Producer) Stop() error { - _ = c.SuperProducer.Close() - return c.rd.Close() -} diff --git a/pkg/magic/keyframe.go b/pkg/magic/keyframe.go index d2ae80bd..9b6ef562 100644 --- a/pkg/magic/keyframe.go +++ b/pkg/magic/keyframe.go @@ -12,26 +12,33 @@ import ( ) type Keyframe struct { - core.SuperConsumer + core.Connection wr *core.WriteBuffer } +// Deprecated: should be rewritten func NewKeyframe() *Keyframe { - return &Keyframe{ - core.SuperConsumer{ - Medias: []*core.Media{ - { - Kind: core.KindVideo, - Direction: core.DirectionSendonly, - Codecs: []*core.Codec{ - {Name: core.CodecJPEG}, - {Name: core.CodecH264}, - {Name: core.CodecH265}, - }, - }, + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecJPEG}, + {Name: core.CodecRAW}, + {Name: core.CodecH264}, + {Name: core.CodecH265}, }, }, - core.NewWriteBuffer(nil), + } + wr := core.NewWriteBuffer(nil) + return &Keyframe{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "keyframe", + Medias: medias, + Transport: wr, + }, + wr: wr, } } @@ -81,6 +88,15 @@ func (k *Keyframe) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv if track.Codec.IsRTP() { sender.Handler = mjpeg.RTPDepay(sender.Handler) } + + case core.CodecRAW: + sender.Handler = func(packet *rtp.Packet) { + if n, err := k.wr.Write(packet.Payload); err == nil { + k.Send += n + } + } + + sender.Handler = mjpeg.Encoder(track.Codec, 5, sender.Handler) } sender.HandleRTP(track) @@ -98,8 +114,3 @@ func (k *Keyframe) CodecName() string { func (k *Keyframe) WriteTo(wr io.Writer) (int64, error) { return k.wr.WriteTo(wr) } - -func (k *Keyframe) Stop() error { - _ = k.SuperConsumer.Close() - return k.wr.Close() -} diff --git a/pkg/magic/mjpeg/producer.go b/pkg/magic/mjpeg/producer.go index e5627fd7..e47c168d 100644 --- a/pkg/magic/mjpeg/producer.go +++ b/pkg/magic/mjpeg/producer.go @@ -9,14 +9,12 @@ import ( ) type Producer struct { - core.SuperProducer + core.Connection rd *core.ReadBuffer } func Open(rd io.Reader) (*Producer, error) { - prod := &Producer{rd: core.NewReadBuffer(rd)} - prod.Type = "MJPEG producer" - prod.Medias = []*core.Media{ + medias := []*core.Media{ { Kind: core.KindVideo, Direction: core.DirectionRecvonly, @@ -29,7 +27,15 @@ func Open(rd io.Reader) (*Producer, error) { }, }, } - return prod, nil + return &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "mjpeg", + Medias: medias, + Transport: rd, + }, + rd: core.NewReadBuffer(rd), + }, nil } func (c *Producer) Start() error { @@ -70,8 +76,3 @@ func (c *Producer) Start() error { buf = buf[i:] } } - -func (c *Producer) Stop() error { - _ = c.SuperProducer.Close() - return c.rd.Close() -} diff --git a/pkg/magic/producer.go b/pkg/magic/producer.go index 9bde508d..3742ccf9 100644 --- a/pkg/magic/producer.go +++ b/pkg/magic/producer.go @@ -13,7 +13,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/magic/bitstream" "github.com/AlexxIT/go2rtc/pkg/magic/mjpeg" "github.com/AlexxIT/go2rtc/pkg/mpegts" - "github.com/AlexxIT/go2rtc/pkg/multipart" + "github.com/AlexxIT/go2rtc/pkg/mpjpeg" "github.com/AlexxIT/go2rtc/pkg/wav" "github.com/AlexxIT/go2rtc/pkg/y4m" ) @@ -26,29 +26,31 @@ func Open(r io.Reader) (core.Producer, error) { return nil, err } - switch { - case string(b) == annexb.StartCode: + switch string(b) { + case annexb.StartCode: return bitstream.Open(rd) - - case string(b) == wav.FourCC: + case wav.FourCC: return wav.Open(rd) - - case string(b) == y4m.FourCC: + case y4m.FourCC: return y4m.Open(rd) + } - case bytes.HasPrefix(b, []byte{0xFF, 0xD8}): - return mjpeg.Open(rd) - - case bytes.HasPrefix(b, []byte(flv.Signature)): + switch string(b[:3]) { + case flv.Signature: return flv.Open(rd) + } - case bytes.HasPrefix(b, []byte("--")): - return multipart.Open(rd) - - case b[0] == 0xFF && (b[1] == 0xF1 || b[1] == 0xF9): + switch string(b[:2]) { + case "\xFF\xD8": + return mjpeg.Open(rd) + case "\xFF\xF1", "\xFF\xF9": return aac.Open(rd) + case "--": + return mpjpeg.Open(rd) + } - case b[0] == mpegts.SyncByte: + switch b[0] { + case mpegts.SyncByte: return mpegts.Open(rd) } diff --git a/pkg/mjpeg/client.go b/pkg/mjpeg/client.go deleted file mode 100644 index f16c42cd..00000000 --- a/pkg/mjpeg/client.go +++ /dev/null @@ -1,75 +0,0 @@ -package mjpeg - -import ( - "errors" - "io" - "net/http" - - "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/AlexxIT/go2rtc/pkg/tcp" - "github.com/pion/rtp" -) - -type Client struct { - core.Listener - - UserAgent string - RemoteAddr string - - closed bool - res *http.Response - - medias []*core.Media - receiver *core.Receiver - - recv int -} - -func NewClient(res *http.Response) *Client { - return &Client{res: res} -} - -func (c *Client) Handle() error { - body, err := io.ReadAll(c.res.Body) - if err != nil { - return err - } - - pkt := &rtp.Packet{ - Header: rtp.Header{Timestamp: core.Now90000()}, - Payload: body, - } - c.receiver.WriteRTP(pkt) - - c.recv += len(body) - - req := c.res.Request - - for !c.closed { - res, err := tcp.Do(req) - if err != nil { - return err - } - - if res.StatusCode != http.StatusOK { - return errors.New("wrong status: " + res.Status) - } - - body, err = io.ReadAll(res.Body) - if err != nil { - return err - } - - c.recv += len(body) - - if c.receiver != nil { - pkt = &rtp.Packet{ - Header: rtp.Header{Timestamp: core.Now90000()}, - Payload: body, - } - c.receiver.WriteRTP(pkt) - } - } - - return nil -} diff --git a/pkg/mjpeg/consumer.go b/pkg/mjpeg/consumer.go index d5fb0d51..819c558a 100644 --- a/pkg/mjpeg/consumer.go +++ b/pkg/mjpeg/consumer.go @@ -8,26 +8,30 @@ import ( ) type Consumer struct { - core.SuperConsumer + core.Connection wr *core.WriteBuffer } func NewConsumer() *Consumer { - return &Consumer{ - core.SuperConsumer{ - Type: "MJPEG passive consumer", - Medias: []*core.Media{ - { - Kind: core.KindVideo, - Direction: core.DirectionSendonly, - Codecs: []*core.Codec{ - {Name: core.CodecJPEG}, - {Name: core.CodecRAW}, - }, - }, + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecJPEG}, + {Name: core.CodecRAW}, }, }, - core.NewWriteBuffer(nil), + } + wr := core.NewWriteBuffer(nil) + return &Consumer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "mjpeg", + Medias: medias, + Transport: wr, + }, + wr: wr, } } @@ -42,7 +46,7 @@ func (c *Consumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv if track.Codec.IsRTP() { sender.Handler = RTPDepay(sender.Handler) } else if track.Codec.Name == core.CodecRAW { - sender.Handler = Encoder(track.Codec, sender.Handler) + sender.Handler = Encoder(track.Codec, 0, sender.Handler) } sender.HandleRTP(track) @@ -53,8 +57,3 @@ func (c *Consumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv func (c *Consumer) WriteTo(wr io.Writer) (int64, error) { return c.wr.WriteTo(wr) } - -func (c *Consumer) Stop() error { - _ = c.SuperConsumer.Close() - return c.wr.Close() -} diff --git a/pkg/mjpeg/helpers.go b/pkg/mjpeg/helpers.go index 08b4408b..87f59e07 100644 --- a/pkg/mjpeg/helpers.go +++ b/pkg/mjpeg/helpers.go @@ -9,24 +9,38 @@ import ( "github.com/pion/rtp" ) -// FixJPEG - reencode JPEG if it has wrong header -// -// for example, this app produce "bad" images: -// https://github.com/jacksonliam/mjpg-streamer -// -// and they can't be uploaded to the Telegram servers: -// {"ok":false,"error_code":400,"description":"Bad Request: IMAGE_PROCESS_FAILED"} func FixJPEG(b []byte) []byte { // skip non-JPEG - if len(b) < 10 || b[0] != 0xFF || b[1] != 0xD8 { - return b - } - // skip if header OK for imghdr library - // https://docs.python.org/3/library/imghdr.html - if string(b[2:4]) == "\xFF\xDB" || string(b[6:10]) == "JFIF" || string(b[6:10]) == "Exif" { + if len(b) < 10 || b[0] != 0xFF || b[1] != markerSOI { return b } + // skip JPEG without app marker + if b[2] == 0xFF && b[3] == markerDQT { + return b + } + + switch string(b[6:10]) { + case "JFIF", "Exif": + // skip if header OK for imghdr library + // - https://docs.python.org/3/library/imghdr.html + return b + case "AVI1": + // adds DHT tables to JPEG file before SOS marker + // useful when you want to save a JPEG frame from an MJPEG stream + // - https://github.com/image-rs/jpeg-decoder/issues/76 + // - https://github.com/pion/mediadevices/pull/493 + // - https://bugzilla.mozilla.org/show_bug.cgi?id=963907#c18 + return InjectDHT(b) + } + + // reencode JPEG if it has wrong header + // + // for example, this app produce "bad" images: + // https://github.com/jacksonliam/mjpg-streamer + // + // and they can't be uploaded to the Telegram servers: + // {"ok":false,"error_code":400,"description":"Bad Request: IMAGE_PROCESS_FAILED"} img, err := jpeg.Decode(bytes.NewReader(b)) if err != nil { return b @@ -38,12 +52,19 @@ func FixJPEG(b []byte) []byte { return buf.Bytes() } -func Encoder(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { +// Encoder convert YUV frame to Img. +// Support skipping empty frames, for example if USB cam needs time to start. +func Encoder(codec *core.Codec, skipEmpty int, handler core.HandlerFunc) core.HandlerFunc { newImage := y4m.NewImage(codec.FmtpLine) return func(packet *rtp.Packet) { img := newImage(packet.Payload) + if skipEmpty != 0 && y4m.HasSameColor(img) { + skipEmpty-- + return + } + buf := bytes.NewBuffer(nil) if err := jpeg.Encode(buf, img, nil); err != nil { return @@ -54,3 +75,26 @@ func Encoder(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { handler(&clone) } } + +const dhtSize = 432 // known size for 4 default tables + +func InjectDHT(b []byte) []byte { + if bytes.Index(b, []byte{0xFF, markerDHT}) > 0 { + return b // already exist + } + + i := bytes.Index(b, []byte{0xFF, markerSOS}) + if i < 0 { + return b + } + + dht := make([]byte, 0, dhtSize) + dht = MakeHuffmanHeaders(dht) + + tmp := make([]byte, len(b)+dhtSize) + copy(tmp, b[:i]) + copy(tmp[i:], dht) + copy(tmp[i+dhtSize:], b[i:]) + + return tmp +} diff --git a/pkg/mjpeg/jpeg.go b/pkg/mjpeg/jpeg.go new file mode 100644 index 00000000..8d6d13d1 --- /dev/null +++ b/pkg/mjpeg/jpeg.go @@ -0,0 +1,10 @@ +package mjpeg + +const ( + markerSOF = 0xC0 // Start Of Frame (Baseline Sequential) + markerSOI = 0xD8 // Start Of Image + markerEOI = 0xD9 // End Of Image + markerSOS = 0xDA // Start Of Scan + markerDQT = 0xDB // Define Quantization Table + markerDHT = 0xC4 // Define Huffman Table +) diff --git a/pkg/mjpeg/mjpeg_test.go b/pkg/mjpeg/mjpeg_test.go new file mode 100644 index 00000000..586f8c80 --- /dev/null +++ b/pkg/mjpeg/mjpeg_test.go @@ -0,0 +1,13 @@ +package mjpeg + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRFC2435(t *testing.T) { + lqt, cqt := MakeTables(71) + require.Equal(t, byte(9), lqt[0]) + require.Equal(t, byte(10), cqt[0]) +} diff --git a/pkg/mjpeg/producer.go b/pkg/mjpeg/producer.go deleted file mode 100644 index 5b352252..00000000 --- a/pkg/mjpeg/producer.go +++ /dev/null @@ -1,61 +0,0 @@ -package mjpeg - -import ( - "encoding/json" - - "github.com/AlexxIT/go2rtc/pkg/core" -) - -func (c *Client) GetMedias() []*core.Media { - if c.medias == nil { - c.medias = []*core.Media{{ - Kind: core.KindVideo, - Direction: core.DirectionRecvonly, - Codecs: []*core.Codec{ - { - Name: core.CodecJPEG, - ClockRate: 90000, - PayloadType: core.PayloadTypeRAW, - }, - }, - }} - } - return c.medias -} - -func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { - if c.receiver == nil { - c.receiver = core.NewReceiver(media, codec) - } - return c.receiver, nil -} - -func (c *Client) Start() error { - // https://github.com/AlexxIT/go2rtc/issues/278 - return c.Handle() -} - -func (c *Client) Stop() error { - if c.receiver != nil { - c.receiver.Close() - } - // important for close reader/writer gorutines - _ = c.res.Body.Close() - c.closed = true - return nil -} - -func (c *Client) MarshalJSON() ([]byte, error) { - info := &core.Info{ - Type: "JPEG active producer", - URL: c.res.Request.URL.String(), - RemoteAddr: c.RemoteAddr, - UserAgent: c.UserAgent, - Medias: c.medias, - Recv: c.recv, - } - if c.receiver != nil { - info.Receivers = []*core.Receiver{c.receiver} - } - return json.Marshal(info) -} diff --git a/pkg/mjpeg/rfc2435.go b/pkg/mjpeg/rfc2435.go index f7e41330..aa34c2f1 100644 --- a/pkg/mjpeg/rfc2435.go +++ b/pkg/mjpeg/rfc2435.go @@ -2,21 +2,24 @@ package mjpeg // RFC 2435. Appendix A -var jpeg_luma_quantizer = []byte{ - 16, 11, 10, 16, 24, 40, 51, 61, - 12, 12, 14, 19, 26, 58, 60, 55, - 14, 13, 16, 24, 40, 57, 69, 56, - 14, 17, 22, 29, 51, 87, 80, 62, - 18, 22, 37, 56, 68, 109, 103, 77, - 24, 35, 55, 64, 81, 104, 113, 92, - 49, 64, 78, 87, 103, 121, 120, 101, - 72, 92, 95, 98, 112, 100, 103, 99, +// don't know why two tables are not respect RFC +// https://github.com/FFmpeg/FFmpeg/blob/master/libavformat/rtpdec_jpeg.c + +var jpeg_luma_quantizer = [64]byte{ + 16, 11, 12, 14, 12, 10, 16, 14, + 13, 14, 18, 17, 16, 19, 24, 40, + 26, 24, 22, 22, 24, 49, 35, 37, + 29, 40, 58, 51, 61, 60, 57, 51, + 56, 55, 64, 72, 92, 78, 64, 68, + 87, 69, 55, 56, 80, 109, 81, 87, + 95, 98, 103, 104, 103, 62, 77, 113, + 121, 112, 100, 120, 92, 101, 103, 99, } -var jpeg_chroma_quantizer = []byte{ - 17, 18, 24, 47, 99, 99, 99, 99, - 18, 21, 26, 66, 99, 99, 99, 99, - 24, 26, 56, 99, 99, 99, 99, 99, - 47, 66, 99, 99, 99, 99, 99, 99, +var jpeg_chroma_quantizer = [64]byte{ + 17, 18, 18, 24, 21, 24, 47, 26, + 26, 47, 99, 66, 56, 66, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, @@ -37,7 +40,7 @@ func MakeTables(q byte) (lqt, cqt []byte) { if q < 50 { factor = 5000 / factor - } else if q > 99 { + } else { factor = 200 - factor*2 } @@ -140,43 +143,69 @@ var chm_ac_symbols = []byte{ func MakeHeaders(p []byte, t byte, w, h uint16, lqt, cqt []byte) []byte { // Appendix A from https://www.rfc-editor.org/rfc/rfc2435 - p = append(p, 0xFF, 0xD8) + p = append(p, 0xFF, markerSOI) p = MakeQuantHeader(p, lqt, 0) p = MakeQuantHeader(p, cqt, 1) if t == 0 { - t = 0x21 + t = 0x21 // hsamp = 2, vsamp = 1 } else { - t = 0x22 + t = 0x22 // hsamp = 2, vsamp = 2 } - p = append(p, - 0xFF, 0xC0, 0, 17, 8, + p = append(p, 0xFF, markerSOF, + 0, 17, // size + 8, // bits per component byte(h>>8), byte(h&0xFF), byte(w>>8), byte(w&0xFF), - 3, 0, t, 0, 1, 0x11, 1, 2, 0x11, 1, + 3, // number of components + 0, // comp 0 + t, + 0, // quant table 0 + 1, // comp 1 + 0x11, // hsamp = 1, vsamp = 1 + 1, // quant table 1 + 2, // comp 2 + 0x11, // hsamp = 1, vsamp = 1 + 1, // quant table 1 ) - p = MakeHuffmanHeader(p, lum_dc_codelens, lum_dc_symbols, 0, 0) - p = MakeHuffmanHeader(p, lum_ac_codelens, lum_ac_symbols, 0, 1) - p = MakeHuffmanHeader(p, chm_dc_codelens, chm_dc_symbols, 1, 0) - p = MakeHuffmanHeader(p, chm_ac_codelens, chm_ac_symbols, 1, 1) + p = MakeHuffmanHeaders(p) - return append(p, 0xFF, 0xDA, 0, 12, 3, 0, 0, 1, 0x11, 2, 0x11, 0, 63, 0) + return append(p, 0xFF, markerSOS, + 0, 12, // size + 3, // 3 components + 0, // comp 0 + 0, // huffman table 0 + 1, // comp 1 + 0x11, // huffman table 1 + 2, // comp 2 + 0x11, // huffman table 1 + 0, // first DCT coeff + 63, // last DCT coeff + 0, // sucessive approx + ) } func MakeQuantHeader(p []byte, qt []byte, tableNo byte) []byte { - p = append(p, 0xFF, 0xDB, 0, 67, tableNo) + p = append(p, 0xFF, markerDQT, 0, 67, tableNo) return append(p, qt...) } func MakeHuffmanHeader(p, codelens, symbols []byte, tableNo, tableClass byte) []byte { - p = append(p, - 0xFF, 0xC4, 0, - byte(3+len(codelens)+len(symbols)), + p = append(p, 0xFF, markerDHT, + 0, byte(3+len(codelens)+len(symbols)), // size (tableClass<<4)|tableNo, ) p = append(p, codelens...) return append(p, symbols...) } + +func MakeHuffmanHeaders(p []byte) []byte { + p = MakeHuffmanHeader(p, lum_dc_codelens, lum_dc_symbols, 0, 0) + p = MakeHuffmanHeader(p, lum_ac_codelens, lum_ac_symbols, 0, 1) + p = MakeHuffmanHeader(p, chm_dc_codelens, chm_dc_symbols, 1, 0) + p = MakeHuffmanHeader(p, chm_ac_codelens, chm_ac_symbols, 1, 1) + return p +} diff --git a/pkg/mp4/consumer.go b/pkg/mp4/consumer.go index 83b2d2e3..34849863 100644 --- a/pkg/mp4/consumer.go +++ b/pkg/mp4/consumer.go @@ -14,7 +14,7 @@ import ( ) type Consumer struct { - core.SuperConsumer + core.Connection wr *core.WriteBuffer muxer *Muxer mu sync.Mutex @@ -47,12 +47,17 @@ func NewConsumer(medias []*core.Media) *Consumer { } } - cons := &Consumer{ + wr := core.NewWriteBuffer(nil) + return &Consumer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "mp4", + Medias: medias, + Transport: wr, + }, muxer: &Muxer{}, - wr: core.NewWriteBuffer(nil), + wr: wr, } - cons.Medias = medias - return cons } func (c *Consumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { @@ -182,8 +187,3 @@ func (c *Consumer) WriteTo(wr io.Writer) (int64, error) { return c.wr.WriteTo(wr) } - -func (c *Consumer) Stop() error { - _ = c.SuperConsumer.Close() - return c.wr.Close() -} diff --git a/pkg/mp4/keyframe.go b/pkg/mp4/keyframe.go index 25a6983d..399f95e7 100644 --- a/pkg/mp4/keyframe.go +++ b/pkg/mp4/keyframe.go @@ -10,11 +10,12 @@ import ( ) type Keyframe struct { - core.SuperConsumer + core.Connection wr *core.WriteBuffer muxer *Muxer } +// Deprecated: should be rewritten func NewKeyframe(medias []*core.Media) *Keyframe { if medias == nil { medias = []*core.Media{ @@ -29,9 +30,15 @@ func NewKeyframe(medias []*core.Media) *Keyframe { } } + wr := core.NewWriteBuffer(nil) cons := &Keyframe{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "mp4", + Transport: wr, + }, muxer: &Muxer{}, - wr: core.NewWriteBuffer(nil), + wr: wr, } cons.Medias = medias return cons @@ -95,8 +102,3 @@ func (c *Keyframe) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv func (c *Keyframe) WriteTo(wr io.Writer) (int64, error) { return c.wr.WriteTo(wr) } - -func (c *Keyframe) Stop() error { - _ = c.SuperConsumer.Close() - return c.wr.Close() -} diff --git a/pkg/mpegts/consumer.go b/pkg/mpegts/consumer.go index eb0902fc..fcb57c74 100644 --- a/pkg/mpegts/consumer.go +++ b/pkg/mpegts/consumer.go @@ -11,17 +11,13 @@ import ( ) type Consumer struct { - core.SuperConsumer + core.Connection muxer *Muxer wr *core.WriteBuffer } func NewConsumer() *Consumer { - c := &Consumer{ - muxer: NewMuxer(), - wr: core.NewWriteBuffer(nil), - } - c.Medias = []*core.Media{ + medias := []*core.Media{ { Kind: core.KindVideo, Direction: core.DirectionSendonly, @@ -38,7 +34,17 @@ func NewConsumer() *Consumer { }, }, } - return c + wr := core.NewWriteBuffer(nil) + return &Consumer{ + core.Connection{ + ID: core.NewID(), + FormatName: "mpegts", + Medias: medias, + Transport: wr, + }, + NewMuxer(), + wr, + } } func (c *Consumer) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { @@ -110,14 +116,9 @@ func (c *Consumer) WriteTo(wr io.Writer) (int64, error) { return c.wr.WriteTo(wr) } -func (c *Consumer) Stop() error { - _ = c.SuperConsumer.Close() - return c.wr.Close() -} - -func TimestampFromRTP(rtp *rtp.Packet, codec *core.Codec) { - if codec.ClockRate == ClockRate { - return - } - rtp.Timestamp = uint32(float64(rtp.Timestamp) / float64(codec.ClockRate) * ClockRate) -} +//func TimestampFromRTP(rtp *rtp.Packet, codec *core.Codec) { +// if codec.ClockRate == ClockRate { +// return +// } +// rtp.Timestamp = uint32(float64(rtp.Timestamp) / float64(codec.ClockRate) * ClockRate) +//} diff --git a/pkg/mpegts/demuxer.go b/pkg/mpegts/demuxer.go index a3efc2c9..08ccca39 100644 --- a/pkg/mpegts/demuxer.go +++ b/pkg/mpegts/demuxer.go @@ -364,7 +364,7 @@ func (p *PES) GetPacket() (pkt *rtp.Packet) { Header: rtp.Header{ PayloadType: p.StreamType, }, - Payload: annexb.EncodeToAVCC(p.Payload, false), + Payload: annexb.EncodeToAVCC(p.Payload), } if p.DTS != 0 { diff --git a/pkg/mpegts/producer.go b/pkg/mpegts/producer.go index 78f320a2..2c72d8aa 100644 --- a/pkg/mpegts/producer.go +++ b/pkg/mpegts/producer.go @@ -13,12 +13,19 @@ import ( ) type Producer struct { - core.SuperProducer + core.Connection rd *core.ReadBuffer } func Open(rd io.Reader) (*Producer, error) { - prod := &Producer{rd: core.NewReadBuffer(rd)} + prod := &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "mpegts", + Transport: rd, + }, + rd: core.NewReadBuffer(rd), + } if err := prod.probe(); err != nil { return nil, err } @@ -26,7 +33,7 @@ func Open(rd io.Reader) (*Producer, error) { } func (c *Producer) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { - receiver, _ := c.SuperProducer.GetTrack(media, codec) + receiver, _ := c.Connection.GetTrack(media, codec) receiver.ID = StreamType(codec) return receiver, nil } @@ -40,6 +47,8 @@ func (c *Producer) Start() error { return err } + c.Recv += len(pkt.Payload) + //log.Printf("[mpegts] size: %6d, muxer: %10d, pt: %2d", len(pkt.Payload), pkt.Timestamp, pkt.PayloadType) for _, receiver := range c.Receivers { @@ -52,11 +61,6 @@ func (c *Producer) Start() error { } } -func (c *Producer) Stop() error { - _ = c.SuperProducer.Close() - return c.rd.Close() -} - func (c *Producer) probe() error { c.rd.BufferSize = core.ProbeSize defer c.rd.Reset() diff --git a/pkg/multipart/multipart.go b/pkg/mpjpeg/multipart.go similarity index 72% rename from pkg/multipart/multipart.go rename to pkg/mpjpeg/multipart.go index aea1b828..ca8924e5 100644 --- a/pkg/multipart/multipart.go +++ b/pkg/mpjpeg/multipart.go @@ -1,4 +1,4 @@ -package multipart +package mpjpeg import ( "bufio" @@ -18,15 +18,21 @@ func Next(rd *bufio.Reader) (http.Header, []byte, error) { return nil, nil, err } - if strings.HasPrefix(s, "--") { - break - } - if s == "\r\n" { continue } - return nil, nil, errors.New("multipart: wrong boundary: " + s) + if !strings.HasPrefix(s, "--") { + return nil, nil, errors.New("multipart: wrong boundary: " + s) + } + + // Foscam G2 has a awful implementation of MJPEG + // https://github.com/AlexxIT/go2rtc/issues/1258 + if b, _ := rd.Peek(2); string(b) == "--" { + continue + } + + break } tp := textproto.NewReader(rd) @@ -50,7 +56,5 @@ func Next(rd *bufio.Reader) (http.Header, []byte, error) { return nil, nil, err } - _, _ = rd.Discard(2) // skip "\r\n" - return http.Header(header), buf, nil } diff --git a/pkg/mpjpeg/producer.go b/pkg/mpjpeg/producer.go new file mode 100644 index 00000000..a8d5e16a --- /dev/null +++ b/pkg/mpjpeg/producer.go @@ -0,0 +1,65 @@ +package mpjpeg + +import ( + "bufio" + "errors" + "io" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + rd *bufio.Reader +} + +func Open(rd io.Reader) (*Producer, error) { + return &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "mpjpeg", // Multipart JPEG + Transport: rd, + Medias: []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: core.CodecJPEG, + ClockRate: 90000, + PayloadType: core.PayloadTypeRAW, + }, + }, + }, + }, + }, + }, nil +} + +func (c *Producer) Start() error { + if len(c.Receivers) != 1 { + return errors.New("mjpeg: no receivers") + } + + rd := bufio.NewReader(c.Transport.(io.Reader)) + + mjpeg := c.Receivers[0] + + for { + _, body, err := Next(rd) + if err != nil { + return err + } + + c.Recv += len(body) + + if mjpeg != nil { + packet := &rtp.Packet{ + Header: rtp.Header{Timestamp: core.Now90000()}, + Payload: body, + } + mjpeg.WriteRTP(packet) + } + } +} diff --git a/pkg/multipart/producer.go b/pkg/multipart/producer.go deleted file mode 100644 index 70a2c547..00000000 --- a/pkg/multipart/producer.go +++ /dev/null @@ -1,68 +0,0 @@ -package multipart - -import ( - "bufio" - "errors" - "io" - - "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/pion/rtp" -) - -type Producer struct { - core.SuperProducer - closer io.Closer - reader *bufio.Reader -} - -func Open(rd io.Reader) (*Producer, error) { - prod := &Producer{ - closer: rd.(io.Closer), - reader: bufio.NewReader(rd), - } - prod.Medias = []*core.Media{ - { - Kind: core.KindVideo, - Direction: core.DirectionRecvonly, - Codecs: []*core.Codec{ - { - Name: core.CodecJPEG, - ClockRate: 90000, - PayloadType: core.PayloadTypeRAW, - }, - }, - }, - } - prod.Type = "Multipart producer" - return prod, nil -} - -func (c *Producer) Start() error { - if len(c.Receivers) != 1 { - return errors.New("mjpeg: no receivers") - } - - mjpeg := c.Receivers[0] - - for { - _, body, err := Next(c.reader) - if err != nil { - return err - } - - c.Recv += len(body) - - if mjpeg != nil { - packet := &rtp.Packet{ - Header: rtp.Header{Timestamp: core.Now90000()}, - Payload: body, - } - mjpeg.WriteRTP(packet) - } - } -} - -func (c *Producer) Stop() error { - _ = c.SuperProducer.Close() - return c.closer.Close() -} diff --git a/pkg/nest/api.go b/pkg/nest/api.go index 5e0d3407..c2f255c2 100644 --- a/pkg/nest/api.go +++ b/pkg/nest/api.go @@ -17,9 +17,15 @@ type API struct { StreamProjectID string StreamDeviceID string - StreamSessionID string StreamExpiresAt time.Time + // WebRTC + StreamSessionID string + + // RTSP + StreamToken string + StreamExtensionToken string + extendTimer *time.Timer } @@ -27,6 +33,12 @@ type Auth struct { AccessToken string } +type DeviceInfo struct { + Name string + DeviceID string + Protocols []string +} + var cache = map[string]*API{} var cacheMu sync.Mutex @@ -53,6 +65,8 @@ func NewAPI(clientID, clientSecret, refreshToken string) (*API, error) { if err != nil { return nil, err } + defer res.Body.Close() + if res.StatusCode != 200 { return nil, errors.New("nest: wrong status: " + res.Status) } @@ -78,7 +92,7 @@ func NewAPI(clientID, clientSecret, refreshToken string) (*API, error) { return api, nil } -func (a *API) GetDevices(projectID string) (map[string]string, error) { +func (a *API) GetDevices(projectID string) ([]DeviceInfo, error) { uri := "https://smartdevicemanagement.googleapis.com/v1/enterprises/" + projectID + "/devices" req, err := http.NewRequest("GET", uri, nil) if err != nil { @@ -92,6 +106,7 @@ func (a *API) GetDevices(projectID string) (map[string]string, error) { if err != nil { return nil, err } + defer res.Body.Close() if res.StatusCode != 200 { return nil, errors.New("nest: wrong status: " + res.Status) @@ -105,24 +120,30 @@ func (a *API) GetDevices(projectID string) (map[string]string, error) { return nil, err } - devices := map[string]string{} + devices := make([]DeviceInfo, 0, len(resv.Devices)) for _, device := range resv.Devices { + // only RTSP and WEB_RTC available (both supported) if len(device.Traits.SdmDevicesTraitsCameraLiveStream.SupportedProtocols) == 0 { continue } - if device.Traits.SdmDevicesTraitsCameraLiveStream.SupportedProtocols[0] != "WEB_RTC" { - continue - } - i := strings.LastIndexByte(device.Name, '/') if i <= 0 { continue } name := device.Traits.SdmDevicesTraitsInfo.CustomName - devices[name] = device.Name[i+1:] + // Devices configured through the Nest app use the container/room name as opposed to the customName trait + if name == "" && len(device.ParentRelations) > 0 { + name = device.ParentRelations[0].DisplayName + } + + devices = append(devices, DeviceInfo{ + Name: name, + DeviceID: device.Name[i+1:], + Protocols: device.Traits.SdmDevicesTraitsCameraLiveStream.SupportedProtocols, + }) } return devices, nil @@ -157,6 +178,7 @@ func (a *API) ExchangeSDP(projectID, deviceID, offer string) (string, error) { if err != nil { return "", err } + defer res.Body.Close() if res.StatusCode != 200 { return "", errors.New("nest: wrong status: " + res.Status) @@ -186,11 +208,137 @@ func (a *API) ExtendStream() error { var reqv struct { Command string `json:"command"` Params struct { - MediaSessionID string `json:"mediaSessionId"` + MediaSessionID string `json:"mediaSessionId,omitempty"` + StreamExtensionToken string `json:"streamExtensionToken,omitempty"` } `json:"params"` } - reqv.Command = "sdm.devices.commands.CameraLiveStream.ExtendWebRtcStream" - reqv.Params.MediaSessionID = a.StreamSessionID + + if a.StreamToken != "" { + // RTSP + reqv.Command = "sdm.devices.commands.CameraLiveStream.ExtendRtspStream" + reqv.Params.StreamExtensionToken = a.StreamExtensionToken + } else { + // WebRTC + reqv.Command = "sdm.devices.commands.CameraLiveStream.ExtendWebRtcStream" + reqv.Params.MediaSessionID = a.StreamSessionID + } + + b, err := json.Marshal(reqv) + if err != nil { + return err + } + + uri := "https://smartdevicemanagement.googleapis.com/v1/enterprises/" + + a.StreamProjectID + "/devices/" + a.StreamDeviceID + ":executeCommand" + req, err := http.NewRequest("POST", uri, bytes.NewReader(b)) + if err != nil { + return err + } + + req.Header.Set("Authorization", "Bearer "+a.Token) + + client := &http.Client{Timeout: time.Second * 5000} + res, err := client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode != 200 { + return errors.New("nest: wrong status: " + res.Status) + } + + var resv struct { + Results struct { + ExpiresAt time.Time `json:"expiresAt"` + MediaSessionID string `json:"mediaSessionId"` + StreamExtensionToken string `json:"streamExtensionToken"` + StreamToken string `json:"streamToken"` + } `json:"results"` + } + + if err = json.NewDecoder(res.Body).Decode(&resv); err != nil { + return err + } + + a.StreamSessionID = resv.Results.MediaSessionID + a.StreamExpiresAt = resv.Results.ExpiresAt + a.StreamExtensionToken = resv.Results.StreamExtensionToken + a.StreamToken = resv.Results.StreamToken + + return nil +} + +func (a *API) GenerateRtspStream(projectID, deviceID string) (string, error) { + var reqv struct { + Command string `json:"command"` + Params struct{} `json:"params"` + } + reqv.Command = "sdm.devices.commands.CameraLiveStream.GenerateRtspStream" + + b, err := json.Marshal(reqv) + if err != nil { + return "", err + } + + uri := "https://smartdevicemanagement.googleapis.com/v1/enterprises/" + + projectID + "/devices/" + deviceID + ":executeCommand" + req, err := http.NewRequest("POST", uri, bytes.NewReader(b)) + if err != nil { + return "", err + } + + req.Header.Set("Authorization", "Bearer "+a.Token) + + client := &http.Client{Timeout: time.Second * 5000} + res, err := client.Do(req) + if err != nil { + return "", err + } + + if res.StatusCode != 200 { + return "", errors.New("nest: wrong status: " + res.Status) + } + + var resv struct { + Results struct { + StreamURLs map[string]string `json:"streamUrls"` + StreamExtensionToken string `json:"streamExtensionToken"` + StreamToken string `json:"streamToken"` + ExpiresAt time.Time `json:"expiresAt"` + } `json:"results"` + } + + if err = json.NewDecoder(res.Body).Decode(&resv); err != nil { + return "", err + } + + if _, ok := resv.Results.StreamURLs["rtspUrl"]; !ok { + return "", errors.New("nest: failed to generate rtsp url") + } + + a.StreamProjectID = projectID + a.StreamDeviceID = deviceID + a.StreamToken = resv.Results.StreamToken + a.StreamExtensionToken = resv.Results.StreamExtensionToken + a.StreamExpiresAt = resv.Results.ExpiresAt + + return resv.Results.StreamURLs["rtspUrl"], nil +} + +func (a *API) StopRTSPStream() error { + if a.StreamProjectID == "" || a.StreamDeviceID == "" { + return errors.New("nest: tried to stop rtsp stream without a project or device ID") + } + + var reqv struct { + Command string `json:"command"` + Params struct { + StreamExtensionToken string `json:"streamExtensionToken"` + } `json:"params"` + } + reqv.Command = "sdm.devices.commands.CameraLiveStream.StopRtspStream" + reqv.Params.StreamExtensionToken = a.StreamExtensionToken b, err := json.Marshal(reqv) if err != nil { @@ -216,19 +364,10 @@ func (a *API) ExtendStream() error { return errors.New("nest: wrong status: " + res.Status) } - var resv struct { - Results struct { - ExpiresAt time.Time `json:"expiresAt"` - MediaSessionID string `json:"mediaSessionId"` - } `json:"results"` - } - - if err = json.NewDecoder(res.Body).Decode(&resv); err != nil { - return err - } - - a.StreamSessionID = resv.Results.MediaSessionID - a.StreamExpiresAt = resv.Results.ExpiresAt + a.StreamProjectID = "" + a.StreamDeviceID = "" + a.StreamExtensionToken = "" + a.StreamToken = "" return nil } @@ -261,10 +400,10 @@ type Device struct { //SdmDevicesTraitsCameraClipPreview struct { //} `json:"sdm.devices.traits.CameraClipPreview"` } `json:"traits"` - //ParentRelations []struct { - // Parent string `json:"parent"` - // DisplayName string `json:"displayName"` - //} `json:"parentRelations"` + ParentRelations []struct { + Parent string `json:"parent"` + DisplayName string `json:"displayName"` + } `json:"parentRelations"` } func (a *API) StartExtendStreamTimer() { @@ -277,7 +416,6 @@ func (a *API) StartExtendStreamTimer() { duration = time.Until(a.StreamExpiresAt.Add(-30 * time.Second)) a.extendTimer.Reset(duration) }) - } func (a *API) StopExtendStreamTimer() { diff --git a/pkg/nest/client.go b/pkg/nest/client.go index cb73cc98..93c4ce64 100644 --- a/pkg/nest/client.go +++ b/pkg/nest/client.go @@ -3,18 +3,25 @@ package nest import ( "errors" "net/url" + "strings" "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/rtsp" "github.com/AlexxIT/go2rtc/pkg/webrtc" pion "github.com/pion/webrtc/v3" ) -type Client struct { +type WebRTCClient struct { conn *webrtc.Conn api *API } -func NewClient(rawURL string) (*Client, error) { +type RTSPClient struct { + conn *rtsp.Conn + api *API +} + +func Dial(rawURL string) (core.Producer, error) { u, err := url.Parse(rawURL) if err != nil { return nil, err @@ -36,6 +43,42 @@ func NewClient(rawURL string) (*Client, error) { return nil, err } + protocols := strings.Split(query.Get("protocols"), ",") + if len(protocols) > 0 && protocols[0] == "RTSP" { + return rtspConn(nestAPI, rawURL, projectID, deviceID) + } + + // Default to WEB_RTC for backwards compataiility + return rtcConn(nestAPI, rawURL, projectID, deviceID) +} + +func (c *WebRTCClient) GetMedias() []*core.Media { + return c.conn.GetMedias() +} + +func (c *WebRTCClient) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return c.conn.GetTrack(media, codec) +} + +func (c *WebRTCClient) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + return c.conn.AddTrack(media, codec, track) +} + +func (c *WebRTCClient) Start() error { + c.api.StartExtendStreamTimer() + return c.conn.Start() +} + +func (c *WebRTCClient) Stop() error { + c.api.StopExtendStreamTimer() + return c.conn.Stop() +} + +func (c *WebRTCClient) MarshalJSON() ([]byte, error) { + return c.conn.MarshalJSON() +} + +func rtcConn(nestAPI *API, rawURL, projectID, deviceID string) (*WebRTCClient, error) { rtcAPI, err := webrtc.NewAPI() if err != nil { return nil, err @@ -48,8 +91,10 @@ func NewClient(rawURL string) (*Client, error) { } conn := webrtc.NewConn(pc) - conn.Desc = "Nest" + conn.FormatName = "nest/webrtc" conn.Mode = core.ModeActiveProducer + conn.Protocol = "http" + conn.URL = rawURL // https://developers.google.com/nest/device-access/traits/device/camera-live-stream#generatewebrtcstream-request-fields medias := []*core.Media{ @@ -75,31 +120,46 @@ func NewClient(rawURL string) (*Client, error) { return nil, err } - return &Client{conn: conn, api: nestAPI}, nil + return &WebRTCClient{conn: conn, api: nestAPI}, nil } -func (c *Client) GetMedias() []*core.Media { - return c.conn.GetMedias() +func rtspConn(nestAPI *API, rawURL, projectID, deviceID string) (*RTSPClient, error) { + rtspURL, err := nestAPI.GenerateRtspStream(projectID, deviceID) + if err != nil { + return nil, err + } + + rtspClient := rtsp.NewClient(rtspURL) + if err := rtspClient.Dial(); err != nil { + return nil, err + } + if err := rtspClient.Describe(); err != nil { + return nil, err + } + + return &RTSPClient{conn: rtspClient, api: nestAPI}, nil } -func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { +func (c *RTSPClient) GetMedias() []*core.Media { + result := c.conn.GetMedias() + return result +} + +func (c *RTSPClient) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { return c.conn.GetTrack(media, codec) } -func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { - return c.conn.AddTrack(media, codec, track) -} - -func (c *Client) Start() error { +func (c *RTSPClient) Start() error { c.api.StartExtendStreamTimer() return c.conn.Start() } -func (c *Client) Stop() error { +func (c *RTSPClient) Stop() error { + c.api.StopRTSPStream() c.api.StopExtendStreamTimer() return c.conn.Stop() } -func (c *Client) MarshalJSON() ([]byte, error) { +func (c *RTSPClient) MarshalJSON() ([]byte, error) { return c.conn.MarshalJSON() } diff --git a/pkg/onvif/README.md b/pkg/onvif/README.md new file mode 100644 index 00000000..73267379 --- /dev/null +++ b/pkg/onvif/README.md @@ -0,0 +1,38 @@ +## Profiles + +- Profile A - For access control configuration +- Profile C - For door control and event management +- Profile S - For basic video streaming + - Video streaming and configuration +- Profile T - For advanced video streaming + - H.264 / H.265 video compression + - Imaging settings + - Motion alarm and tampering events + - Metadata streaming + - Bi-directional audio + +## Services + +https://www.onvif.org/profiles/specifications/ + +- https://www.onvif.org/ver10/device/wsdl/devicemgmt.wsdl +- https://www.onvif.org/ver20/imaging/wsdl/imaging.wsdl +- https://www.onvif.org/ver10/media/wsdl/media.wsdl + +## TMP + +| | Dahua | Reolink | TP-Link | +|------------------------|---------|---------|---------| +| GetCapabilities | no auth | no auth | no auth | +| GetServices | no auth | no auth | no auth | +| GetServiceCapabilities | no auth | no auth | auth | +| GetSystemDateAndTime | no auth | no auth | no auth | +| GetNetworkInterfaces | auth | auth | auth | +| GetDeviceInformation | auth | auth | auth | +| GetProfiles | auth | auth | auth | +| GetScopes | auth | auth | auth | + +- Dahua - onvif://192.168.10.90:80 +- Reolink - onvif://192.168.10.92:8000 +- TP-Link - onvif://192.168.10.91:2020/onvif/device_service +- \ No newline at end of file diff --git a/pkg/onvif/client.go b/pkg/onvif/client.go index 97bfd8dc..cb6221e1 100644 --- a/pkg/onvif/client.go +++ b/pkg/onvif/client.go @@ -2,8 +2,6 @@ package onvif import ( "bytes" - "crypto/sha1" - "encoding/base64" "errors" "html" "io" @@ -12,8 +10,6 @@ import ( "regexp" "strings" "time" - - "github.com/AlexxIT/go2rtc/pkg/core" ) const PathDevice = "/onvif/device_service" @@ -41,7 +37,7 @@ func NewClient(rawURL string) (*Client, error) { client.deviceURL = baseURL + u.Path } - b, err := client.GetCapabilities() + b, err := client.DeviceRequest(DeviceGetCapabilities) if err != nil { return nil, err } @@ -95,7 +91,7 @@ func (c *Client) GetURI() (string, error) { } func (c *Client) GetName() (string, error) { - b, err := c.GetDeviceInformation() + b, err := c.DeviceRequest(DeviceGetDeviceInformation) if err != nil { return "", err } @@ -104,7 +100,7 @@ func (c *Client) GetName() (string, error) { } func (c *Client) GetProfilesTokens() ([]string, error) { - b, err := c.GetProfiles() + b, err := c.MediaRequest(MediaGetProfiles) if err != nil { return nil, err } @@ -127,86 +123,53 @@ func (c *Client) HasSnapshots() bool { return strings.Contains(string(b), `SnapshotUri="true"`) } -func (c *Client) GetCapabilities() ([]byte, error) { +func (c *Client) GetProfile(token string) ([]byte, error) { return c.Request( - c.deviceURL, - ` - All -`, + c.mediaURL, ``+token+``, ) } -func (c *Client) GetNetworkInterfaces() ([]byte, error) { - return c.Request( - c.deviceURL, ``, - ) -} - -func (c *Client) GetDeviceInformation() ([]byte, error) { - return c.Request( - c.deviceURL, ``, - ) -} - -func (c *Client) GetProfiles() ([]byte, error) { - return c.Request( - c.mediaURL, ``, - ) +func (c *Client) GetVideoSourceConfiguration(token string) ([]byte, error) { + return c.Request(c.mediaURL, ` + `+token+` +`) } func (c *Client) GetStreamUri(token string) ([]byte, error) { - return c.Request( - c.mediaURL, - ` + return c.Request(c.mediaURL, ` RTP-Unicast RTSP `+token+` -`, - ) +`) } func (c *Client) GetSnapshotUri(token string) ([]byte, error) { return c.Request( - c.imaginURL, - ` - `+token+` -`, - ) -} - -func (c *Client) GetSystemDateAndTime() ([]byte, error) { - return c.Request( - c.deviceURL, ``, + c.imaginURL, ``+token+``, ) } func (c *Client) GetServiceCapabilities() ([]byte, error) { // some cameras answer GetServiceCapabilities for media only for path = "/onvif/media" return c.Request( - c.mediaURL, ``, + c.mediaURL, ``, ) } -func (c *Client) SystemReboot() ([]byte, error) { - return c.Request( - c.deviceURL, ``, - ) +func (c *Client) DeviceRequest(operation string) ([]byte, error) { + if operation == DeviceGetServices { + operation = `true` + } else { + operation = `` + } + return c.Request(c.deviceURL, operation) } -func (c *Client) GetServices() ([]byte, error) { - return c.Request( - c.deviceURL, ` - true -`, - ) -} - -func (c *Client) GetScopes() ([]byte, error) { - return c.Request( - c.deviceURL, ``, - ) +func (c *Client) MediaRequest(operation string) ([]byte, error) { + operation = `` + return c.Request(c.mediaURL, operation) } func (c *Client) Request(url, body string) ([]byte, error) { @@ -214,35 +177,11 @@ func (c *Client) Request(url, body string) ([]byte, error) { return nil, errors.New("onvif: unsupported service") } - buf := bytes.NewBuffer(nil) - buf.WriteString( - ``, - ) - - if user := c.url.User; user != nil { - nonce := core.RandString(16, 36) - created := time.Now().UTC().Format(time.RFC3339Nano) - pass, _ := user.Password() - - h := sha1.New() - h.Write([]byte(nonce + created + pass)) - - buf.WriteString(` - - -` + user.Username() + ` -` + base64.StdEncoding.EncodeToString(h.Sum(nil)) + ` -` + base64.StdEncoding.EncodeToString([]byte(nonce)) + ` -` + created + ` - - -`) - } - - buf.WriteString(`` + body + ``) + e := NewEnvelopeWithUser(c.url.User) + e.Append(body) client := &http.Client{Timeout: time.Second * 5000} - res, err := client.Post(url, `application/soap+xml;charset=utf-8`, buf) + res, err := client.Post(url, `application/soap+xml;charset=utf-8`, bytes.NewReader(e.Bytes())) if err != nil { return nil, err } diff --git a/pkg/onvif/envelope.go b/pkg/onvif/envelope.go new file mode 100644 index 00000000..f0e1b29c --- /dev/null +++ b/pkg/onvif/envelope.go @@ -0,0 +1,79 @@ +package onvif + +import ( + "crypto/sha1" + "encoding/base64" + "fmt" + "net/url" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" +) + +type Envelope struct { + buf []byte +} + +const ( + prefix1 = ` + +` + prefix2 = ` +` + suffix = ` + +` +) + +func NewEnvelope() *Envelope { + e := &Envelope{buf: make([]byte, 0, 1024)} + e.Append(prefix1, prefix2) + return e +} + +func NewEnvelopeWithUser(user *url.Userinfo) *Envelope { + if user == nil { + return NewEnvelope() + } + + nonce := core.RandString(16, 36) + created := time.Now().UTC().Format(time.RFC3339Nano) + pass, _ := user.Password() + + h := sha1.New() + h.Write([]byte(nonce + created + pass)) + + e := &Envelope{buf: make([]byte, 0, 1024)} + e.Append(prefix1) + e.Appendf(` + + + %s + %s + %s + %s + + + +`, + user.Username(), + base64.StdEncoding.EncodeToString(h.Sum(nil)), + base64.StdEncoding.EncodeToString([]byte(nonce)), + created) + e.Append(prefix2) + return e +} + +func (e *Envelope) Append(args ...string) { + for _, s := range args { + e.buf = append(e.buf, s...) + } +} + +func (e *Envelope) Appendf(format string, args ...any) { + e.buf = fmt.Appendf(e.buf, format, args...) +} + +func (e *Envelope) Bytes() []byte { + return append(e.buf, suffix...) +} diff --git a/pkg/onvif/helpers.go b/pkg/onvif/helpers.go index fc9c8392..f240f2ec 100644 --- a/pkg/onvif/helpers.go +++ b/pkg/onvif/helpers.go @@ -1,6 +1,7 @@ package onvif import ( + "fmt" "net" "regexp" "strconv" @@ -11,7 +12,7 @@ import ( ) func FindTagValue(b []byte, tag string) string { - re := regexp.MustCompile(`(?s)[:<]` + tag + `>([^<]+)`) + re := regexp.MustCompile(`(?s)<(?:\w+:)?` + tag + `\b[^>]*>([^<]+)`) m := re.FindSubmatch(b) if len(m) != 2 { return "" @@ -106,3 +107,25 @@ func atoi(s string) int { } return i } + +func GetPosixTZ(current time.Time) string { + // Thanks to https://github.com/Path-Variable/go-posix-time + _, offset := current.Zone() + + if current.IsDST() { + _, end := current.ZoneBounds() + endPlus1 := end.Add(time.Hour * 25) + _, offset = endPlus1.Zone() + } + + var prefix string + if offset < 0 { + prefix = "GMT+" + offset = -offset / 60 + } else { + prefix = "GMT-" + offset = offset / 60 + } + + return prefix + fmt.Sprintf("%02d:%02d", offset/60, offset%60) +} diff --git a/pkg/onvif/onvif_test.go b/pkg/onvif/onvif_test.go index cd57d60b..e9ffab04 100644 --- a/pkg/onvif/onvif_test.go +++ b/pkg/onvif/onvif_test.go @@ -84,6 +84,34 @@ func TestGetStreamUri(t *testing.T) { `, url: "rtsp://192.168.5.53:8090/profile1=r", }, + { + name: "go2rtc 1.9.4", + xml: ` + + + + rtsp://192.168.1.123:8554/rtsp-dahua1 + + + +`, + url: "rtsp://192.168.1.123:8554/rtsp-dahua1", + }, + { + name: "go2rtc 1.9.8", + xml: ` + + + + + rtsp://192.168.1.123:8554/rtsp-dahua2 + + + + +`, + url: "rtsp://192.168.1.123:8554/rtsp-dahua2", + }, } for _, test := range tests { diff --git a/pkg/onvif/server.go b/pkg/onvif/server.go index f8f2883c..db0bb2fb 100644 --- a/pkg/onvif/server.go +++ b/pkg/onvif/server.go @@ -2,30 +2,40 @@ package onvif import ( "bytes" - "fmt" "regexp" - "strconv" "time" ) -const ( - ActionGetCapabilities = "GetCapabilities" - ActionGetSystemDateAndTime = "GetSystemDateAndTime" - ActionGetNetworkInterfaces = "GetNetworkInterfaces" - ActionGetDeviceInformation = "GetDeviceInformation" - ActionGetServiceCapabilities = "GetServiceCapabilities" - ActionGetProfiles = "GetProfiles" - ActionGetStreamUri = "GetStreamUri" - ActionSystemReboot = "SystemReboot" +const ServiceGetServiceCapabilities = "GetServiceCapabilities" - ActionGetServices = "GetServices" - ActionGetScopes = "GetScopes" - ActionGetVideoSources = "GetVideoSources" - ActionGetAudioSources = "GetAudioSources" - ActionGetVideoSourceConfigurations = "GetVideoSourceConfigurations" - ActionGetAudioSourceConfigurations = "GetAudioSourceConfigurations" - ActionGetVideoEncoderConfigurations = "GetVideoEncoderConfigurations" - ActionGetAudioEncoderConfigurations = "GetAudioEncoderConfigurations" +const ( + DeviceGetCapabilities = "GetCapabilities" + DeviceGetDeviceInformation = "GetDeviceInformation" + DeviceGetDiscoveryMode = "GetDiscoveryMode" + DeviceGetDNS = "GetDNS" + DeviceGetHostname = "GetHostname" + DeviceGetNetworkDefaultGateway = "GetNetworkDefaultGateway" + DeviceGetNetworkInterfaces = "GetNetworkInterfaces" + DeviceGetNetworkProtocols = "GetNetworkProtocols" + DeviceGetNTP = "GetNTP" + DeviceGetScopes = "GetScopes" + DeviceGetServices = "GetServices" + DeviceGetSystemDateAndTime = "GetSystemDateAndTime" + DeviceSystemReboot = "SystemReboot" +) + +const ( + MediaGetAudioEncoderConfigurations = "GetAudioEncoderConfigurations" + MediaGetAudioSources = "GetAudioSources" + MediaGetAudioSourceConfigurations = "GetAudioSourceConfigurations" + MediaGetProfile = "GetProfile" + MediaGetProfiles = "GetProfiles" + MediaGetSnapshotUri = "GetSnapshotUri" + MediaGetStreamUri = "GetStreamUri" + MediaGetVideoEncoderConfigurations = "GetVideoEncoderConfigurations" + MediaGetVideoSources = "GetVideoSources" + MediaGetVideoSourceConfiguration = "GetVideoSourceConfiguration" + MediaGetVideoSourceConfigurations = "GetVideoSourceConfigurations" ) func GetRequestAction(b []byte) string { @@ -42,163 +52,201 @@ func GetRequestAction(b []byte) string { return string(m[1]) } -func GetCapabilitiesResponse(host string) string { - return ` - - - - - - http://` + host + `/onvif/device_service - - - http://` + host + `/onvif/media_service - - false - false - true - - - - - -` +func GetCapabilitiesResponse(host string) []byte { + e := NewEnvelope() + e.Append(` + + + http://`, host, `/onvif/device_service + + + http://`, host, `/onvif/media_service + + false + false + true + + + +`) + return e.Bytes() } -func GetSystemDateAndTimeResponse() string { +func GetServicesResponse(host string) []byte { + e := NewEnvelope() + e.Append(` + + http://www.onvif.org/ver10/device/wsdl + http://`, host, `/onvif/device_service + 25 + + + http://www.onvif.org/ver10/media/wsdl + http://`, host, `/onvif/media_service + 25 + +`) + return e.Bytes() +} + +func GetSystemDateAndTimeResponse() []byte { loc := time.Now() utc := loc.UTC() - return fmt.Sprintf(` - - - - - NTP - false - - GMT%s - - - - %d - %d - %d - - - %d - %d - %d - - - - - %d - %d - %d - - - %d - %d - %d - - - - - -`, - loc.Format("-07:00"), + e := NewEnvelope() + e.Appendf(` + + NTP + true + + %s + + + %d%d%d + %d%d%d + + + %d%d%d + %d%d%d + + +`, + GetPosixTZ(loc), utc.Hour(), utc.Minute(), utc.Second(), utc.Year(), utc.Month(), utc.Day(), loc.Hour(), loc.Minute(), loc.Second(), loc.Year(), loc.Month(), loc.Day(), ) + return e.Bytes() } -func GetNetworkInterfacesResponse() string { - return ` - - - - -` +func GetDeviceInformationResponse(manuf, model, firmware, serial string) []byte { + e := NewEnvelope() + e.Append(` + `, manuf, ` + `, model, ` + `, firmware, ` + `, serial, ` + 1.00 +`) + return e.Bytes() } -func GetDeviceInformationResponse(manuf, model, firmware, serial string) string { - return ` - - - - ` + manuf + ` - ` + model + ` - ` + firmware + ` - ` + serial + ` - 1.00 - - -` +func GetMediaServiceCapabilitiesResponse() []byte { + e := NewEnvelope() + e.Append(` + + + +`) + return e.Bytes() } -func GetServiceCapabilitiesResponse() string { - return ` - - - - - - - - -` +func GetProfilesResponse(names []string) []byte { + e := NewEnvelope() + e.Append(` +`) + for _, name := range names { + appendProfile(e, "Profiles", name) + } + e.Append(``) + return e.Bytes() } -func SystemRebootResponse() string { - return ` - - - - system reboot in 1 second... - - -` +func GetProfileResponse(name string) []byte { + e := NewEnvelope() + e.Append(` +`) + appendProfile(e, "Profile", name) + e.Append(``) + return e.Bytes() } -func GetProfilesResponse(names []string) string { - buf := bytes.NewBuffer(nil) - buf.WriteString(` - - - `) +func appendProfile(e *Envelope, tag, name string) { + // empty `RateControl` important for UniFi Protect + e.Append(` + `, name, ` + + VSC + `, name, ` + + + + VEC + H264 + 19201080 + + + +`) +} - for i, name := range names { - buf.WriteString(` - - ` + name + ` - - H264 - - 1920 - 1080 - - - `) +func GetVideoSourceConfigurationResponse(name string) []byte { + e := NewEnvelope() + e.Append(` + + VSC + `, name, ` + + +`) + return e.Bytes() +} + +func GetVideoSourcesResponse(names []string) []byte { + e := NewEnvelope() + e.Append(` +`) + for _, name := range names { + e.Append(` + 30.000000 + 19201080 + +`) + } + e.Append(``) + return e.Bytes() +} + +func GetStreamUriResponse(uri string) []byte { + e := NewEnvelope() + e.Append(``, uri, ``) + return e.Bytes() +} + +func GetSnapshotUriResponse(uri string) []byte { + e := NewEnvelope() + e.Append(``, uri, ``) + return e.Bytes() +} + +func StaticResponse(operation string) []byte { + switch operation { + case DeviceGetSystemDateAndTime: + return GetSystemDateAndTimeResponse() } - buf.WriteString(` - - -`) - - return buf.String() + e := NewEnvelope() + e.Append(responses[operation]) + b := e.Bytes() + if operation == DeviceGetNetworkInterfaces { + println() + } + return b } -func GetStreamUriResponse(uri string) string { - return ` - - - - - ` + uri + ` - - - -` +var responses = map[string]string{ + DeviceGetDiscoveryMode: `Discoverable`, + DeviceGetDNS: ``, + DeviceGetHostname: ``, + DeviceGetNetworkDefaultGateway: ``, + DeviceGetNTP: ``, + DeviceSystemReboot: `OK`, + + DeviceGetNetworkInterfaces: ``, + DeviceGetNetworkProtocols: ``, + DeviceGetScopes: ` + Fixedonvif://www.onvif.org/name/go2rtc + Fixedonvif://www.onvif.org/location/github + Fixedonvif://www.onvif.org/Profile/Streaming + Fixedonvif://www.onvif.org/type/Network_Video_Transmitter +`, } diff --git a/pkg/opus/opus.go b/pkg/opus/.opus.go similarity index 97% rename from pkg/opus/opus.go rename to pkg/opus/.opus.go index 9fe1d8b6..42043977 100644 --- a/pkg/opus/opus.go +++ b/pkg/opus/.opus.go @@ -5,7 +5,6 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/pion/rtp" - "github.com/rs/zerolog/log" ) func Log(handler core.HandlerFunc) core.HandlerFunc { diff --git a/pkg/pcm/producer.go b/pkg/pcm/producer.go new file mode 100644 index 00000000..8a957f6d --- /dev/null +++ b/pkg/pcm/producer.go @@ -0,0 +1,55 @@ +package pcm + +import ( + "io" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + rd io.Reader +} + +func Open(rd io.Reader) (*Producer, error) { + medias := []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + {Name: core.CodecPCMU, ClockRate: 8000}, + }, + }, + } + return &Producer{ + core.Connection{ + ID: core.NewID(), + FormatName: "pcm", + Medias: medias, + Transport: rd, + }, + rd, + }, nil +} + +func (c *Producer) Start() error { + for { + payload := make([]byte, 1024) + if _, err := io.ReadFull(c.rd, payload); err != nil { + return err + } + + c.Recv += 1024 + + if len(c.Receivers) == 0 { + continue + } + + pkt := &rtp.Packet{ + Header: rtp.Header{Timestamp: core.Now90000()}, + Payload: payload, + } + c.Receivers[0].WriteRTP(pkt) + } +} diff --git a/pkg/probe/probe.go b/pkg/probe/producer.go similarity index 72% rename from pkg/probe/probe.go rename to pkg/probe/producer.go index 61a2b361..1fbd3efb 100644 --- a/pkg/probe/probe.go +++ b/pkg/probe/producer.go @@ -8,17 +8,11 @@ import ( ) type Probe struct { - Type string `json:"type,omitempty"` - RemoteAddr string `json:"remote_addr,omitempty"` - UserAgent string `json:"user_agent,omitempty"` - Medias []*core.Media `json:"medias,omitempty"` - Receivers []*core.Receiver `json:"receivers,omitempty"` - Senders []*core.Sender `json:"senders,omitempty"` + core.Connection } func NewProbe(query url.Values) *Probe { - c := &Probe{Type: "probe"} - c.Medias = core.ParseQuery(query) + medias := core.ParseQuery(query) for _, value := range query["microphone"] { media := &core.Media{Kind: core.KindAudio, Direction: core.DirectionRecvonly} @@ -32,10 +26,16 @@ func NewProbe(query url.Values) *Probe { media.Codecs = append(media.Codecs, &core.Codec{Name: name}) } - c.Medias = append(c.Medias, media) + medias = append(medias, media) } - return c + return &Probe{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "probe", + Medias: medias, + }, + } } func (p *Probe) GetMedias() []*core.Media { diff --git a/pkg/ring/api.go b/pkg/ring/api.go new file mode 100644 index 00000000..ed69465f --- /dev/null +++ b/pkg/ring/api.go @@ -0,0 +1,545 @@ +package ring + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "reflect" + "strings" + "time" +) + +type RefreshTokenAuth struct { + RefreshToken string +} + +type EmailAuth struct { + Email string + Password string +} + +// AuthConfig represents the decoded refresh token data +type AuthConfig struct { + RT string `json:"rt"` // Refresh Token + HID string `json:"hid"` // Hardware ID +} + +// AuthTokenResponse represents the response from the authentication endpoint +type AuthTokenResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` // Always "client" + TokenType string `json:"token_type"` // Always "Bearer" +} + +type Auth2faResponse struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + TSVState string `json:"tsv_state"` + Phone string `json:"phone"` + NextTimeInSecs int `json:"next_time_in_secs"` +} + +// SocketTicketRequest represents the request to get a socket ticket +type SocketTicketResponse struct { + Ticket string `json:"ticket"` + ResponseTimestamp int64 `json:"response_timestamp"` +} + +// RingRestClient handles authentication and requests to Ring API +type RingRestClient struct { + httpClient *http.Client + authConfig *AuthConfig + hardwareID string + authToken *AuthTokenResponse + Using2FA bool + PromptFor2FA string + RefreshToken string + auth interface{} // EmailAuth or RefreshTokenAuth + onTokenRefresh func(string) +} + +// CameraKind represents the different types of Ring cameras +type CameraKind string + +// CameraData contains common fields for all camera types +type CameraData struct { + ID float64 `json:"id"` + Description string `json:"description"` + DeviceID string `json:"device_id"` + Kind string `json:"kind"` + LocationID string `json:"location_id"` +} + +// RingDeviceType represents different types of Ring devices +type RingDeviceType string + +// RingDevicesResponse represents the response from the Ring API +type RingDevicesResponse struct { + Doorbots []CameraData `json:"doorbots"` + AuthorizedDoorbots []CameraData `json:"authorized_doorbots"` + StickupCams []CameraData `json:"stickup_cams"` + AllCameras []CameraData `json:"all_cameras"` + Chimes []CameraData `json:"chimes"` + Other []map[string]interface{} `json:"other"` +} + +const ( + Doorbot CameraKind = "doorbot" + Doorbell CameraKind = "doorbell" + DoorbellV3 CameraKind = "doorbell_v3" + DoorbellV4 CameraKind = "doorbell_v4" + DoorbellV5 CameraKind = "doorbell_v5" + DoorbellOyster CameraKind = "doorbell_oyster" + DoorbellPortal CameraKind = "doorbell_portal" + DoorbellScallop CameraKind = "doorbell_scallop" + DoorbellScallopLite CameraKind = "doorbell_scallop_lite" + DoorbellGraham CameraKind = "doorbell_graham_cracker" + LpdV1 CameraKind = "lpd_v1" + LpdV2 CameraKind = "lpd_v2" + LpdV4 CameraKind = "lpd_v4" + JboxV1 CameraKind = "jbox_v1" + StickupCam CameraKind = "stickup_cam" + StickupCamV3 CameraKind = "stickup_cam_v3" + StickupCamElite CameraKind = "stickup_cam_elite" + StickupCamLongfin CameraKind = "stickup_cam_longfin" + StickupCamLunar CameraKind = "stickup_cam_lunar" + SpotlightV2 CameraKind = "spotlightw_v2" + HpCamV1 CameraKind = "hp_cam_v1" + HpCamV2 CameraKind = "hp_cam_v2" + StickupCamV4 CameraKind = "stickup_cam_v4" + FloodlightV1 CameraKind = "floodlight_v1" + FloodlightV2 CameraKind = "floodlight_v2" + FloodlightPro CameraKind = "floodlight_pro" + CocoaCamera CameraKind = "cocoa_camera" + CocoaDoorbell CameraKind = "cocoa_doorbell" + CocoaFloodlight CameraKind = "cocoa_floodlight" + CocoaSpotlight CameraKind = "cocoa_spotlight" + StickupCamMini CameraKind = "stickup_cam_mini" + OnvifCamera CameraKind = "onvif_camera" +) + +const ( + IntercomHandsetAudio RingDeviceType = "intercom_handset_audio" + OnvifCameraType RingDeviceType = "onvif_camera" +) + +const ( + clientAPIBaseURL = "https://api.ring.com/clients_api/" + deviceAPIBaseURL = "https://api.ring.com/devices/v1/" + commandsAPIBaseURL = "https://api.ring.com/commands/v1/" + appAPIBaseURL = "https://prd-api-us.prd.rings.solutions/api/v1/" + oauthURL = "https://oauth.ring.com/oauth/token" + apiVersion = 11 + defaultTimeout = 20 * time.Second + maxRetries = 3 +) + +// NewRingRestClient creates a new Ring client instance +func NewRingRestClient(auth interface{}, onTokenRefresh func(string)) (*RingRestClient, error) { + client := &RingRestClient{ + httpClient: &http.Client{Timeout: defaultTimeout}, + onTokenRefresh: onTokenRefresh, + hardwareID: generateHardwareID(), + auth: auth, + } + + switch a := auth.(type) { + case RefreshTokenAuth: + if a.RefreshToken == "" { + return nil, fmt.Errorf("refresh token is required") + } + + config, err := parseAuthConfig(a.RefreshToken) + if err != nil { + return nil, fmt.Errorf("failed to parse refresh token: %w", err) + } + + client.authConfig = config + client.hardwareID = config.HID + client.RefreshToken = a.RefreshToken + case EmailAuth: + if a.Email == "" || a.Password == "" { + return nil, fmt.Errorf("email and password are required") + } + default: + return nil, fmt.Errorf("invalid auth type") + } + + return client, nil +} + +// Request makes an authenticated request to the Ring API +func (c *RingRestClient) Request(method, url string, body interface{}) ([]byte, error) { + // Ensure we have a valid auth token + if err := c.ensureAuth(); err != nil { + return nil, fmt.Errorf("authentication failed: %w", err) + } + + var bodyReader io.Reader + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + bodyReader = bytes.NewReader(jsonBody) + } + + // Create request + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Set headers + req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("hardware_id", c.hardwareID) + req.Header.Set("User-Agent", "android:com.ringapp") + + // Make request with retries + var resp *http.Response + var responseBody []byte + + for attempt := 0; attempt <= maxRetries; attempt++ { + resp, err = c.httpClient.Do(req) + if err != nil { + if attempt == maxRetries { + return nil, fmt.Errorf("request failed after %d retries: %w", maxRetries, err) + } + time.Sleep(5 * time.Second) + continue + } + defer resp.Body.Close() + + responseBody, err = io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + // Handle 401 by refreshing auth and retrying + if resp.StatusCode == http.StatusUnauthorized { + c.authToken = nil // Force token refresh + if attempt == maxRetries { + return nil, fmt.Errorf("authentication failed after %d retries", maxRetries) + } + if err := c.ensureAuth(); err != nil { + return nil, fmt.Errorf("failed to refresh authentication: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.authToken.AccessToken) + continue + } + + // Handle other error status codes + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(responseBody)) + } + + break + } + + return responseBody, nil +} + +// ensureAuth ensures we have a valid auth token +func (c *RingRestClient) ensureAuth() error { + if c.authToken != nil { + return nil + } + + var grantData = map[string]string{ + "grant_type": "refresh_token", + "refresh_token": c.authConfig.RT, + } + + // Add common fields + grantData["client_id"] = "ring_official_android" + grantData["scope"] = "client" + + // Make auth request + body, err := json.Marshal(grantData) + if err != nil { + return fmt.Errorf("failed to marshal auth request: %w", err) + } + + req, err := http.NewRequest("POST", oauthURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create auth request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("hardware_id", c.hardwareID) + req.Header.Set("User-Agent", "android:com.ringapp") + req.Header.Set("2fa-support", "true") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("auth request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusPreconditionFailed { + return fmt.Errorf("2FA required. Please see documentation for handling 2FA") + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var authResp AuthTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil { + return fmt.Errorf("failed to decode auth response: %w", err) + } + + // Update auth config and refresh token + c.authToken = &authResp + c.authConfig = &AuthConfig{ + RT: authResp.RefreshToken, + HID: c.hardwareID, + } + + // Encode and notify about new refresh token + if c.onTokenRefresh != nil { + newRefreshToken := encodeAuthConfig(c.authConfig) + c.onTokenRefresh(newRefreshToken) + } + + return nil +} + +// getAuth makes an authentication request to the Ring API +func (c *RingRestClient) GetAuth(twoFactorAuthCode string) (*AuthTokenResponse, error) { + var grantData map[string]string + + if c.authConfig != nil && twoFactorAuthCode == "" { + grantData = map[string]string{ + "grant_type": "refresh_token", + "refresh_token": c.authConfig.RT, + } + } else { + authEmail, ok := c.auth.(EmailAuth) + if !ok { + return nil, fmt.Errorf("invalid auth type for email authentication") + } + grantData = map[string]string{ + "grant_type": "password", + "username": authEmail.Email, + "password": authEmail.Password, + } + } + + grantData["client_id"] = "ring_official_android" + grantData["scope"] = "client" + + body, err := json.Marshal(grantData) + if err != nil { + return nil, fmt.Errorf("failed to marshal auth request: %w", err) + } + + req, err := http.NewRequest("POST", oauthURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("hardware_id", c.hardwareID) + req.Header.Set("User-Agent", "android:com.ringapp") + req.Header.Set("2fa-support", "true") + if twoFactorAuthCode != "" { + req.Header.Set("2fa-code", twoFactorAuthCode) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // Handle 2FA Responses + if resp.StatusCode == http.StatusPreconditionFailed || + (resp.StatusCode == http.StatusBadRequest && strings.Contains(resp.Header.Get("WWW-Authenticate"), "Verification Code")) { + + var tfaResp Auth2faResponse + if err := json.NewDecoder(resp.Body).Decode(&tfaResp); err != nil { + return nil, err + } + + c.Using2FA = true + if resp.StatusCode == http.StatusBadRequest { + c.PromptFor2FA = "Invalid 2fa code entered. Please try again." + return nil, fmt.Errorf("invalid 2FA code") + } + + if tfaResp.TSVState != "" { + prompt := "from your authenticator app" + if tfaResp.TSVState != "totp" { + prompt = fmt.Sprintf("sent to %s via %s", tfaResp.Phone, tfaResp.TSVState) + } + c.PromptFor2FA = fmt.Sprintf("Please enter the code %s", prompt) + } else { + c.PromptFor2FA = "Please enter the code sent to your text/email" + } + + return nil, fmt.Errorf("2FA required") + } + + // Handle errors + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var authResp AuthTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil { + return nil, fmt.Errorf("failed to decode auth response: %w", err) + } + + c.authToken = &authResp + c.authConfig = &AuthConfig{ + RT: authResp.RefreshToken, + HID: c.hardwareID, + } + + c.RefreshToken = encodeAuthConfig(c.authConfig) + if c.onTokenRefresh != nil { + c.onTokenRefresh(c.RefreshToken) + } + + return c.authToken, nil +} + +// Helper functions for auth config encoding/decoding +func parseAuthConfig(refreshToken string) (*AuthConfig, error) { + decoded, err := base64.StdEncoding.DecodeString(refreshToken) + if err != nil { + return nil, err + } + + var config AuthConfig + if err := json.Unmarshal(decoded, &config); err != nil { + // Handle legacy format where refresh token is the raw token + return &AuthConfig{RT: refreshToken}, nil + } + + return &config, nil +} + +func encodeAuthConfig(config *AuthConfig) string { + jsonBytes, _ := json.Marshal(config) + return base64.StdEncoding.EncodeToString(jsonBytes) +} + +// API URL helpers +func ClientAPI(path string) string { + return clientAPIBaseURL + path +} + +func DeviceAPI(path string) string { + return deviceAPIBaseURL + path +} + +func CommandsAPI(path string) string { + return commandsAPIBaseURL + path +} + +func AppAPI(path string) string { + return appAPIBaseURL + path +} + +// FetchRingDevices gets all Ring devices and categorizes them +func (c *RingRestClient) FetchRingDevices() (*RingDevicesResponse, error) { + response, err := c.Request("GET", ClientAPI("ring_devices"), nil) + if err != nil { + return nil, fmt.Errorf("failed to fetch ring devices: %w", err) + } + + var devices RingDevicesResponse + if err := json.Unmarshal(response, &devices); err != nil { + return nil, fmt.Errorf("failed to unmarshal devices response: %w", err) + } + + // Process "other" devices + var onvifCameras []CameraData + var intercoms []CameraData + + for _, device := range devices.Other { + kind, ok := device["kind"].(string) + if !ok { + continue + } + + switch RingDeviceType(kind) { + case OnvifCameraType: + var camera CameraData + if deviceJson, err := json.Marshal(device); err == nil { + if err := json.Unmarshal(deviceJson, &camera); err == nil { + onvifCameras = append(onvifCameras, camera) + } + } + case IntercomHandsetAudio: + var intercom CameraData + if deviceJson, err := json.Marshal(device); err == nil { + if err := json.Unmarshal(deviceJson, &intercom); err == nil { + intercoms = append(intercoms, intercom) + } + } + } + } + + // Combine all cameras into AllCameras slice + allCameras := make([]CameraData, 0) + allCameras = append(allCameras, interfaceSlice(devices.Doorbots)...) + allCameras = append(allCameras, interfaceSlice(devices.StickupCams)...) + allCameras = append(allCameras, interfaceSlice(devices.AuthorizedDoorbots)...) + allCameras = append(allCameras, interfaceSlice(onvifCameras)...) + allCameras = append(allCameras, interfaceSlice(intercoms)...) + + devices.AllCameras = allCameras + + return &devices, nil +} + +func (c *RingRestClient) GetSocketTicket() (*SocketTicketResponse, error) { + response, err := c.Request("POST", AppAPI("clap/ticket/request/signalsocket"), nil) + if err != nil { + return nil, fmt.Errorf("failed to fetch socket ticket: %w", err) + } + + var ticket SocketTicketResponse + if err := json.Unmarshal(response, &ticket); err != nil { + return nil, fmt.Errorf("failed to unmarshal socket ticket response: %w", err) + } + + return &ticket, nil +} + +func generateHardwareID() string { + h := sha256.New() + h.Write([]byte("ring-client-go2rtc")) + return hex.EncodeToString(h.Sum(nil)[:16]) +} + +func interfaceSlice(slice interface{}) []CameraData { + s := reflect.ValueOf(slice) + if s.Kind() != reflect.Slice { + return nil + } + + ret := make([]CameraData, s.Len()) + for i := 0; i < s.Len(); i++ { + if camera, ok := s.Index(i).Interface().(CameraData); ok { + ret[i] = camera + } + } + return ret +} diff --git a/pkg/ring/client.go b/pkg/ring/client.go new file mode 100644 index 00000000..4c473276 --- /dev/null +++ b/pkg/ring/client.go @@ -0,0 +1,541 @@ +package ring + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/webrtc" + "github.com/google/uuid" + "github.com/gorilla/websocket" + pion "github.com/pion/webrtc/v3" +) + +type Client struct { + api *RingRestClient + ws *websocket.Conn + prod core.Producer + camera *CameraData + dialogID string + sessionID string + wsMutex sync.Mutex + done chan struct{} +} + +type SessionBody struct { + DoorbotID int `json:"doorbot_id"` + SessionID string `json:"session_id"` +} + +type AnswerMessage struct { + Method string `json:"method"` // "sdp" + Body struct { + SessionBody + SDP string `json:"sdp"` + Type string `json:"type"` // "answer" + } `json:"body"` +} + +type IceCandidateMessage struct { + Method string `json:"method"` // "ice" + Body struct { + SessionBody + Ice string `json:"ice"` + MLineIndex int `json:"mlineindex"` + } `json:"body"` +} + +type SessionMessage struct { + Method string `json:"method"` // "session_created" or "session_started" + Body SessionBody `json:"body"` +} + +type PongMessage struct { + Method string `json:"method"` // "pong" + Body SessionBody `json:"body"` +} + +type NotificationMessage struct { + Method string `json:"method"` // "notification" + Body struct { + SessionBody + IsOK bool `json:"is_ok"` + Text string `json:"text"` + } `json:"body"` +} + +type StreamInfoMessage struct { + Method string `json:"method"` // "stream_info" + Body struct { + SessionBody + Transcoding bool `json:"transcoding"` + TranscodingReason string `json:"transcoding_reason"` + } `json:"body"` +} + +type CloseMessage struct { + Method string `json:"method"` // "close" + Body struct { + SessionBody + Reason struct { + Code int `json:"code"` + Text string `json:"text"` + } `json:"reason"` + } `json:"body"` +} + +type BaseMessage struct { + Method string `json:"method"` + Body map[string]any `json:"body"` +} + +// Close reason codes +const ( + CloseReasonNormalClose = 0 + CloseReasonAuthenticationFailed = 5 + CloseReasonTimeout = 6 +) + +func Dial(rawURL string) (*Client, error) { + // 1. Parse URL and validate basic params + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + query := u.Query() + encodedToken := query.Get("refresh_token") + deviceID := query.Get("device_id") + _, isSnapshot := query["snapshot"] + + if encodedToken == "" || deviceID == "" { + return nil, errors.New("ring: wrong query") + } + + // URL-decode the refresh token + refreshToken, err := url.QueryUnescape(encodedToken) + if err != nil { + return nil, fmt.Errorf("ring: invalid refresh token encoding: %w", err) + } + + // Initialize Ring API client + ringAPI, err := NewRingRestClient(RefreshTokenAuth{RefreshToken: refreshToken}, nil) + if err != nil { + return nil, err + } + + // Get camera details + devices, err := ringAPI.FetchRingDevices() + if err != nil { + return nil, err + } + + var camera *CameraData + for _, cam := range devices.AllCameras { + if fmt.Sprint(cam.DeviceID) == deviceID { + camera = &cam + break + } + } + if camera == nil { + return nil, errors.New("ring: camera not found") + } + + // Create base client + client := &Client{ + api: ringAPI, + camera: camera, + dialogID: uuid.NewString(), + done: make(chan struct{}), + } + + // Check if snapshot request + if isSnapshot { + client.prod = NewSnapshotProducer(ringAPI, camera) + return client, nil + } + + // If not snapshot, continue with WebRTC setup + ticket, err := ringAPI.GetSocketTicket() + if err != nil { + return nil, err + } + + // Create WebSocket connection + wsURL := fmt.Sprintf("wss://api.prod.signalling.ring.devices.a2z.com/ws?api_version=4.0&auth_type=ring_solutions&client_id=ring_site-%s&token=%s", + uuid.NewString(), url.QueryEscape(ticket.Ticket)) + + client.ws, _, err = websocket.DefaultDialer.Dial(wsURL, map[string][]string{ + "User-Agent": {"android:com.ringapp"}, + }) + if err != nil { + return nil, err + } + + // Create Peer Connection + conf := pion.Configuration{ + ICEServers: []pion.ICEServer{ + {URLs: []string{ + "stun:stun.kinesisvideo.us-east-1.amazonaws.com:443", + "stun:stun.kinesisvideo.us-east-2.amazonaws.com:443", + "stun:stun.kinesisvideo.us-west-2.amazonaws.com:443", + "stun:stun.l.google.com:19302", + "stun:stun1.l.google.com:19302", + "stun:stun2.l.google.com:19302", + "stun:stun3.l.google.com:19302", + "stun:stun4.l.google.com:19302", + }}, + }, + ICETransportPolicy: pion.ICETransportPolicyAll, + BundlePolicy: pion.BundlePolicyBalanced, + } + + api, err := webrtc.NewAPI() + if err != nil { + client.ws.Close() + return nil, err + } + + pc, err := api.NewPeerConnection(conf) + if err != nil { + client.ws.Close() + return nil, err + } + + // protect from sending ICE candidate before Offer + var sendOffer core.Waiter + + // protect from blocking on errors + defer sendOffer.Done(nil) + + // waiter will wait PC error or WS error or nil (connection OK) + var connState core.Waiter + + prod := webrtc.NewConn(pc) + prod.FormatName = "ring/webrtc" + prod.Mode = core.ModeActiveProducer + prod.Protocol = "ws" + prod.URL = rawURL + + client.prod = prod + + prod.Listen(func(msg any) { + switch msg := msg.(type) { + case *pion.ICECandidate: + _ = sendOffer.Wait() + + iceCandidate := msg.ToJSON() + + // skip empty ICE candidates + if iceCandidate.Candidate == "" { + return + } + + icePayload := map[string]interface{}{ + "ice": iceCandidate.Candidate, + "mlineindex": iceCandidate.SDPMLineIndex, + } + + if err = client.sendSessionMessage("ice", icePayload); err != nil { + connState.Done(err) + return + } + + case pion.PeerConnectionState: + switch msg { + case pion.PeerConnectionStateConnecting: + case pion.PeerConnectionStateConnected: + connState.Done(nil) + default: + connState.Done(errors.New("ring: " + msg.String())) + } + } + }) + + // Setup media configuration + medias := []*core.Media{ + { + Kind: core.KindAudio, + Direction: core.DirectionSendRecv, + Codecs: []*core.Codec{ + { + Name: "opus", + ClockRate: 48000, + Channels: 2, + }, + }, + }, + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: "H264", + ClockRate: 90000, + }, + }, + }, + } + + // Create offer + offer, err := prod.CreateOffer(medias) + if err != nil { + client.Stop() + return nil, err + } + + // Send offer + offerPayload := map[string]interface{}{ + "stream_options": map[string]bool{ + "audio_enabled": true, + "video_enabled": true, + }, + "sdp": offer, + } + + if err = client.sendSessionMessage("live_view", offerPayload); err != nil { + client.Stop() + return nil, err + } + + sendOffer.Done(nil) + + // Ring expects a ping message every 5 seconds + go client.startPingLoop(pc) + go client.startMessageLoop(&connState) + + if err = connState.Wait(); err != nil { + return nil, err + } + + return client, nil +} + +func (c *Client) startPingLoop(pc *pion.PeerConnection) { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-c.done: + return + case <-ticker.C: + if pc.ConnectionState() == pion.PeerConnectionStateConnected { + if err := c.sendSessionMessage("ping", nil); err != nil { + return + } + } + } + } +} + +func (c *Client) startMessageLoop(connState *core.Waiter) { + var err error + + // will be closed when conn will be closed + defer func() { + connState.Done(err) + }() + + for { + select { + case <-c.done: + return + default: + var res BaseMessage + if err = c.ws.ReadJSON(&res); err != nil { + select { + case <-c.done: + return + default: + } + + c.Stop() + return + } + + // check if "doorbot_id" is present + if _, ok := res.Body["doorbot_id"]; !ok { + continue + } + + // check if the message is from the correct doorbot + doorbotID := res.Body["doorbot_id"].(float64) + if doorbotID != float64(c.camera.ID) { + continue + } + + // check if the message is from the correct session + if res.Method == "session_created" || res.Method == "session_started" { + if _, ok := res.Body["session_id"]; ok && c.sessionID == "" { + c.sessionID = res.Body["session_id"].(string) + } + } + + if _, ok := res.Body["session_id"]; ok { + if res.Body["session_id"].(string) != c.sessionID { + continue + } + } + + rawMsg, _ := json.Marshal(res) + + switch res.Method { + case "sdp": + if prod, ok := c.prod.(*webrtc.Conn); ok { + // Get answer + var msg AnswerMessage + if err = json.Unmarshal(rawMsg, &msg); err != nil { + c.Stop() + return + } + if err = prod.SetAnswer(msg.Body.SDP); err != nil { + c.Stop() + return + } + if err = c.activateSession(); err != nil { + c.Stop() + return + } + } + + case "ice": + if prod, ok := c.prod.(*webrtc.Conn); ok { + // Continue to receiving candidates + var msg IceCandidateMessage + if err = json.Unmarshal(rawMsg, &msg); err != nil { + break + } + + // check for empty ICE candidate + if msg.Body.Ice == "" { + break + } + + if err = prod.AddCandidate(msg.Body.Ice); err != nil { + c.Stop() + return + } + } + + case "close": + c.Stop() + return + + case "pong": + // Ignore + continue + } + } + } +} + +func (c *Client) activateSession() error { + if err := c.sendSessionMessage("activate_session", nil); err != nil { + return err + } + + streamPayload := map[string]interface{}{ + "audio_enabled": true, + "video_enabled": true, + } + + if err := c.sendSessionMessage("stream_options", streamPayload); err != nil { + return err + } + + return nil +} + +func (c *Client) sendSessionMessage(method string, body map[string]interface{}) error { + c.wsMutex.Lock() + defer c.wsMutex.Unlock() + + if body == nil { + body = make(map[string]interface{}) + } + + body["doorbot_id"] = c.camera.ID + if c.sessionID != "" { + body["session_id"] = c.sessionID + } + + msg := map[string]interface{}{ + "method": method, + "dialog_id": c.dialogID, + "body": body, + } + + if err := c.ws.WriteJSON(msg); err != nil { + return err + } + + return nil +} + +func (c *Client) GetMedias() []*core.Media { + return c.prod.GetMedias() +} + +func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return c.prod.GetTrack(media, codec) +} + +func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + if webrtcProd, ok := c.prod.(*webrtc.Conn); ok { + if media.Kind == core.KindAudio { + // Enable speaker + speakerPayload := map[string]interface{}{ + "stealth_mode": false, + } + _ = c.sendSessionMessage("camera_options", speakerPayload) + } + return webrtcProd.AddTrack(media, codec, track) + } + + return fmt.Errorf("add track not supported for snapshot") +} + +func (c *Client) Start() error { + return c.prod.Start() +} + +func (c *Client) Stop() error { + select { + case <-c.done: + return nil + default: + close(c.done) + } + + if c.prod != nil { + _ = c.prod.Stop() + } + + if c.ws != nil { + closePayload := map[string]interface{}{ + "reason": map[string]interface{}{ + "code": CloseReasonNormalClose, + "text": "", + }, + } + + _ = c.sendSessionMessage("close", closePayload) + _ = c.ws.Close() + c.ws = nil + } + + return nil +} + +func (c *Client) MarshalJSON() ([]byte, error) { + if webrtcProd, ok := c.prod.(*webrtc.Conn); ok { + return webrtcProd.MarshalJSON() + } + + return json.Marshal(c.prod) +} diff --git a/pkg/ring/snapshot.go b/pkg/ring/snapshot.go new file mode 100644 index 00000000..f64e4f79 --- /dev/null +++ b/pkg/ring/snapshot.go @@ -0,0 +1,62 @@ +package ring + +import ( + "fmt" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +type SnapshotProducer struct { + core.Connection + + client *RingRestClient + camera *CameraData +} + +func NewSnapshotProducer(client *RingRestClient, camera *CameraData) *SnapshotProducer { + return &SnapshotProducer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "ring/snapshot", + Protocol: "https", + RemoteAddr: "app-snaps.ring.com", + Medias: []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{ + { + Name: core.CodecJPEG, + ClockRate: 90000, + PayloadType: core.PayloadTypeRAW, + }, + }, + }, + }, + }, + client: client, + camera: camera, + } +} + +func (p *SnapshotProducer) Start() error { + // Fetch snapshot + response, err := p.client.Request("GET", fmt.Sprintf("https://app-snaps.ring.com/snapshots/next/%d", int(p.camera.ID)), nil) + if err != nil { + return err + } + + pkt := &rtp.Packet{ + Header: rtp.Header{Timestamp: core.Now90000()}, + Payload: response, + } + + p.Receivers[0].WriteRTP(pkt) + + return nil +} + +func (p *SnapshotProducer) Stop() error { + return p.Connection.Stop() +} diff --git a/pkg/roborock/client.go b/pkg/roborock/client.go index 39caab88..ef221e65 100644 --- a/pkg/roborock/client.go +++ b/pkg/roborock/client.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "log" "net/rpc" "net/url" "strconv" @@ -19,6 +18,7 @@ import ( pion "github.com/pion/webrtc/v3" ) +// Deprecated: should be rewritten to core.Connection type Client struct { core.Listener @@ -35,8 +35,15 @@ type Client struct { backchannel bool } -func NewClient(url string) *Client { - return &Client{url: url} +func Dial(rawURL string) (*Client, error) { + client := &Client{url: rawURL} + if err := client.Dial(); err != nil { + return nil, err + } + if err := client.Connect(); err != nil { + return nil, err + } + return client, nil } func (c *Client) Dial() error { @@ -104,8 +111,10 @@ func (c *Client) Connect() error { var sendOffer sync.WaitGroup c.conn = webrtc.NewConn(pc) - c.conn.Desc = "Roborock" + c.conn.FormatName = "roborock" c.conn.Mode = core.ModeActiveProducer + c.conn.Protocol = "mqtt" + c.conn.URL = c.url c.conn.Listen(func(msg any) { switch msg := msg.(type) { case *pion.ICECandidate: @@ -138,7 +147,7 @@ func (c *Client) Connect() error { } offer := pc.LocalDescription() - log.Printf("[roborock] offer\n%s", offer.SDP) + //log.Printf("[roborock] offer\n%s", offer.SDP) if err = c.SendSDPtoRobot(offer); err != nil { return err } @@ -151,7 +160,7 @@ func (c *Client) Connect() error { time.Sleep(time.Second) if desc, _ := c.GetDeviceSDP(); desc != nil { - log.Printf("[roborock] answer\n%s", desc.SDP) + //log.Printf("[roborock] answer\n%s", desc.SDP) if err = c.conn.SetAnswer(desc.SDP); err != nil { return err } diff --git a/pkg/roborock/iot/client.go b/pkg/roborock/iot/client.go index 8773455d..c3b2d97f 100644 --- a/pkg/roborock/iot/client.go +++ b/pkg/roborock/iot/client.go @@ -6,12 +6,12 @@ import ( "encoding/hex" "encoding/json" "fmt" - "github.com/AlexxIT/go2rtc/pkg/mqtt" - "github.com/rs/zerolog/log" "net" "net/rpc" "net/url" "time" + + "github.com/AlexxIT/go2rtc/pkg/mqtt" ) type Codec struct { @@ -56,7 +56,7 @@ func (c *Codec) WriteRequest(r *rpc.Request, v any) error { return err } - log.Printf("[roborock] send: %s", payload) + //log.Printf("[roborock] send: %s", payload) payload = c.Encrypt(payload, ts, ts, ts) @@ -86,7 +86,7 @@ func (c *Codec) ReadResponseHeader(r *rpc.Response) error { continue } - log.Printf("[roborock] recv %s", payload) + //log.Printf("[roborock] recv %s", payload) // get content from response payload: // {"t":1676871268,"dps":{"102":"{\"id\":315003,\"result\":[\"ok\"]}"}} diff --git a/pkg/rtmp/client.go b/pkg/rtmp/client.go index aff8e23c..c9e9ad17 100644 --- a/pkg/rtmp/client.go +++ b/pkg/rtmp/client.go @@ -8,10 +8,11 @@ import ( "strings" "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/flv" "github.com/AlexxIT/go2rtc/pkg/tcp" ) -func DialPlay(rawURL string) (core.Producer, error) { +func DialPlay(rawURL string) (*flv.Producer, error) { u, err := url.Parse(rawURL) if err != nil { return nil, err @@ -22,19 +23,19 @@ func DialPlay(rawURL string) (core.Producer, error) { return nil, err } - rtmpConn, err := NewClient(conn, u) + client, err := NewClient(conn, u) if err != nil { return nil, err } - if err = rtmpConn.play(); err != nil { + if err = client.play(); err != nil { return nil, err } - return rtmpConn.Producer() + return client.Producer() } -func DialPublish(rawURL string) (io.Writer, error) { +func DialPublish(rawURL string, cons *flv.Consumer) (io.Writer, error) { u, err := url.Parse(rawURL) if err != nil { return nil, err @@ -54,6 +55,11 @@ func DialPublish(rawURL string) (io.Writer, error) { return nil, err } + cons.FormatName = "rtmp" + cons.Protocol = "rtmp" + cons.RemoteAddr = conn.RemoteAddr().String() + cons.URL = rawURL + return client, nil } diff --git a/pkg/rtmp/flv.go b/pkg/rtmp/flv.go index 87bef0a8..350f4c3c 100644 --- a/pkg/rtmp/flv.go +++ b/pkg/rtmp/flv.go @@ -1,11 +1,10 @@ package rtmp import ( - "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/flv" ) -func (c *Conn) Producer() (core.Producer, error) { +func (c *Conn) Producer() (*flv.Producer, error) { c.rdBuf = []byte{ 'F', 'L', 'V', // signature 1, // version @@ -13,7 +12,17 @@ func (c *Conn) Producer() (core.Producer, error) { 0, 0, 0, 9, // header size } - return flv.Open(c) + prod, err := flv.Open(c) + if err != nil { + return nil, err + } + + prod.FormatName = "rtmp" + prod.Protocol = "rtmp" + prod.RemoteAddr = c.conn.RemoteAddr().String() + prod.URL = c.url + + return prod, nil } // Read - convert RTMP to FLV format diff --git a/pkg/rtmp/server.go b/pkg/rtmp/server.go index ed727b98..3dcd4048 100644 --- a/pkg/rtmp/server.go +++ b/pkg/rtmp/server.go @@ -117,10 +117,6 @@ func (c *Conn) acceptCommand(b []byte) error { } } - if c.App == "" { - return fmt.Errorf("rtmp: read command %x", b) - } - payload := amf.EncodeItems( "_result", tID, map[string]any{"fmsVer": "FMS/3,0,1,123"}, @@ -129,9 +125,16 @@ func (c *Conn) acceptCommand(b []byte) error { return c.writeMessage(3, TypeCommand, 0, payload) case CommandReleaseStream: + // if app is empty - will use key as app + if c.App == "" && len(items) == 4 { + c.App, _ = items[3].(string) + } + payload := amf.EncodeItems("_result", tID, nil) return c.writeMessage(3, TypeCommand, 0, payload) + case CommandFCPublish: // no response + case CommandCreateStream: payload := amf.EncodeItems("_result", tID, nil, 1) return c.writeMessage(3, TypeCommand, 0, payload) @@ -140,8 +143,6 @@ func (c *Conn) acceptCommand(b []byte) error { c.Intent = cmd c.streamID = 1 - case CommandFCPublish: // no response - default: println("rtmp: unknown command: " + cmd) } diff --git a/pkg/rtsp/client.go b/pkg/rtsp/client.go index ca32ce32..352c00a1 100644 --- a/pkg/rtsp/client.go +++ b/pkg/rtsp/client.go @@ -20,7 +20,13 @@ import ( var Timeout = time.Second * 5 func NewClient(uri string) *Conn { - return &Conn{uri: uri} + return &Conn{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "rtsp", + }, + uri: uri, + } } func (c *Conn) Dial() (err error) { @@ -36,8 +42,10 @@ func (c *Conn) Dial() (err error) { timeout = time.Second * time.Duration(c.Timeout) } conn, err = tcp.Dial(c.URL, timeout) + c.Protocol = "rtsp+tcp" } else { conn, err = websocket.Dial(c.Transport) + c.Protocol = "ws" } if err != nil { return @@ -53,6 +61,10 @@ func (c *Conn) Dial() (err error) { c.sequence = 0 c.state = StateConn + c.Connection.RemoteAddr = conn.RemoteAddr().String() + c.Connection.Transport = conn + c.Connection.URL = c.uri + return nil } @@ -143,7 +155,7 @@ func (c *Conn) Describe() error { } } - c.sdp = string(res.Body) // for info + c.SDP = string(res.Body) // for info medias, err := UnmarshalSDP(res.Body) if err != nil { @@ -186,10 +198,20 @@ func (c *Conn) Announce() (err error) { return err } - res, err := c.Do(req) + _, err = c.Do(req) + return +} - _ = res +func (c *Conn) Record() (err error) { + req := &tcp.Request{ + Method: MethodRecord, + URL: c.URL, + Header: map[string][]string{ + "Range": {"npt=0.000-"}, + }, + } + _, err = c.Do(req) return } @@ -304,5 +326,8 @@ func (c *Conn) Close() error { if c.mode == core.ModeActiveProducer { _ = c.Teardown() } + if c.OnClose != nil { + _ = c.OnClose() + } return c.conn.Close() } diff --git a/pkg/rtsp/conn.go b/pkg/rtsp/conn.go index 91465f2c..0c2009d7 100644 --- a/pkg/rtsp/conn.go +++ b/pkg/rtsp/conn.go @@ -18,20 +18,20 @@ import ( ) type Conn struct { + core.Connection core.Listener // public Backchannel bool Media string + OnClose func() error PacketSize uint16 SessionName string Timeout int Transport string // custom transport support, ex. RTSP over WebSocket - Medias []*core.Media - UserAgent string - URL *url.URL + URL *url.URL // internal @@ -43,19 +43,10 @@ type Conn struct { reader *bufio.Reader sequence int session string - sdp string uri string state State stateMu sync.Mutex - - receivers []*core.Receiver - senders []*core.Sender - - // stats - - recv int - send int } const ( @@ -113,7 +104,7 @@ func (c *Conn) Handle() (err error) { // polling frames from remote RTSP Server (ex Camera) timeout = time.Second * 5 - if len(c.receivers) == 0 { + if len(c.Receivers) == 0 { // if we only send audio to camera // https://github.com/AlexxIT/go2rtc/issues/659 timeout += keepaliveDT @@ -238,7 +229,7 @@ func (c *Conn) Handle() (err error) { return } - c.recv += int(size) + c.Recv += int(size) if channelID&1 == 0 { packet := &rtp.Packet{} @@ -246,7 +237,7 @@ func (c *Conn) Handle() (err error) { return } - for _, receiver := range c.receivers { + for _, receiver := range c.Receivers { if receiver.ID == channelID { receiver.WriteRTP(packet) break diff --git a/pkg/rtsp/consumer.go b/pkg/rtsp/consumer.go index 79e2b348..860ed113 100644 --- a/pkg/rtsp/consumer.go +++ b/pkg/rtsp/consumer.go @@ -18,15 +18,6 @@ func (c *Conn) GetMedias() []*core.Media { } func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) (err error) { - core.Assert(media.Direction == core.DirectionSendonly) - - for _, sender := range c.senders { - if sender.Codec == codec { - sender.HandleRTP(track) - return - } - } - var channel byte switch c.mode { @@ -47,12 +38,12 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv c.state = StateSetup case core.ModePassiveConsumer: - channel = byte(len(c.senders)) * 2 + channel = byte(len(c.Senders)) * 2 // for consumer is better to use original track codec codec = track.Codec.Clone() // generate new payload type, starting from 96 - codec.PayloadType = byte(96 + len(c.senders)) + codec.PayloadType = byte(96 + len(c.Senders)) default: panic(core.Caller()) @@ -70,7 +61,7 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv sender.HandleRTP(track) - c.senders = append(c.senders, sender) + c.Senders = append(c.Senders, sender) return nil } @@ -99,7 +90,7 @@ func (c *Conn) packetWriter(codec *core.Codec, channel, payloadType uint8) core. } //log.Printf("[rtsp] channel:%2d write_size:%6d buffer_size:%6d", channel, n, len(buf)) if _, err := c.conn.Write(buf[:n]); err == nil { - c.send += n + c.Send += n } n = 0 } @@ -171,6 +162,8 @@ func (c *Conn) packetWriter(codec *core.Codec, channel, payloadType uint8) core. case core.CodecJPEG: handlerFunc = mjpeg.RTPPay(handlerFunc) } + } else if codec.Name == core.CodecPCML { + handlerFunc = pcm.LittleToBig(handlerFunc) } else if c.PacketSize != 0 { switch codec.Name { case core.CodecH264: diff --git a/pkg/rtsp/helpers.go b/pkg/rtsp/helpers.go index c0f02f5b..952730bb 100644 --- a/pkg/rtsp/helpers.go +++ b/pkg/rtsp/helpers.go @@ -28,8 +28,10 @@ func UnmarshalSDP(rawSDP []byte) ([]*core.Media, error) { sd := &sdp.SessionDescription{} if err := sd.Unmarshal(rawSDP); err != nil { // fix multiple `s=` https://github.com/AlexxIT/WebRTC/issues/417 - re, _ := regexp.Compile("\ns=[^\n]+") - rawSDP = re.ReplaceAll(rawSDP, nil) + rawSDP = regexp.MustCompile("\ns=[^\n]+").ReplaceAll(rawSDP, nil) + + // fix broken `c=` https://github.com/AlexxIT/go2rtc/issues/1426 + rawSDP = regexp.MustCompile("\nc=[^\n]+").ReplaceAll(rawSDP, nil) // fix SDP header for some cameras if i := bytes.Index(rawSDP, []byte("\nm=")); i > 0 { @@ -38,8 +40,13 @@ func UnmarshalSDP(rawSDP []byte) ([]*core.Media, error) { // Fix invalid media type (errSDPInvalidValue) caused by // some TP-LINK IP camera, e.g. TL-IPC44GW - m := regexp.MustCompile("m=application/[^ ]+") - rawSDP = m.ReplaceAll(rawSDP, []byte("m=application")) + for _, b := range regexp.MustCompile("m=[^ ]+ ").FindAll(rawSDP, -1) { + switch string(b[2 : len(b)-1]) { + case "audio", "video", "application": + default: + rawSDP = bytes.Replace(rawSDP, b, []byte("m=application "), 1) + } + } if err == io.EOF { rawSDP = append(rawSDP, '\n') @@ -63,8 +70,25 @@ func UnmarshalSDP(rawSDP []byte) ([]*core.Media, error) { // Check buggy SDP with fmtp for H264 on another track // https://github.com/AlexxIT/WebRTC/issues/419 for _, codec := range media.Codecs { - if codec.Name == core.CodecH264 && codec.FmtpLine == "" { - codec.FmtpLine = findFmtpLine(codec.PayloadType, sd.MediaDescriptions) + switch codec.Name { + case core.CodecH264: + if codec.FmtpLine == "" { + codec.FmtpLine = findFmtpLine(codec.PayloadType, sd.MediaDescriptions) + } + case core.CodecH265: + if codec.FmtpLine != "" { + // all three parameters are needed for a valid fmtp line + // https://github.com/AlexxIT/go2rtc/pull/1588 + if !strings.Contains(codec.FmtpLine, "sprop-vps=") || + !strings.Contains(codec.FmtpLine, "sprop-sps=") || + !strings.Contains(codec.FmtpLine, "sprop-pps=") { + codec.FmtpLine = "" + } + } + case core.CodecOpus: + // fix OPUS for some cameras https://datatracker.ietf.org/doc/html/rfc7587 + codec.ClockRate = 48000 + codec.Channels = 2 } } diff --git a/pkg/rtsp/producer.go b/pkg/rtsp/producer.go index d0f36a1c..3d818b62 100644 --- a/pkg/rtsp/producer.go +++ b/pkg/rtsp/producer.go @@ -10,7 +10,7 @@ import ( func (c *Conn) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { core.Assert(media.Direction == core.DirectionRecvonly) - for _, track := range c.receivers { + for _, track := range c.Receivers { if track.Codec == codec { return track, nil } @@ -19,22 +19,33 @@ func (c *Conn) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, e c.stateMu.Lock() defer c.stateMu.Unlock() - if c.state == StatePlay { - if err := c.Reconnect(); err != nil { + var channel byte + + switch c.mode { + case core.ModeActiveProducer: + if c.state == StatePlay { + if err := c.Reconnect(); err != nil { + return nil, err + } + } + + var err error + channel, err = c.SetupMedia(media) + if err != nil { return nil, err } - } - channel, err := c.SetupMedia(media) - if err != nil { - return nil, err + c.state = StateSetup + case core.ModePassiveConsumer: + // Backchannel + channel = byte(len(c.Senders)) * 2 + default: + return nil, errors.New("rtsp: wrong mode for GetTrack") } - c.state = StateSetup - track := core.NewReceiver(media, codec) track.ID = channel - c.receivers = append(c.receivers, track) + c.Receivers = append(c.Receivers, track) return track, nil } @@ -81,10 +92,10 @@ func (c *Conn) Start() (err error) { } func (c *Conn) Stop() (err error) { - for _, receiver := range c.receivers { + for _, receiver := range c.Receivers { receiver.Close() } - for _, sender := range c.senders { + for _, sender := range c.Senders { sender.Close() } @@ -99,25 +110,7 @@ func (c *Conn) Stop() (err error) { } func (c *Conn) MarshalJSON() ([]byte, error) { - info := &core.Info{ - Type: "RTSP " + c.mode.String(), - SDP: c.sdp, - UserAgent: c.UserAgent, - Medias: c.Medias, - Receivers: c.receivers, - Senders: c.senders, - Recv: c.recv, - Send: c.send, - } - - if c.URL != nil { - info.URL = c.URL.String() - } - if c.conn != nil { - info.RemoteAddr = c.conn.RemoteAddr().String() - } - - return json.Marshal(info) + return json.Marshal(c.Connection) } func (c *Conn) Reconnect() error { @@ -135,12 +128,12 @@ func (c *Conn) Reconnect() error { } // restore previous medias - for _, receiver := range c.receivers { + for _, receiver := range c.Receivers { if _, err := c.SetupMedia(receiver.Media); err != nil { return err } } - for _, sender := range c.senders { + for _, sender := range c.Senders { if _, err := c.SetupMedia(sender.Media); err != nil { return err } diff --git a/pkg/rtsp/rtsp_test.go b/pkg/rtsp/rtsp_test.go index 7eb317a7..14c99803 100644 --- a/pkg/rtsp/rtsp_test.go +++ b/pkg/rtsp/rtsp_test.go @@ -3,6 +3,7 @@ package rtsp import ( "testing" + "github.com/AlexxIT/go2rtc/pkg/core" "github.com/stretchr/testify/assert" ) @@ -159,3 +160,110 @@ a=control:trackID=2 assert.Equal(t, "recvonly", medias[0].Direction) assert.Equal(t, "recvonly", medias[1].Direction) } + +func TestBugSDP6(t *testing.T) { + // https://github.com/AlexxIT/go2rtc/issues/1278 + s := `v=0 +o=- 3730506281693 1 IN IP4 172.20.0.215 +s=IP camera Live streaming +i=stream1 +t=0 0 +a=tool:LIVE555 Streaming Media v2014.02.04 +a=type:broadcast +a=control:* +a=range:npt=0- +a=x-qt-text-nam:IP camera Live streaming +a=x-qt-text-inf:stream1 +m=video 0 RTP/AVP 26 +c=IN IP4 172.20.0.215 +b=AS:1500 +a=x-bufferdelay:0.55000 +a=x-dimensions:1280,960 +a=control:track1 +m=audio 0 RTP/AVP 0 +c=IN IP4 172.20.0.215 +b=AS:64 +a=x-bufferdelay:0.55000 +a=control:track2 +m=application 0 RTP/AVP 107 +c=IN IP4 172.20.0.215 +b=AS:1 +a=x-bufferdelay:0.55000 +a=rtpmap:107 vnd.onvif.metadata/90000/500 +a=control:track4 +m=vana 0 RTP/AVP 108 +c=IN IP4 172.20.0.215 +b=AS:1 +a=x-bufferdelay:0.55000 +a=rtpmap:108 video.analysis/90000/500 +a=control:track5 +` + medias, err := UnmarshalSDP([]byte(s)) + assert.Nil(t, err) + assert.Len(t, medias, 4) +} + +func TestBugSDP7(t *testing.T) { + // https://github.com/AlexxIT/go2rtc/issues/1426 + s := `v=0 +o=- 1001 1 IN +s=VCP IPC Realtime stream +m=video 0 RTP/AVP 105 +c=IN +a=control:rtsp://1.0.1.113/media/video2/video +a=rtpmap:105 H264/90000 +a=fmtp:105 profile-level-id=640016; packetization-mode=1; sprop-parameter-sets=Z2QAFqw7UFAX/LCAAAH0AABOIEI=,aOqPLA== +a=recvonly +m=audio 0 RTP/AVP 0 +c=IN +a=fmtp:0 RTCP=0 +a=control:rtsp://1.0.1.113/media/video2/audio1 +a=recvonly +m=audio 0 RTP/AVP 0 +c=IN +a=control:rtsp://1.0.1.113/media/video2/backchannel +a=rtpmap:0 PCMA/8000 +a=rtpmap:0 PCMU/8000 +a=sendonly +m=application 0 RTP/AVP 107 +c=IN +a=control:rtsp://1.0.1.113/media/video2/metadata +a=rtpmap:107 vnd.onvif.metadata/90000 +a=fmtp:107 DecoderTag=h3c-v3 RTCP=0 +a=recvonly +` + medias, err := UnmarshalSDP([]byte(s)) + assert.Nil(t, err) + assert.Len(t, medias, 4) +} + +func TestHikvisionPCM(t *testing.T) { + s := `v=0 +o=- 1721969533379665 1721969533379665 IN IP4 192.168.1.12 +s=Media Presentation +e=NONE +b=AS:5100 +t=0 0 +a=control:rtsp://192.168.1.12:554/Streaming/channels/101/ +m=video 0 RTP/AVP 96 +c=IN IP4 0.0.0.0 +b=AS:5000 +a=recvonly +a=x-dimensions:3200,1800 +a=control:rtsp://192.168.1.12:554/Streaming/channels/101/trackID=1 +a=rtpmap:96 H264/90000 +a=fmtp:96 profile-level-id=420029; packetization-mode=1; sprop-parameter-sets=Z2QAM6wVFKAyAOP5f/AAEAAWyAAAH0AAB1MAIA==,aO48sA== +m=audio 0 RTP/AVP 11 +c=IN IP4 0.0.0.0 +b=AS:50 +a=recvonly +a=control:rtsp://192.168.1.12:554/Streaming/channels/101/trackID=2 +a=rtpmap:11 PCM/48000 +a=Media_header:MEDIAINFO=494D4B4801030000040000010170011080BB0000007D000000000000000000000000000000000000; +a=appversion:1.0 +` + medias, err := UnmarshalSDP([]byte(s)) + assert.Nil(t, err) + assert.Len(t, medias, 2) + assert.Equal(t, core.CodecPCML, medias[1].Codecs[0].Name) +} diff --git a/pkg/rtsp/server.go b/pkg/rtsp/server.go index 8e0d3134..343bdc66 100644 --- a/pkg/rtsp/server.go +++ b/pkg/rtsp/server.go @@ -13,11 +13,19 @@ import ( "github.com/AlexxIT/go2rtc/pkg/tcp" ) +var FailedAuth = errors.New("failed authentication") + func NewServer(conn net.Conn) *Conn { - c := new(Conn) - c.conn = conn - c.reader = bufio.NewReader(conn) - return c + return &Conn{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "rtsp", + Protocol: "rtsp+tcp", + RemoteAddr: conn.RemoteAddr().String(), + }, + conn: conn, + reader: bufio.NewReader(conn), + } } func (c *Conn) Auth(username, password string) { @@ -39,7 +47,7 @@ func (c *Conn) Accept() error { c.Fire(req) - if !c.auth.Validate(req) { + if valid, empty := c.auth.Validate(req); !valid { res := &tcp.Response{ Status: "401 Unauthorized", Header: map[string][]string{"Www-Authenticate": {`Basic realm="go2rtc"`}}, @@ -48,7 +56,12 @@ func (c *Conn) Accept() error { if err = c.WriteResponse(res); err != nil { return err } - continue + if empty { + // eliminate false positive: ffmpeg sends first request without + // authorization header even if the user provides credentials + continue + } + return FailedAuth } // Receiver: OPTIONS > DESCRIBE > SETUP... > PLAY > TEARDOWN @@ -70,7 +83,7 @@ func (c *Conn) Accept() error { return errors.New("wrong content type") } - c.sdp = string(req.Body) // for info + c.SDP = string(req.Body) // for info c.Medias, err = UnmarshalSDP(req.Body) if err != nil { @@ -81,7 +94,7 @@ func (c *Conn) Accept() error { for i, media := range c.Medias { track := core.NewReceiver(media, media.Codecs[0]) track.ID = byte(i * 2) - c.receivers = append(c.receivers, track) + c.Receivers = append(c.Receivers, track) } c.mode = core.ModePassiveProducer @@ -96,7 +109,7 @@ func (c *Conn) Accept() error { c.mode = core.ModePassiveConsumer c.Fire(MethodDescribe) - if c.senders == nil { + if c.Senders == nil { res := &tcp.Response{ Status: "404 Not Found", Request: req, @@ -113,7 +126,7 @@ func (c *Conn) Accept() error { // convert tracks to real output medias medias var medias []*core.Media - for i, track := range c.senders { + for i, track := range c.Senders { media := &core.Media{ Kind: core.GetKind(track.Codec.Name), Direction: core.DirectionRecvonly, @@ -123,41 +136,53 @@ func (c *Conn) Accept() error { medias = append(medias, media) } + for i, track := range c.Receivers { + media := &core.Media{ + Kind: core.GetKind(track.Codec.Name), + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{track.Codec}, + ID: "trackID=" + strconv.Itoa(i+len(c.Senders)), + } + medias = append(medias, media) + } + res.Body, err = core.MarshalSDP(c.SessionName, medias) if err != nil { return err } - c.sdp = string(res.Body) // for info + c.SDP = string(res.Body) // for info if err = c.WriteResponse(res); err != nil { return err } case MethodSetup: - tr := req.Header.Get("Transport") - res := &tcp.Response{ Header: map[string][]string{}, Request: req, } - const transport = "RTP/AVP/TCP;unicast;interleaved=" - if strings.HasPrefix(tr, transport) { + // Test if client requests TCP transport, otherwise return 461 Transport not supported + // This allows smart clients who initially requested UDP to fall back on TCP transport + if tr := req.Header.Get("Transport"); strings.HasPrefix(tr, "RTP/AVP/TCP") { c.session = core.RandString(8, 10) c.state = StateSetup if c.mode == core.ModePassiveConsumer { - if i := reqTrackID(req); i >= 0 && i < len(c.senders) { - // mark sender as SETUP - c.senders[i].Media.ID = MethodSetup + if i := reqTrackID(req); i >= 0 && i < len(c.Senders)+len(c.Receivers) { + if i < len(c.Senders) { + c.Senders[i].Media.ID = MethodSetup + } else { + c.Receivers[i-len(c.Senders)].Media.ID = MethodSetup + } tr = fmt.Sprintf("RTP/AVP/TCP;unicast;interleaved=%d-%d", i*2, i*2+1) res.Header.Set("Transport", tr) } else { res.Status = "400 Bad Request" } } else { - res.Header.Set("Transport", tr[:len(transport)+3]) + res.Header.Set("Transport", tr) } } else { res.Status = "461 Unsupported transport" @@ -170,7 +195,7 @@ func (c *Conn) Accept() error { case MethodRecord, MethodPlay: if c.mode == core.ModePassiveConsumer { // stop unconfigured senders - for _, track := range c.senders { + for _, track := range c.Senders { if track.Media.ID != MethodSetup { track.Close() } diff --git a/pkg/shell/command.go b/pkg/shell/command.go new file mode 100644 index 00000000..b7c81899 --- /dev/null +++ b/pkg/shell/command.go @@ -0,0 +1,59 @@ +package shell + +import ( + "context" + "os/exec" +) + +// Command like exec.Cmd, but with support: +// - io.Closer interface +// - Wait from multiple places +// - Done channel +type Command struct { + *exec.Cmd + ctx context.Context + cancel context.CancelFunc + err error +} + +func NewCommand(s string) *Command { + ctx, cancel := context.WithCancel(context.Background()) + args := QuoteSplit(s) + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + cmd.SysProcAttr = procAttr + return &Command{cmd, ctx, cancel, nil} +} + +func (c *Command) Start() error { + if err := c.Cmd.Start(); err != nil { + return err + } + + go func() { + c.err = c.Cmd.Wait() + c.cancel() // release context resources + }() + + return nil +} + +func (c *Command) Wait() error { + <-c.ctx.Done() + return c.err +} + +func (c *Command) Run() error { + if err := c.Start(); err != nil { + return err + } + return c.Wait() +} + +func (c *Command) Done() <-chan struct{} { + return c.ctx.Done() +} + +func (c *Command) Close() error { + c.cancel() + return nil +} diff --git a/pkg/shell/procattr.go b/pkg/shell/procattr.go new file mode 100644 index 00000000..fffdc2a4 --- /dev/null +++ b/pkg/shell/procattr.go @@ -0,0 +1,7 @@ +//go:build !linux + +package shell + +import "syscall" + +var procAttr *syscall.SysProcAttr diff --git a/pkg/shell/procattr_linux.go b/pkg/shell/procattr_linux.go new file mode 100644 index 00000000..cef1d152 --- /dev/null +++ b/pkg/shell/procattr_linux.go @@ -0,0 +1,6 @@ +package shell + +import "syscall" + +// will stop child if parent died (even with SIGKILL) +var procAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGTERM} diff --git a/pkg/shell/shell.go b/pkg/shell/shell.go index d538b961..75df671f 100644 --- a/pkg/shell/shell.go +++ b/pkg/shell/shell.go @@ -3,6 +3,7 @@ package shell import ( "os" "os/signal" + "path/filepath" "regexp" "strings" "syscall" @@ -51,6 +52,13 @@ func ReplaceEnvVars(text string) string { dok = true } + if dir, vok := os.LookupEnv("CREDENTIALS_DIRECTORY"); vok { + value, err := os.ReadFile(filepath.Join(dir, key)) + if err == nil { + return strings.TrimSpace(string(value)) + } + } + if value, vok := os.LookupEnv(key); vok { return value } diff --git a/pkg/stdin/consumer.go b/pkg/stdin/backchannel.go similarity index 83% rename from pkg/stdin/consumer.go rename to pkg/stdin/backchannel.go index a1284948..b154a291 100644 --- a/pkg/stdin/consumer.go +++ b/pkg/stdin/backchannel.go @@ -2,7 +2,6 @@ package stdin import ( "encoding/json" - "errors" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/pion/rtp" @@ -42,17 +41,16 @@ func (c *Client) Stop() (err error) { if c.sender != nil { c.sender.Close() } - if c.cmd.Process == nil { - return nil - } - return errors.Join(c.cmd.Process.Kill(), c.cmd.Wait()) + return c.cmd.Close() } func (c *Client) MarshalJSON() ([]byte, error) { - info := &core.Info{ - Type: "Exec active consumer", - Medias: c.medias, - Send: c.send, + info := &core.Connection{ + ID: core.ID(c), + FormatName: "exec", + Protocol: "pipe", + Medias: c.medias, + Send: c.send, } if c.sender != nil { info.Senders = []*core.Sender{c.sender} diff --git a/pkg/stdin/client.go b/pkg/stdin/client.go index 51db30ee..a77d4459 100644 --- a/pkg/stdin/client.go +++ b/pkg/stdin/client.go @@ -1,20 +1,20 @@ package stdin import ( - "os/exec" - "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/shell" ) +// Deprecated: should be rewritten to core.Connection type Client struct { - cmd *exec.Cmd + cmd *shell.Command medias []*core.Media sender *core.Sender send int } -func NewClient(cmd *exec.Cmd) (*Client, error) { +func NewClient(cmd *shell.Command) (*Client, error) { c := &Client{ cmd: cmd, medias: []*core.Media{ diff --git a/pkg/tapo/consumer.go b/pkg/tapo/backchannel.go similarity index 100% rename from pkg/tapo/consumer.go rename to pkg/tapo/backchannel.go diff --git a/pkg/tapo/client.go b/pkg/tapo/client.go index ed79e500..c19267ff 100644 --- a/pkg/tapo/client.go +++ b/pkg/tapo/client.go @@ -23,10 +23,11 @@ import ( "github.com/AlexxIT/go2rtc/pkg/tcp" ) +// Deprecated: should be rewritten to core.Connection type Client struct { core.Listener - url string + url *url.URL medias []*core.Media receivers []*core.Receiver @@ -51,17 +52,15 @@ type cbcMode interface { SetIV([]byte) } -func Dial(url string) (*Client, error) { - var err error - c := &Client{url: url} - if c.conn1, err = c.newConn(); err != nil { - return nil, err - } - return c, nil -} - -func (c *Client) newConn() (net.Conn, error) { - u, err := url.Parse(c.url) +// Dial support different urls: +// - tapo://{cloud-password}@192.168.1.123 - auth to Tapo cameras +// with cloud password (autodetect hash method) +// - tapo://admin:{hashed-cloud-password}@192.168.1.123 - auth to Tapo cameras +// with pre-hashed cloud password +// - vigi://admin:{password}@192.168.1.123 - auth to Vigi cameras with password +// for admin account (other not supported) +func Dial(rawURL string) (*Client, error) { + u, err := url.Parse(rawURL) if err != nil { return nil, err } @@ -70,21 +69,31 @@ func (c *Client) newConn() (net.Conn, error) { u.Host += ":8800" } - req, err := http.NewRequest("POST", "http://"+u.Host+"/stream", nil) + c := &Client{url: u} + if c.conn1, err = c.newConn(); err != nil { + return nil, err + } + return c, nil +} + +func (c *Client) newConn() (net.Conn, error) { + req, err := http.NewRequest("POST", "http://"+c.url.Host+"/stream", nil) if err != nil { return nil, err } - query := u.Query() + query := c.url.Query() if deviceId := query.Get("deviceId"); deviceId != "" { req.URL.RawQuery = "deviceId=" + deviceId } - req.URL.User = u.User req.Header.Set("Content-Type", "multipart/mixed; boundary=--client-stream-boundary--") - conn, res, err := dial(req) + username := c.url.User.Username() + password, _ := c.url.User.Password() + + conn, res, err := dial(req, c.url.Scheme, username, password) if err != nil { return nil, err } @@ -94,7 +103,7 @@ func (c *Client) newConn() (net.Conn, error) { } if c.decrypt == nil { - c.newDectypter(res) + c.newDectypter(res, c.url.Scheme, username, password) } channel := query.Get("channel") @@ -118,14 +127,18 @@ func (c *Client) newConn() (net.Conn, error) { return conn, nil } -func (c *Client) newDectypter(res *http.Response) { - username := res.Request.URL.User.Username() - password, _ := res.Request.URL.User.Password() +func (c *Client) newDectypter(res *http.Response, brand, username, password string) { + exchange := res.Header.Get("Key-Exchange") + nonce := core.Between(exchange, `nonce="`, `"`) - // extract nonce from response - // cipher="AES_128_CBC" username="admin" padding="PKCS7_16" algorithm="MD5" nonce="***" - nonce := res.Header.Get("Key-Exchange") - nonce = core.Between(nonce, `nonce="`, `"`) + if brand == "tapo" && password == "" { + if strings.Contains(exchange, `encrypt_type="3"`) { + password = fmt.Sprintf("%32X", sha256.Sum256([]byte(username))) + } else { + password = fmt.Sprintf("%16X", md5.Sum([]byte(username))) + } + username = "admin" + } key := md5.Sum([]byte(nonce + ":" + password)) iv := md5.Sum([]byte(username + ":" + nonce)) @@ -262,16 +275,12 @@ func (c *Client) Request(conn net.Conn, body []byte) (string, error) { } } -func dial(req *http.Request) (net.Conn, *http.Response, error) { +func dial(req *http.Request, brand, username, password string) (net.Conn, *http.Response, error) { conn, err := net.DialTimeout("tcp", req.URL.Host, core.ConnDialTimeout) if err != nil { return nil, nil, err } - username := req.URL.User.Username() - password, _ := req.URL.User.Password() - req.URL.User = nil - if err = req.Write(conn); err != nil { return nil, nil, err } @@ -282,6 +291,7 @@ func dial(req *http.Request) (net.Conn, *http.Response, error) { if err != nil { return nil, nil, err } + _, _ = io.Copy(io.Discard, res.Body) // discard leftovers _ = res.Body.Close() // ignore response body auth := res.Header.Get("WWW-Authenticate") @@ -290,7 +300,7 @@ func dial(req *http.Request) (net.Conn, *http.Response, error) { return nil, nil, fmt.Errorf("Expected StatusCode to be %d, received %d", http.StatusUnauthorized, res.StatusCode) } - if password == "" { + if brand == "tapo" && password == "" { // support cloud password in place of username if strings.Contains(auth, `encrypt_type="3"`) { password = fmt.Sprintf("%32X", sha256.Sum256([]byte(username))) @@ -298,6 +308,8 @@ func dial(req *http.Request) (net.Conn, *http.Response, error) { password = fmt.Sprintf("%16X", md5.Sum([]byte(username))) } username = "admin" + } else if brand == "vigi" && username == "admin" { + password = securityEncode(password) } realm := tcp.Between(auth, `realm="`, `"`) @@ -330,7 +342,39 @@ func dial(req *http.Request) (net.Conn, *http.Response, error) { return nil, nil, err } - req.URL.User = url.UserPassword(username, password) - return conn, res, nil } + +const ( + keyShort = "RDpbLfCPsJZ7fiv" + keyLong = "yLwVl0zKqws7LgKPRQ84Mdt708T1qQ3Ha7xv3H7NyU84p21BriUWBU43odz3iP4rBL3cD02KZciXTysVXiV8ngg6vL48rPJyAUw0HurW20xqxv9aYb4M9wK1Ae0wlro510qXeU07kV57fQMc8L6aLgMLwygtc0F10a0Dg70TOoouyFhdysuRMO51yY5ZlOZZLEal1h0t9YQW0Ko7oBwmCAHoic4HYbUyVeU3sfQ1xtXcPcf1aT303wAQhv66qzW" +) + +func securityEncode(s string) string { + size := len(s) + + var n int // max + if size > len(keyShort) { + n = size + } else { + n = len(keyShort) + } + + b := make([]byte, n) + + for i := 0; i < n; i++ { + c1 := 187 + c2 := 187 + if i >= size { + c1 = int(keyShort[i]) + } else if i >= len(keyShort) { + c2 = int(s[i]) + } else { + c1 = int(keyShort[i]) + c2 = int(s[i]) + } + b[i] = keyLong[(c1^c2)%len(keyLong)] + } + + return string(b) +} diff --git a/pkg/tapo/producer.go b/pkg/tapo/producer.go index ac213e15..87a91ff5 100644 --- a/pkg/tapo/producer.go +++ b/pkg/tapo/producer.go @@ -2,6 +2,7 @@ package tapo import ( "encoding/json" + "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/mpegts" ) @@ -74,15 +75,20 @@ func (c *Client) Stop() error { } func (c *Client) MarshalJSON() ([]byte, error) { - info := &core.Info{ - Type: "Tapo active producer", - Medias: c.medias, - Recv: c.recv, - Receivers: c.receivers, - Send: c.send, + info := &core.Connection{ + ID: core.ID(c), + FormatName: c.url.Scheme, + Protocol: "http", + Medias: c.medias, + Recv: c.recv, + Receivers: c.receivers, + Send: c.send, } if c.sender != nil { info.Senders = []*core.Sender{c.sender} } + if c.conn1 != nil { + info.RemoteAddr = c.conn1.RemoteAddr().String() + } return json.Marshal(info) } diff --git a/pkg/tcp/auth.go b/pkg/tcp/auth.go index ac212fcf..3eb26024 100644 --- a/pkg/tcp/auth.go +++ b/pkg/tcp/auth.go @@ -85,14 +85,14 @@ func (a *Auth) Write(req *Request) { } } -func (a *Auth) Validate(req *Request) bool { +func (a *Auth) Validate(req *Request) (valid, empty bool) { if a == nil { - return true + return true, true } header := req.Header.Get("Authorization") if header == "" { - return false + return false, true } if a.Method == AuthUnknown { @@ -100,7 +100,7 @@ func (a *Auth) Validate(req *Request) bool { a.header = "Basic " + B64(a.user, a.pass) } - return header == a.header + return header == a.header, false } func (a *Auth) ReadNone(res *Response) bool { diff --git a/pkg/tcp/helpers.go b/pkg/tcp/helpers.go deleted file mode 100644 index 9db42a89..00000000 --- a/pkg/tcp/helpers.go +++ /dev/null @@ -1,12 +0,0 @@ -package tcp - -import ( - "net/http" -) - -func RemoteAddr(r *http.Request) string { - if remote := r.Header.Get("X-Forwarded-For"); remote != "" { - return remote + ", " + r.RemoteAddr - } - return r.RemoteAddr -} diff --git a/pkg/tcp/request.go b/pkg/tcp/request.go index 88da83b6..13463cd7 100644 --- a/pkg/tcp/request.go +++ b/pkg/tcp/request.go @@ -19,11 +19,11 @@ func Do(req *http.Request) (*http.Response, error) { switch req.URL.Scheme { case "httpx": - secure = &tls.Config{InsecureSkipVerify: true} + secure = insecureConfig req.URL.Scheme = "https" case "https": if hostname := req.URL.Hostname(); IsIP(hostname) { - secure = &tls.Config{InsecureSkipVerify: true} + secure = insecureConfig } } @@ -144,6 +144,22 @@ type key string var connKey = key("conn") var secureKey = key("secure") +var insecureConfig = &tls.Config{ + InsecureSkipVerify: true, + CipherSuites: []uint16{ + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + + // this cipher suites disabled starting from https://tip.golang.org/doc/go1.22 + // but cameras can't work without them https://github.com/AlexxIT/go2rtc/issues/1172 + tls.TLS_RSA_WITH_AES_128_GCM_SHA256, // insecure + tls.TLS_RSA_WITH_AES_256_GCM_SHA384, // insecure + }, +} + func WithConn() (context.Context, *net.Conn) { pconn := new(net.Conn) return context.WithValue(context.Background(), connKey, pconn), pconn diff --git a/pkg/v4l2/device/README.md b/pkg/v4l2/device/README.md new file mode 100644 index 00000000..de686ea0 --- /dev/null +++ b/pkg/v4l2/device/README.md @@ -0,0 +1,21 @@ +# Video For Linux Two + +Build on Ubuntu + +```bash +sudo apt install gcc-x86-64-linux-gnu +sudo apt install gcc-i686-linux-gnu +sudo apt install gcc-aarch64-linux-gnu binutils +sudo apt install gcc-arm-linux-gnueabihf +sudo apt install gcc-mipsel-linux-gnu + +x86_64-linux-gnu-gcc -w -static videodev2_arch.c -o videodev2_x86_64 +i686-linux-gnu-gcc -w -static videodev2_arch.c -o videodev2_i686 +aarch64-linux-gnu-gcc -w -static videodev2_arch.c -o videodev2_aarch64 +arm-linux-gnueabihf-gcc -w -static videodev2_arch.c -o videodev2_armhf +mipsel-linux-gnu-gcc -w -static videodev2_arch.c -o videodev2_mipsel -D_TIME_BITS=32 +``` + +## Useful links + +- https://github.com/torvalds/linux/blob/master/include/uapi/linux/videodev2.h diff --git a/pkg/v4l2/device/device.go b/pkg/v4l2/device/device.go new file mode 100644 index 00000000..c77d60f5 --- /dev/null +++ b/pkg/v4l2/device/device.go @@ -0,0 +1,252 @@ +//go:build linux + +package device + +import ( + "bytes" + "errors" + "fmt" + "syscall" + "unsafe" +) + +type Device struct { + fd int + bufs [][]byte + pixFmt uint32 +} + +func Open(path string) (*Device, error) { + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CLOEXEC, 0) + if err != nil { + return nil, err + } + return &Device{fd: fd}, nil +} + +const buffersCount = 2 + +type Capability struct { + Driver string + Card string + BusInfo string + Version string +} + +func (d *Device) Capability() (*Capability, error) { + c := v4l2_capability{} + if err := ioctl(d.fd, VIDIOC_QUERYCAP, unsafe.Pointer(&c)); err != nil { + return nil, err + } + return &Capability{ + Driver: str(c.driver[:]), + Card: str(c.card[:]), + BusInfo: str(c.bus_info[:]), + Version: fmt.Sprintf("%d.%d.%d", byte(c.version>>16), byte(c.version>>8), byte(c.version)), + }, nil +} + +func (d *Device) ListFormats() ([]uint32, error) { + var items []uint32 + + for i := uint32(0); ; i++ { + fd := v4l2_fmtdesc{ + index: i, + typ: V4L2_BUF_TYPE_VIDEO_CAPTURE, + } + if err := ioctl(d.fd, VIDIOC_ENUM_FMT, unsafe.Pointer(&fd)); err != nil { + if !errors.Is(err, syscall.EINVAL) { + return nil, err + } + break + } + + items = append(items, fd.pixelformat) + } + + return items, nil +} + +func (d *Device) ListSizes(pixFmt uint32) ([][2]uint32, error) { + var items [][2]uint32 + + for i := uint32(0); ; i++ { + fs := v4l2_frmsizeenum{ + index: i, + pixel_format: pixFmt, + } + if err := ioctl(d.fd, VIDIOC_ENUM_FRAMESIZES, unsafe.Pointer(&fs)); err != nil { + if !errors.Is(err, syscall.EINVAL) { + return nil, err + } + break + } + + if fs.typ != V4L2_FRMSIZE_TYPE_DISCRETE { + continue + } + + items = append(items, [2]uint32{fs.discrete.width, fs.discrete.height}) + } + + return items, nil +} + +func (d *Device) ListFrameRates(pixFmt, width, height uint32) ([]uint32, error) { + var items []uint32 + + for i := uint32(0); ; i++ { + fi := v4l2_frmivalenum{ + index: i, + pixel_format: pixFmt, + width: width, + height: height, + } + if err := ioctl(d.fd, VIDIOC_ENUM_FRAMEINTERVALS, unsafe.Pointer(&fi)); err != nil { + if !errors.Is(err, syscall.EINVAL) { + return nil, err + } + break + } + + if fi.typ != V4L2_FRMIVAL_TYPE_DISCRETE || fi.discrete.numerator != 1 { + continue + } + + items = append(items, fi.discrete.denominator) + } + + return items, nil +} + +func (d *Device) SetFormat(width, height, pixFmt uint32) error { + d.pixFmt = pixFmt + + f := v4l2_format{ + typ: V4L2_BUF_TYPE_VIDEO_CAPTURE, + pix: v4l2_pix_format{ + width: width, + height: height, + pixelformat: pixFmt, + field: V4L2_FIELD_NONE, + colorspace: V4L2_COLORSPACE_DEFAULT, + }, + } + return ioctl(d.fd, VIDIOC_S_FMT, unsafe.Pointer(&f)) +} + +func (d *Device) SetParam(fps uint32) error { + p := v4l2_streamparm{ + typ: V4L2_BUF_TYPE_VIDEO_CAPTURE, + capture: v4l2_captureparm{ + timeperframe: v4l2_fract{numerator: 1, denominator: fps}, + }, + } + return ioctl(d.fd, VIDIOC_S_PARM, unsafe.Pointer(&p)) +} + +func (d *Device) StreamOn() (err error) { + rb := v4l2_requestbuffers{ + count: buffersCount, + typ: V4L2_BUF_TYPE_VIDEO_CAPTURE, + memory: V4L2_MEMORY_MMAP, + } + if err = ioctl(d.fd, VIDIOC_REQBUFS, unsafe.Pointer(&rb)); err != nil { + return err + } + + d.bufs = make([][]byte, buffersCount) + for i := uint32(0); i < buffersCount; i++ { + qb := v4l2_buffer{ + index: i, + typ: V4L2_BUF_TYPE_VIDEO_CAPTURE, + memory: V4L2_MEMORY_MMAP, + } + if err = ioctl(d.fd, VIDIOC_QUERYBUF, unsafe.Pointer(&qb)); err != nil { + return err + } + + if d.bufs[i], err = syscall.Mmap( + d.fd, int64(qb.offset), int(qb.length), syscall.PROT_READ, syscall.MAP_SHARED, + ); nil != err { + return err + } + + if err = ioctl(d.fd, VIDIOC_QBUF, unsafe.Pointer(&qb)); err != nil { + return err + } + } + + typ := uint32(V4L2_BUF_TYPE_VIDEO_CAPTURE) + return ioctl(d.fd, VIDIOC_STREAMON, unsafe.Pointer(&typ)) +} + +func (d *Device) StreamOff() (err error) { + typ := uint32(V4L2_BUF_TYPE_VIDEO_CAPTURE) + if err = ioctl(d.fd, VIDIOC_STREAMOFF, unsafe.Pointer(&typ)); err != nil { + return err + } + + for i := range d.bufs { + _ = syscall.Munmap(d.bufs[i]) + } + + rb := v4l2_requestbuffers{ + count: 0, + typ: V4L2_BUF_TYPE_VIDEO_CAPTURE, + memory: V4L2_MEMORY_MMAP, + } + return ioctl(d.fd, VIDIOC_REQBUFS, unsafe.Pointer(&rb)) +} + +func (d *Device) Capture() ([]byte, error) { + dec := v4l2_buffer{ + typ: V4L2_BUF_TYPE_VIDEO_CAPTURE, + memory: V4L2_MEMORY_MMAP, + } + if err := ioctl(d.fd, VIDIOC_DQBUF, unsafe.Pointer(&dec)); err != nil { + return nil, err + } + + src := d.bufs[dec.index][:dec.bytesused] + dst := make([]byte, dec.bytesused) + + switch d.pixFmt { + case V4L2_PIX_FMT_YUYV: + YUYVtoYUV(dst, src) + case V4L2_PIX_FMT_NV12: + NV12toYUV(dst, src) + default: + copy(dst, d.bufs[dec.index][:dec.bytesused]) + } + + enc := v4l2_buffer{ + typ: V4L2_BUF_TYPE_VIDEO_CAPTURE, + memory: V4L2_MEMORY_MMAP, + index: dec.index, + } + if err := ioctl(d.fd, VIDIOC_QBUF, unsafe.Pointer(&enc)); err != nil { + return nil, err + } + + return dst, nil +} + +func (d *Device) Close() error { + return syscall.Close(d.fd) +} + +func ioctl(fd int, req uint, arg unsafe.Pointer) error { + _, _, err := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), uintptr(req), uintptr(arg)) + if err != 0 { + return err + } + return nil +} + +func str(b []byte) string { + if i := bytes.IndexByte(b, 0); i >= 0 { + return string(b[:i]) + } + return string(b) +} diff --git a/pkg/v4l2/device/formats.go b/pkg/v4l2/device/formats.go new file mode 100644 index 00000000..a0b41082 --- /dev/null +++ b/pkg/v4l2/device/formats.go @@ -0,0 +1,62 @@ +package device + +const ( + V4L2_PIX_FMT_YUYV = 'Y' | 'U'<<8 | 'Y'<<16 | 'V'<<24 + V4L2_PIX_FMT_NV12 = 'N' | 'V'<<8 | '1'<<16 | '2'<<24 + V4L2_PIX_FMT_MJPEG = 'M' | 'J'<<8 | 'P'<<16 | 'G'<<24 + V4L2_PIX_FMT_H264 = 'H' | '2'<<8 | '6'<<16 | '4'<<24 + V4L2_PIX_FMT_HEVC = 'H' | 'E'<<8 | 'V'<<16 | 'C'<<24 +) + +type Format struct { + FourCC uint32 + Name string + FFmpeg string +} + +var Formats = []Format{ + {V4L2_PIX_FMT_YUYV, "YUV 4:2:2", "yuyv422"}, + {V4L2_PIX_FMT_NV12, "Y/UV 4:2:0", "nv12"}, + {V4L2_PIX_FMT_MJPEG, "Motion-JPEG", "mjpeg"}, + {V4L2_PIX_FMT_H264, "H.264", "h264"}, + {V4L2_PIX_FMT_HEVC, "HEVC", "hevc"}, +} + +func YUYVtoYUV(dst, src []byte) { + n := len(src) + i0 := 0 + iy := 0 + iu := n / 2 + iv := n / 4 * 3 + for i0 < n { + dst[iy] = src[i0] + i0++ + iy++ + dst[iu] = src[i0] + i0++ + iu++ + dst[iy] = src[i0] + i0++ + iy++ + dst[iv] = src[i0] + i0++ + iv++ + } +} + +func NV12toYUV(dst, src []byte) { + n := len(src) + k := n / 6 + i0 := k * 4 + iu := i0 + iv := i0 + k + copy(dst, src[:i0]) // copy Y + for i0 < n { + dst[iu] = src[i0] + i0++ + iu++ + dst[iv] = src[i0] + i0++ + iv++ + } +} diff --git a/pkg/v4l2/device/videodev2_386.go b/pkg/v4l2/device/videodev2_386.go new file mode 100644 index 00000000..8737ca9d --- /dev/null +++ b/pkg/v4l2/device/videodev2_386.go @@ -0,0 +1,149 @@ +package device + +const ( + VIDIOC_QUERYCAP = 0x80685600 + VIDIOC_ENUM_FMT = 0xc0405602 + VIDIOC_G_FMT = 0xc0cc5604 + VIDIOC_S_FMT = 0xc0cc5605 + VIDIOC_REQBUFS = 0xc0145608 + VIDIOC_QUERYBUF = 0xc0445609 + + VIDIOC_QBUF = 0xc044560f + VIDIOC_DQBUF = 0xc0445611 + VIDIOC_STREAMON = 0x40045612 + VIDIOC_STREAMOFF = 0x40045613 + VIDIOC_G_PARM = 0xc0cc5615 + VIDIOC_S_PARM = 0xc0cc5616 + + VIDIOC_ENUM_FRAMESIZES = 0xc02c564a + VIDIOC_ENUM_FRAMEINTERVALS = 0xc034564b +) + +const ( + V4L2_BUF_TYPE_VIDEO_CAPTURE = 1 + V4L2_COLORSPACE_DEFAULT = 0 + V4L2_FIELD_NONE = 1 + V4L2_FRMIVAL_TYPE_DISCRETE = 1 + V4L2_FRMSIZE_TYPE_DISCRETE = 1 + V4L2_MEMORY_MMAP = 1 +) + +type v4l2_capability struct { // size 104 + driver [16]byte // offset 0, size 16 + card [32]byte // offset 16, size 32 + bus_info [32]byte // offset 48, size 32 + version uint32 // offset 80, size 4 + capabilities uint32 // offset 84, size 4 + device_caps uint32 // offset 88, size 4 + reserved [3]uint32 // offset 92, size 12 +} + +type v4l2_format struct { // size 204 + typ uint32 // offset 0, size 4 + _ [0]byte // align + pix v4l2_pix_format // offset 4, size 48 + _ [152]byte // filler +} + +type v4l2_pix_format struct { // size 48 + width uint32 // offset 0, size 4 + height uint32 // offset 4, size 4 + pixelformat uint32 // offset 8, size 4 + field uint32 // offset 12, size 4 + bytesperline uint32 // offset 16, size 4 + sizeimage uint32 // offset 20, size 4 + colorspace uint32 // offset 24, size 4 + priv uint32 // offset 28, size 4 + flags uint32 // offset 32, size 4 + ycbcr_enc uint32 // offset 36, size 4 + quantization uint32 // offset 40, size 4 + xfer_func uint32 // offset 44, size 4 +} + +type v4l2_streamparm struct { // size 204 + typ uint32 // offset 0, size 4 + capture v4l2_captureparm // offset 4, size 40 + _ [160]byte // filler +} + +type v4l2_captureparm struct { // size 40 + capability uint32 // offset 0, size 4 + capturemode uint32 // offset 4, size 4 + timeperframe v4l2_fract // offset 8, size 8 + extendedmode uint32 // offset 16, size 4 + readbuffers uint32 // offset 20, size 4 + reserved [4]uint32 // offset 24, size 16 +} + +type v4l2_fract struct { // size 8 + numerator uint32 // offset 0, size 4 + denominator uint32 // offset 4, size 4 +} + +type v4l2_requestbuffers struct { // size 20 + count uint32 // offset 0, size 4 + typ uint32 // offset 4, size 4 + memory uint32 // offset 8, size 4 + capabilities uint32 // offset 12, size 4 + flags uint8 // offset 16, size 1 + reserved [3]uint8 // offset 17, size 3 +} + +type v4l2_buffer struct { // size 68 + index uint32 // offset 0, size 4 + typ uint32 // offset 4, size 4 + bytesused uint32 // offset 8, size 4 + flags uint32 // offset 12, size 4 + field uint32 // offset 16, size 4 + _ [8]byte // align + timecode v4l2_timecode // offset 28, size 16 + sequence uint32 // offset 44, size 4 + memory uint32 // offset 48, size 4 + offset uint32 // offset 52, size 4 + _ [0]byte // align + length uint32 // offset 56, size 4 + _ [8]byte // filler +} + +type v4l2_timecode struct { // size 16 + typ uint32 // offset 0, size 4 + flags uint32 // offset 4, size 4 + frames uint8 // offset 8, size 1 + seconds uint8 // offset 9, size 1 + minutes uint8 // offset 10, size 1 + hours uint8 // offset 11, size 1 + userbits [4]uint8 // offset 12, size 4 +} + +type v4l2_fmtdesc struct { // size 64 + index uint32 // offset 0, size 4 + typ uint32 // offset 4, size 4 + flags uint32 // offset 8, size 4 + description [32]byte // offset 12, size 32 + pixelformat uint32 // offset 44, size 4 + mbus_code uint32 // offset 48, size 4 + reserved [3]uint32 // offset 52, size 12 +} + +type v4l2_frmsizeenum struct { // size 44 + index uint32 // offset 0, size 4 + pixel_format uint32 // offset 4, size 4 + typ uint32 // offset 8, size 4 + discrete v4l2_frmsize_discrete // offset 12, size 8 + _ [24]byte // filler +} + +type v4l2_frmsize_discrete struct { // size 8 + width uint32 // offset 0, size 4 + height uint32 // offset 4, size 4 +} + +type v4l2_frmivalenum struct { // size 52 + index uint32 // offset 0, size 4 + pixel_format uint32 // offset 4, size 4 + width uint32 // offset 8, size 4 + height uint32 // offset 12, size 4 + typ uint32 // offset 16, size 4 + discrete v4l2_fract // offset 20, size 8 + _ [24]byte // filler +} diff --git a/pkg/v4l2/device/videodev2_arch.c b/pkg/v4l2/device/videodev2_arch.c new file mode 100644 index 00000000..1053a088 --- /dev/null +++ b/pkg/v4l2/device/videodev2_arch.c @@ -0,0 +1,163 @@ +#include +#include +#include + +#define printconst1(con) printf("\t%s = 0x%08lx\n", #con, con) +#define printconst2(con) printf("\t%s = %d\n", #con, con) +#define printstruct(str) printf("type %s struct { // size %lu\n", #str, sizeof(struct str)) +#define printmember(str, mem, typ) printf("\t%s %s // offset %lu, size %lu\n", #mem == "type" ? "typ" : #mem, typ, offsetof(struct str, mem), sizeof((struct str){0}.mem)) +#define printunimem(str, uni, mem, typ) printf("\t%s %s // offset %lu, size %lu\n", #mem, typ, offsetof(struct str, uni.mem), sizeof((struct str){0}.uni.mem)) +#define printalign1(str, mem2, mem1) printf("\t_ [%lu]byte // align\n", offsetof(struct str, mem2) - offsetof(struct str, mem1) - sizeof((struct str){0}.mem1)) +#define printfiller(str, mem) printf("\t_ [%lu]byte // filler\n", sizeof(struct str) - offsetof(struct str, mem) - sizeof((struct str){0}.mem)) + +int main() { + printf("const (\n"); + printconst1(VIDIOC_QUERYCAP); + printconst1(VIDIOC_ENUM_FMT); + printconst1(VIDIOC_G_FMT); + printconst1(VIDIOC_S_FMT); + printconst1(VIDIOC_REQBUFS); + printconst1(VIDIOC_QUERYBUF); + printf("\n"); + printconst1(VIDIOC_QBUF); + printconst1(VIDIOC_DQBUF); + printconst1(VIDIOC_STREAMON); + printconst1(VIDIOC_STREAMOFF); + printconst1(VIDIOC_G_PARM); + printconst1(VIDIOC_S_PARM); + printf("\n"); + printconst1(VIDIOC_ENUM_FRAMESIZES); + printconst1(VIDIOC_ENUM_FRAMEINTERVALS); + printf(")\n\n"); + + printf("const (\n"); + printconst2(V4L2_BUF_TYPE_VIDEO_CAPTURE); + printconst2(V4L2_COLORSPACE_DEFAULT); + printconst2(V4L2_FIELD_NONE); + printconst2(V4L2_FRMIVAL_TYPE_DISCRETE); + printconst2(V4L2_FRMSIZE_TYPE_DISCRETE); + printconst2(V4L2_MEMORY_MMAP); + printf(")\n\n"); + + printstruct(v4l2_capability); + printmember(v4l2_capability, driver, "[16]byte"); + printmember(v4l2_capability, card, "[32]byte"); + printmember(v4l2_capability, bus_info, "[32]byte"); + printmember(v4l2_capability, version, "uint32"); + printmember(v4l2_capability, capabilities, "uint32"); + printmember(v4l2_capability, device_caps, "uint32"); + printmember(v4l2_capability, reserved, "[3]uint32"); + printf("}\n\n"); + + printstruct(v4l2_format); + printmember(v4l2_format, type, "uint32"); + printalign1(v4l2_format, fmt, type); + printunimem(v4l2_format, fmt, pix, "v4l2_pix_format"); + printfiller(v4l2_format, fmt.pix); + printf("}\n\n"); + + printstruct(v4l2_pix_format); + printmember(v4l2_pix_format, width, "uint32"); + printmember(v4l2_pix_format, height, "uint32"); + printmember(v4l2_pix_format, pixelformat, "uint32"); + printmember(v4l2_pix_format, field, "uint32"); + printmember(v4l2_pix_format, bytesperline, "uint32"); + printmember(v4l2_pix_format, sizeimage, "uint32"); + printmember(v4l2_pix_format, colorspace, "uint32"); + printmember(v4l2_pix_format, priv, "uint32"); + printmember(v4l2_pix_format, flags, "uint32"); + printmember(v4l2_pix_format, ycbcr_enc, "uint32"); + printmember(v4l2_pix_format, quantization, "uint32"); + printmember(v4l2_pix_format, xfer_func, "uint32"); + printf("}\n\n"); + + printstruct(v4l2_streamparm); + printmember(v4l2_streamparm, type, "uint32"); + printunimem(v4l2_streamparm, parm, capture, "v4l2_captureparm"); + printfiller(v4l2_streamparm, parm.capture); + printf("}\n\n"); + + printstruct(v4l2_captureparm); + printmember(v4l2_captureparm, capability, "uint32"); + printmember(v4l2_captureparm, capturemode, "uint32"); + printmember(v4l2_captureparm, timeperframe, "v4l2_fract"); + printmember(v4l2_captureparm, extendedmode, "uint32"); + printmember(v4l2_captureparm, readbuffers, "uint32"); + printmember(v4l2_captureparm, reserved, "[4]uint32"); + printf("}\n\n"); + + printstruct(v4l2_fract); + printmember(v4l2_fract, numerator, "uint32"); + printmember(v4l2_fract, denominator, "uint32"); + printf("}\n\n"); + + printstruct(v4l2_requestbuffers); + printmember(v4l2_requestbuffers, count, "uint32"); + printmember(v4l2_requestbuffers, type, "uint32"); + printmember(v4l2_requestbuffers, memory, "uint32"); + printmember(v4l2_requestbuffers, capabilities, "uint32"); + printmember(v4l2_requestbuffers, flags, "uint8"); + printmember(v4l2_requestbuffers, reserved, "[3]uint8"); + printf("}\n\n"); + + printstruct(v4l2_buffer); + printmember(v4l2_buffer, index, "uint32"); + printmember(v4l2_buffer, type, "uint32"); + printmember(v4l2_buffer, bytesused, "uint32"); + printmember(v4l2_buffer, flags, "uint32"); + printmember(v4l2_buffer, field, "uint32"); + printalign1(v4l2_buffer, timecode, field); + printmember(v4l2_buffer, timecode, "v4l2_timecode"); + printmember(v4l2_buffer, sequence, "uint32"); + printmember(v4l2_buffer, memory, "uint32"); + printunimem(v4l2_buffer, m, offset, "uint32"); + printalign1(v4l2_buffer, length, m.offset); + printmember(v4l2_buffer, length, "uint32"); + printfiller(v4l2_buffer, length); + printf("}\n\n"); + + printstruct(v4l2_timecode); + printmember(v4l2_timecode, type, "uint32"); + printmember(v4l2_timecode, flags, "uint32"); + printmember(v4l2_timecode, frames, "uint8"); + printmember(v4l2_timecode, seconds, "uint8"); + printmember(v4l2_timecode, minutes, "uint8"); + printmember(v4l2_timecode, hours, "uint8"); + printmember(v4l2_timecode, userbits, "[4]uint8"); + printf("}\n\n"); + + printstruct(v4l2_fmtdesc); + printmember(v4l2_fmtdesc, index, "uint32"); + printmember(v4l2_fmtdesc, type, "uint32"); + printmember(v4l2_fmtdesc, flags, "uint32"); + printmember(v4l2_fmtdesc, description, "[32]byte"); + printmember(v4l2_fmtdesc, pixelformat, "uint32"); + printmember(v4l2_fmtdesc, mbus_code, "uint32"); + printmember(v4l2_fmtdesc, reserved, "[3]uint32"); + printf("}\n\n"); + + printstruct(v4l2_frmsizeenum); + printmember(v4l2_frmsizeenum, index, "uint32"); + printmember(v4l2_frmsizeenum, pixel_format, "uint32"); + printmember(v4l2_frmsizeenum, type, "uint32"); + printmember(v4l2_frmsizeenum, discrete, "v4l2_frmsize_discrete"); + printfiller(v4l2_frmsizeenum, discrete); + printf("}\n\n"); + + printstruct(v4l2_frmsize_discrete); + printmember(v4l2_frmsize_discrete, width, "uint32"); + printmember(v4l2_frmsize_discrete, height, "uint32"); + printf("}\n\n"); + + printstruct(v4l2_frmivalenum); + printmember(v4l2_frmivalenum, index, "uint32"); + printmember(v4l2_frmivalenum, pixel_format, "uint32"); + printmember(v4l2_frmivalenum, width, "uint32"); + printmember(v4l2_frmivalenum, height, "uint32"); + printmember(v4l2_frmivalenum, type, "uint32"); + printmember(v4l2_frmivalenum, discrete, "v4l2_fract"); + printfiller(v4l2_frmivalenum, discrete); + printf("}\n\n"); + + return 0; +} \ No newline at end of file diff --git a/pkg/v4l2/device/videodev2_arm.go b/pkg/v4l2/device/videodev2_arm.go new file mode 100644 index 00000000..098ca5a3 --- /dev/null +++ b/pkg/v4l2/device/videodev2_arm.go @@ -0,0 +1,149 @@ +package device + +const ( + VIDIOC_QUERYCAP = 0x80685600 + VIDIOC_ENUM_FMT = 0xc0405602 + VIDIOC_G_FMT = 0xc0cc5604 + VIDIOC_S_FMT = 0xc0cc5605 + VIDIOC_REQBUFS = 0xc0145608 + VIDIOC_QUERYBUF = 0xc0505609 + + VIDIOC_QBUF = 0xc050560f + VIDIOC_DQBUF = 0xc0505611 + VIDIOC_STREAMON = 0x40045612 + VIDIOC_STREAMOFF = 0x40045613 + VIDIOC_G_PARM = 0xc0cc5615 + VIDIOC_S_PARM = 0xc0cc5616 + + VIDIOC_ENUM_FRAMESIZES = 0xc02c564a + VIDIOC_ENUM_FRAMEINTERVALS = 0xc034564b +) + +const ( + V4L2_BUF_TYPE_VIDEO_CAPTURE = 1 + V4L2_COLORSPACE_DEFAULT = 0 + V4L2_FIELD_NONE = 1 + V4L2_FRMIVAL_TYPE_DISCRETE = 1 + V4L2_FRMSIZE_TYPE_DISCRETE = 1 + V4L2_MEMORY_MMAP = 1 +) + +type v4l2_capability struct { // size 104 + driver [16]byte // offset 0, size 16 + card [32]byte // offset 16, size 32 + bus_info [32]byte // offset 48, size 32 + version uint32 // offset 80, size 4 + capabilities uint32 // offset 84, size 4 + device_caps uint32 // offset 88, size 4 + reserved [3]uint32 // offset 92, size 12 +} + +type v4l2_format struct { // size 204 + typ uint32 // offset 0, size 4 + _ [0]byte // align + pix v4l2_pix_format // offset 4, size 48 + _ [152]byte // filler +} + +type v4l2_pix_format struct { // size 48 + width uint32 // offset 0, size 4 + height uint32 // offset 4, size 4 + pixelformat uint32 // offset 8, size 4 + field uint32 // offset 12, size 4 + bytesperline uint32 // offset 16, size 4 + sizeimage uint32 // offset 20, size 4 + colorspace uint32 // offset 24, size 4 + priv uint32 // offset 28, size 4 + flags uint32 // offset 32, size 4 + ycbcr_enc uint32 // offset 36, size 4 + quantization uint32 // offset 40, size 4 + xfer_func uint32 // offset 44, size 4 +} + +type v4l2_streamparm struct { // size 204 + typ uint32 // offset 0, size 4 + capture v4l2_captureparm // offset 4, size 40 + _ [160]byte // filler +} + +type v4l2_captureparm struct { // size 40 + capability uint32 // offset 0, size 4 + capturemode uint32 // offset 4, size 4 + timeperframe v4l2_fract // offset 8, size 8 + extendedmode uint32 // offset 16, size 4 + readbuffers uint32 // offset 20, size 4 + reserved [4]uint32 // offset 24, size 16 +} + +type v4l2_fract struct { // size 8 + numerator uint32 // offset 0, size 4 + denominator uint32 // offset 4, size 4 +} + +type v4l2_requestbuffers struct { // size 20 + count uint32 // offset 0, size 4 + typ uint32 // offset 4, size 4 + memory uint32 // offset 8, size 4 + capabilities uint32 // offset 12, size 4 + flags uint8 // offset 16, size 1 + reserved [3]uint8 // offset 17, size 3 +} + +type v4l2_buffer struct { // size 80 + index uint32 // offset 0, size 4 + typ uint32 // offset 4, size 4 + bytesused uint32 // offset 8, size 4 + flags uint32 // offset 12, size 4 + field uint32 // offset 16, size 4 + _ [20]byte // align + timecode v4l2_timecode // offset 40, size 16 + sequence uint32 // offset 56, size 4 + memory uint32 // offset 60, size 4 + offset uint32 // offset 64, size 4 + _ [0]byte // align + length uint32 // offset 68, size 4 + _ [8]byte // filler +} + +type v4l2_timecode struct { // size 16 + typ uint32 // offset 0, size 4 + flags uint32 // offset 4, size 4 + frames uint8 // offset 8, size 1 + seconds uint8 // offset 9, size 1 + minutes uint8 // offset 10, size 1 + hours uint8 // offset 11, size 1 + userbits [4]uint8 // offset 12, size 4 +} + +type v4l2_fmtdesc struct { // size 64 + index uint32 // offset 0, size 4 + typ uint32 // offset 4, size 4 + flags uint32 // offset 8, size 4 + description [32]byte // offset 12, size 32 + pixelformat uint32 // offset 44, size 4 + mbus_code uint32 // offset 48, size 4 + reserved [3]uint32 // offset 52, size 12 +} + +type v4l2_frmsizeenum struct { // size 44 + index uint32 // offset 0, size 4 + pixel_format uint32 // offset 4, size 4 + typ uint32 // offset 8, size 4 + discrete v4l2_frmsize_discrete // offset 12, size 8 + _ [24]byte // filler +} + +type v4l2_frmsize_discrete struct { // size 8 + width uint32 // offset 0, size 4 + height uint32 // offset 4, size 4 +} + +type v4l2_frmivalenum struct { // size 52 + index uint32 // offset 0, size 4 + pixel_format uint32 // offset 4, size 4 + width uint32 // offset 8, size 4 + height uint32 // offset 12, size 4 + typ uint32 // offset 16, size 4 + discrete v4l2_fract // offset 20, size 8 + _ [24]byte // filler +} diff --git a/pkg/v4l2/device/videodev2_mipsle.go b/pkg/v4l2/device/videodev2_mipsle.go new file mode 100644 index 00000000..cecc54c4 --- /dev/null +++ b/pkg/v4l2/device/videodev2_mipsle.go @@ -0,0 +1,149 @@ +package device + +const ( + VIDIOC_QUERYCAP = 0x40685600 + VIDIOC_ENUM_FMT = 0xc0405602 + VIDIOC_G_FMT = 0xc0cc5604 + VIDIOC_S_FMT = 0xc0cc5605 + VIDIOC_REQBUFS = 0xc0145608 + VIDIOC_QUERYBUF = 0xc0445609 + + VIDIOC_QBUF = 0xc044560f + VIDIOC_DQBUF = 0xc0445611 + VIDIOC_STREAMON = 0x80045612 + VIDIOC_STREAMOFF = 0x80045613 + VIDIOC_G_PARM = 0xc0cc5615 + VIDIOC_S_PARM = 0xc0cc5616 + + VIDIOC_ENUM_FRAMESIZES = 0xc02c564a + VIDIOC_ENUM_FRAMEINTERVALS = 0xc034564b +) + +const ( + V4L2_BUF_TYPE_VIDEO_CAPTURE = 1 + V4L2_COLORSPACE_DEFAULT = 0 + V4L2_FIELD_NONE = 1 + V4L2_FRMIVAL_TYPE_DISCRETE = 1 + V4L2_FRMSIZE_TYPE_DISCRETE = 1 + V4L2_MEMORY_MMAP = 1 +) + +type v4l2_capability struct { // size 104 + driver [16]byte // offset 0, size 16 + card [32]byte // offset 16, size 32 + bus_info [32]byte // offset 48, size 32 + version uint32 // offset 80, size 4 + capabilities uint32 // offset 84, size 4 + device_caps uint32 // offset 88, size 4 + reserved [3]uint32 // offset 92, size 12 +} + +type v4l2_format struct { // size 204 + typ uint32 // offset 0, size 4 + _ [0]byte // align + pix v4l2_pix_format // offset 4, size 48 + _ [152]byte // filler +} + +type v4l2_pix_format struct { // size 48 + width uint32 // offset 0, size 4 + height uint32 // offset 4, size 4 + pixelformat uint32 // offset 8, size 4 + field uint32 // offset 12, size 4 + bytesperline uint32 // offset 16, size 4 + sizeimage uint32 // offset 20, size 4 + colorspace uint32 // offset 24, size 4 + priv uint32 // offset 28, size 4 + flags uint32 // offset 32, size 4 + ycbcr_enc uint32 // offset 36, size 4 + quantization uint32 // offset 40, size 4 + xfer_func uint32 // offset 44, size 4 +} + +type v4l2_streamparm struct { // size 204 + typ uint32 // offset 0, size 4 + capture v4l2_captureparm // offset 4, size 40 + _ [160]byte // filler +} + +type v4l2_captureparm struct { // size 40 + capability uint32 // offset 0, size 4 + capturemode uint32 // offset 4, size 4 + timeperframe v4l2_fract // offset 8, size 8 + extendedmode uint32 // offset 16, size 4 + readbuffers uint32 // offset 20, size 4 + reserved [4]uint32 // offset 24, size 16 +} + +type v4l2_fract struct { // size 8 + numerator uint32 // offset 0, size 4 + denominator uint32 // offset 4, size 4 +} + +type v4l2_requestbuffers struct { // size 20 + count uint32 // offset 0, size 4 + typ uint32 // offset 4, size 4 + memory uint32 // offset 8, size 4 + capabilities uint32 // offset 12, size 4 + flags uint8 // offset 16, size 1 + reserved [3]uint8 // offset 17, size 3 +} + +type v4l2_buffer struct { // size 68 + index uint32 // offset 0, size 4 + typ uint32 // offset 4, size 4 + bytesused uint32 // offset 8, size 4 + flags uint32 // offset 12, size 4 + field uint32 // offset 16, size 4 + _ [8]byte // align + timecode v4l2_timecode // offset 28, size 16 + sequence uint32 // offset 44, size 4 + memory uint32 // offset 48, size 4 + offset uint32 // offset 52, size 4 + _ [0]byte // align + length uint32 // offset 56, size 4 + _ [8]byte // filler +} + +type v4l2_timecode struct { // size 16 + typ uint32 // offset 0, size 4 + flags uint32 // offset 4, size 4 + frames uint8 // offset 8, size 1 + seconds uint8 // offset 9, size 1 + minutes uint8 // offset 10, size 1 + hours uint8 // offset 11, size 1 + userbits [4]uint8 // offset 12, size 4 +} + +type v4l2_fmtdesc struct { // size 64 + index uint32 // offset 0, size 4 + typ uint32 // offset 4, size 4 + flags uint32 // offset 8, size 4 + description [32]byte // offset 12, size 32 + pixelformat uint32 // offset 44, size 4 + mbus_code uint32 // offset 48, size 4 + reserved [3]uint32 // offset 52, size 12 +} + +type v4l2_frmsizeenum struct { // size 44 + index uint32 // offset 0, size 4 + pixel_format uint32 // offset 4, size 4 + typ uint32 // offset 8, size 4 + discrete v4l2_frmsize_discrete // offset 12, size 8 + _ [24]byte // filler +} + +type v4l2_frmsize_discrete struct { // size 8 + width uint32 // offset 0, size 4 + height uint32 // offset 4, size 4 +} + +type v4l2_frmivalenum struct { // size 52 + index uint32 // offset 0, size 4 + pixel_format uint32 // offset 4, size 4 + width uint32 // offset 8, size 4 + height uint32 // offset 12, size 4 + typ uint32 // offset 16, size 4 + discrete v4l2_fract // offset 20, size 8 + _ [24]byte // filler +} diff --git a/pkg/v4l2/device/videodev2_x64.go b/pkg/v4l2/device/videodev2_x64.go new file mode 100644 index 00000000..6e1018e0 --- /dev/null +++ b/pkg/v4l2/device/videodev2_x64.go @@ -0,0 +1,151 @@ +//go:build amd64 || arm64 + +package device + +const ( + VIDIOC_QUERYCAP = 0x80685600 + VIDIOC_ENUM_FMT = 0xc0405602 + VIDIOC_G_FMT = 0xc0d05604 + VIDIOC_S_FMT = 0xc0d05605 + VIDIOC_REQBUFS = 0xc0145608 + VIDIOC_QUERYBUF = 0xc0585609 + + VIDIOC_QBUF = 0xc058560f + VIDIOC_DQBUF = 0xc0585611 + VIDIOC_STREAMON = 0x40045612 + VIDIOC_STREAMOFF = 0x40045613 + VIDIOC_G_PARM = 0xc0cc5615 + VIDIOC_S_PARM = 0xc0cc5616 + + VIDIOC_ENUM_FRAMESIZES = 0xc02c564a + VIDIOC_ENUM_FRAMEINTERVALS = 0xc034564b +) + +const ( + V4L2_BUF_TYPE_VIDEO_CAPTURE = 1 + V4L2_COLORSPACE_DEFAULT = 0 + V4L2_FIELD_NONE = 1 + V4L2_FRMIVAL_TYPE_DISCRETE = 1 + V4L2_FRMSIZE_TYPE_DISCRETE = 1 + V4L2_MEMORY_MMAP = 1 +) + +type v4l2_capability struct { // size 104 + driver [16]byte // offset 0, size 16 + card [32]byte // offset 16, size 32 + bus_info [32]byte // offset 48, size 32 + version uint32 // offset 80, size 4 + capabilities uint32 // offset 84, size 4 + device_caps uint32 // offset 88, size 4 + reserved [3]uint32 // offset 92, size 12 +} + +type v4l2_format struct { // size 208 + typ uint32 // offset 0, size 4 + _ [4]byte // align + pix v4l2_pix_format // offset 8, size 48 + _ [152]byte // filler +} + +type v4l2_pix_format struct { // size 48 + width uint32 // offset 0, size 4 + height uint32 // offset 4, size 4 + pixelformat uint32 // offset 8, size 4 + field uint32 // offset 12, size 4 + bytesperline uint32 // offset 16, size 4 + sizeimage uint32 // offset 20, size 4 + colorspace uint32 // offset 24, size 4 + priv uint32 // offset 28, size 4 + flags uint32 // offset 32, size 4 + ycbcr_enc uint32 // offset 36, size 4 + quantization uint32 // offset 40, size 4 + xfer_func uint32 // offset 44, size 4 +} + +type v4l2_streamparm struct { // size 204 + typ uint32 // offset 0, size 4 + capture v4l2_captureparm // offset 4, size 40 + _ [160]byte // filler +} + +type v4l2_captureparm struct { // size 40 + capability uint32 // offset 0, size 4 + capturemode uint32 // offset 4, size 4 + timeperframe v4l2_fract // offset 8, size 8 + extendedmode uint32 // offset 16, size 4 + readbuffers uint32 // offset 20, size 4 + reserved [4]uint32 // offset 24, size 16 +} + +type v4l2_fract struct { // size 8 + numerator uint32 // offset 0, size 4 + denominator uint32 // offset 4, size 4 +} + +type v4l2_requestbuffers struct { // size 20 + count uint32 // offset 0, size 4 + typ uint32 // offset 4, size 4 + memory uint32 // offset 8, size 4 + capabilities uint32 // offset 12, size 4 + flags uint8 // offset 16, size 1 + reserved [3]uint8 // offset 17, size 3 +} + +type v4l2_buffer struct { // size 88 + index uint32 // offset 0, size 4 + typ uint32 // offset 4, size 4 + bytesused uint32 // offset 8, size 4 + flags uint32 // offset 12, size 4 + field uint32 // offset 16, size 4 + _ [20]byte // align + timecode v4l2_timecode // offset 40, size 16 + sequence uint32 // offset 56, size 4 + memory uint32 // offset 60, size 4 + offset uint32 // offset 64, size 4 + _ [4]byte // align + length uint32 // offset 72, size 4 + _ [12]byte // filler +} + +type v4l2_timecode struct { // size 16 + typ uint32 // offset 0, size 4 + flags uint32 // offset 4, size 4 + frames uint8 // offset 8, size 1 + seconds uint8 // offset 9, size 1 + minutes uint8 // offset 10, size 1 + hours uint8 // offset 11, size 1 + userbits [4]uint8 // offset 12, size 4 +} + +type v4l2_fmtdesc struct { // size 64 + index uint32 // offset 0, size 4 + typ uint32 // offset 4, size 4 + flags uint32 // offset 8, size 4 + description [32]byte // offset 12, size 32 + pixelformat uint32 // offset 44, size 4 + mbus_code uint32 // offset 48, size 4 + reserved [3]uint32 // offset 52, size 12 +} + +type v4l2_frmsizeenum struct { // size 44 + index uint32 // offset 0, size 4 + pixel_format uint32 // offset 4, size 4 + typ uint32 // offset 8, size 4 + discrete v4l2_frmsize_discrete // offset 12, size 8 + _ [24]byte // filler +} + +type v4l2_frmsize_discrete struct { // size 8 + width uint32 // offset 0, size 4 + height uint32 // offset 4, size 4 +} + +type v4l2_frmivalenum struct { // size 52 + index uint32 // offset 0, size 4 + pixel_format uint32 // offset 4, size 4 + width uint32 // offset 8, size 4 + height uint32 // offset 12, size 4 + typ uint32 // offset 16, size 4 + discrete v4l2_fract // offset 20, size 8 + _ [24]byte // filler +} diff --git a/pkg/v4l2/producer.go b/pkg/v4l2/producer.go new file mode 100644 index 00000000..663d0a9e --- /dev/null +++ b/pkg/v4l2/producer.go @@ -0,0 +1,142 @@ +//go:build linux + +package v4l2 + +import ( + "errors" + "net/url" + "strings" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264/annexb" + "github.com/AlexxIT/go2rtc/pkg/v4l2/device" + "github.com/pion/rtp" +) + +type Producer struct { + core.Connection + dev *device.Device +} + +func Open(rawURL string) (*Producer, error) { + // Example (ffmpeg source compatible): + // v4l2:device?video=/dev/video0&input_format=mjpeg&video_size=1280x720 + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + query := u.Query() + + dev, err := device.Open(query.Get("video")) + if err != nil { + return nil, err + } + + codec := &core.Codec{ + ClockRate: 90000, + PayloadType: core.PayloadTypeRAW, + } + + var width, height, pixFmt uint32 + + if wh := strings.Split(query.Get("video_size"), "x"); len(wh) == 2 { + codec.FmtpLine = "width=" + wh[0] + ";height=" + wh[1] + width = uint32(core.Atoi(wh[0])) + height = uint32(core.Atoi(wh[1])) + } + + switch query.Get("input_format") { + case "yuyv422": + if codec.FmtpLine == "" { + return nil, errors.New("v4l2: invalid video_size") + } + codec.Name = core.CodecRAW + codec.FmtpLine += ";colorspace=422" + pixFmt = device.V4L2_PIX_FMT_YUYV + case "nv12": + if codec.FmtpLine == "" { + return nil, errors.New("v4l2: invalid video_size") + } + codec.Name = core.CodecRAW + codec.FmtpLine += ";colorspace=420mpeg2" // maybe 420jpeg + pixFmt = device.V4L2_PIX_FMT_NV12 + case "mjpeg": + codec.Name = core.CodecJPEG + pixFmt = device.V4L2_PIX_FMT_MJPEG + case "h264": + codec.Name = core.CodecH264 + pixFmt = device.V4L2_PIX_FMT_H264 + case "hevc": + codec.Name = core.CodecH265 + pixFmt = device.V4L2_PIX_FMT_HEVC + default: + return nil, errors.New("v4l2: invalid input_format") + } + + if err = dev.SetFormat(width, height, pixFmt); err != nil { + return nil, err + } + + if fps := core.Atoi(query.Get("framerate")); fps > 0 { + if err = dev.SetParam(uint32(fps)); err != nil { + return nil, err + } + } + + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{codec}, + }, + } + return &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "v4l2", + Medias: medias, + }, + dev: dev, + }, nil +} + +func (c *Producer) Start() error { + if err := c.dev.StreamOn(); err != nil { + return err + } + + var bitstream bool + switch c.Medias[0].Codecs[0].Name { + case core.CodecH264, core.CodecH265: + bitstream = true + } + + for { + buf, err := c.dev.Capture() + if err != nil { + return err + } + + c.Recv += len(buf) + + if len(c.Receivers) == 0 { + continue + } + + if bitstream { + buf = annexb.EncodeToAVCC(buf) + } + + pkt := &rtp.Packet{ + Header: rtp.Header{Timestamp: core.Now90000()}, + Payload: buf, + } + c.Receivers[0].WriteRTP(pkt) + } +} + +func (c *Producer) Stop() error { + _ = c.Connection.Stop() + return errors.Join(c.dev.StreamOff(), c.dev.Close()) +} diff --git a/pkg/wav/wav.go b/pkg/wav/producer.go similarity index 89% rename from pkg/wav/wav.go rename to pkg/wav/producer.go index 5f572bd6..63f6d01a 100644 --- a/pkg/wav/wav.go +++ b/pkg/wav/producer.go @@ -54,22 +54,27 @@ func Open(r io.Reader) (*Producer, error) { return nil, errors.New("waw: unsupported codec") } - prod := &Producer{rd: rd, cl: r.(io.Closer)} - prod.Type = "WAV producer" - prod.Medias = []*core.Media{ + medias := []*core.Media{ { Kind: core.KindAudio, Direction: core.DirectionRecvonly, Codecs: []*core.Codec{codec}, }, } - return prod, nil + return &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "wav", + Medias: medias, + Transport: r, + }, + rd: rd, + }, nil } type Producer struct { - core.SuperProducer + core.Connection rd *bufio.Reader - cl io.Closer } func (c *Producer) Start() error { @@ -106,11 +111,6 @@ func (c *Producer) Start() error { } } -func (c *Producer) Stop() error { - _ = c.SuperProducer.Close() - return c.cl.Close() -} - func readChunk(r io.Reader) (chunkID string, data []byte, err error) { b := make([]byte, 8) if _, err = io.ReadFull(r, b); err != nil { diff --git a/pkg/webrtc/api.go b/pkg/webrtc/api.go index f63cabfd..0361e6b4 100644 --- a/pkg/webrtc/api.go +++ b/pkg/webrtc/api.go @@ -2,8 +2,8 @@ package webrtc import ( "net" - "slices" + "github.com/AlexxIT/go2rtc/pkg/core" "github.com/pion/interceptor" "github.com/pion/webrtc/v3" ) @@ -47,7 +47,7 @@ func NewServerAPI(network, address string, filters *Filters) (*webrtc.API, error if filters != nil && filters.Interfaces != nil { s.SetIncludeLoopbackCandidate(true) s.SetInterfaceFilter(func(name string) bool { - return slices.Contains(filters.Interfaces, name) + return core.Contains(filters.Interfaces, name) }) } else { // disable listen on Hassio docker interfaces @@ -59,7 +59,7 @@ func NewServerAPI(network, address string, filters *Filters) (*webrtc.API, error if filters != nil && filters.IPs != nil { s.SetIncludeLoopbackCandidate(true) s.SetIPFilter(func(ip net.IP) bool { - return slices.Contains(filters.IPs, ip.String()) + return core.Contains(filters.IPs, ip.String()) }) } diff --git a/pkg/webrtc/client.go b/pkg/webrtc/client.go index 50c7773d..9a7a7b2f 100644 --- a/pkg/webrtc/client.go +++ b/pkg/webrtc/client.go @@ -71,7 +71,7 @@ func (c *Conn) SetAnswer(answer string) (err error) { return } - c.medias = UnmarshalMedias(sd.MediaDescriptions) + c.Medias = UnmarshalMedias(sd.MediaDescriptions) return nil } diff --git a/pkg/webrtc/conn.go b/pkg/webrtc/conn.go index 0e10874e..5bc16ede 100644 --- a/pkg/webrtc/conn.go +++ b/pkg/webrtc/conn.go @@ -1,6 +1,9 @@ package webrtc import ( + "encoding/json" + "fmt" + "strings" "time" "github.com/AlexxIT/go2rtc/pkg/core" @@ -10,28 +13,26 @@ import ( ) type Conn struct { + core.Connection core.Listener - UserAgent string - Desc string - Mode core.Mode + Mode core.Mode `json:"mode"` pc *webrtc.PeerConnection - medias []*core.Media - receivers []*core.Receiver - senders []*core.Sender - - recv int - send int - offer string - remote string closed core.Waiter } func NewConn(pc *webrtc.PeerConnection) *Conn { - c := &Conn{pc: pc} + c := &Conn{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "webrtc", + Transport: pc, + }, + pc: pc, + } pc.OnICECandidate(func(candidate *webrtc.ICECandidate) { // last candidate will be empty @@ -50,7 +51,15 @@ func NewConn(pc *webrtc.PeerConnection) *Conn { } pc.SCTP().Transport().ICETransport().OnSelectedCandidatePairChange( func(pair *webrtc.ICECandidatePair) { - c.remote = pair.Remote.String() + c.Protocol += "+" + pair.Remote.Protocol.String() + c.RemoteAddr = fmt.Sprintf( + "%s:%d %s", sanitizeIP6(pair.Remote.Address), pair.Remote.Port, pair.Remote.Typ, + ) + if pair.Remote.RelatedAddress != "" { + c.RemoteAddr += fmt.Sprintf( + " %s:%d", sanitizeIP6(pair.Remote.RelatedAddress), pair.Remote.RelatedPort, + ) + } }, ) }) @@ -92,7 +101,7 @@ func NewConn(pc *webrtc.PeerConnection) *Conn { return } - c.recv += n + c.Recv += n packet := &rtp.Packet{} if err := packet.Unmarshal(b[:n]); err != nil { @@ -121,7 +130,7 @@ func NewConn(pc *webrtc.PeerConnection) *Conn { switch state { case webrtc.PeerConnectionStateConnected: - for _, sender := range c.senders { + for _, sender := range c.Senders { sender.Start() } case webrtc.PeerConnectionStateDisconnected, webrtc.PeerConnectionStateFailed, webrtc.PeerConnectionStateClosed: @@ -134,6 +143,10 @@ func NewConn(pc *webrtc.PeerConnection) *Conn { return c } +func (c *Conn) MarshalJSON() ([]byte, error) { + return json.Marshal(c.Connection) +} + func (c *Conn) Close() error { c.closed.Done(nil) return c.pc.Close() @@ -172,7 +185,7 @@ func (c *Conn) getMediaCodec(remote *webrtc.TrackRemote) (*core.Media, *core.Cod } // search Media for this MID - for _, media := range c.medias { + for _, media := range c.Medias { if media.ID != tr.Mid() || media.Direction != core.DirectionRecvonly { continue } @@ -194,3 +207,10 @@ func (c *Conn) getMediaCodec(remote *webrtc.TrackRemote) (*core.Media, *core.Cod return nil, nil } + +func sanitizeIP6(host string) string { + if strings.IndexByte(host, ':') > 0 { + return "[" + host + "]" + } + return host +} diff --git a/pkg/webrtc/consumer.go b/pkg/webrtc/consumer.go index 9d96ef59..e9d7b2e5 100644 --- a/pkg/webrtc/consumer.go +++ b/pkg/webrtc/consumer.go @@ -1,7 +1,6 @@ package webrtc import ( - "encoding/json" "errors" "github.com/AlexxIT/go2rtc/pkg/core" @@ -12,13 +11,13 @@ import ( ) func (c *Conn) GetMedias() []*core.Media { - return WithResampling(c.medias) + return WithResampling(c.Medias) } func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { core.Assert(media.Direction == core.DirectionSendonly) - for _, sender := range c.senders { + for _, sender := range c.Senders { if sender.Codec == codec { sender.Bind(track) return nil @@ -42,7 +41,7 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv sender := core.NewSender(media, codec) sender.Handler = func(packet *rtp.Packet) { - c.send += packet.MarshalSize() + c.Send += packet.MarshalSize() //important to send with remote PayloadType _ = localTrack.WriteRTP(payloadType, packet) } @@ -57,14 +56,18 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv } case core.CodecH265: - // SafariPay because it is the only browser in the world - // that supports WebRTC + H265 - sender.Handler = h265.SafariPay(1200, sender.Handler) + sender.Handler = h265.RTPPay(1200, sender.Handler) if track.Codec.IsRTP() { sender.Handler = h265.RTPDepay(track.Codec, sender.Handler) + } else { + sender.Handler = h265.RepairAVCC(track.Codec, sender.Handler) } case core.CodecPCMA, core.CodecPCMU, core.CodecPCM, core.CodecPCML: + // Fix audio quality https://github.com/AlexxIT/WebRTC/issues/500 + // should be before ResampleToG711, because it will be called last + sender.Handler = pcm.RepackG711(false, sender.Handler) + if codec.ClockRate == 0 { if codec.Name == core.CodecPCM || codec.Name == core.CodecPCML { codec.Name = core.CodecPCMA @@ -72,27 +75,16 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv codec.ClockRate = 8000 sender.Handler = pcm.ResampleToG711(track.Codec, 8000, sender.Handler) } - - // Fix audio quality https://github.com/AlexxIT/WebRTC/issues/500 - sender.Handler = pcm.RepackG711(false, sender.Handler) } - sender.Bind(track) + // TODO: rewrite this dirty logic + // maybe not best solution, but ActiveProducer connected before AddTrack + if c.Mode != core.ModeActiveProducer { + sender.Bind(track) + } else { + sender.HandleRTP(track) + } - c.senders = append(c.senders, sender) + c.Senders = append(c.Senders, sender) return nil } - -func (c *Conn) MarshalJSON() ([]byte, error) { - info := &core.Info{ - Type: c.Desc + " " + c.Mode.String(), - RemoteAddr: c.remote, - UserAgent: c.UserAgent, - Medias: c.medias, - Receivers: c.receivers, - Senders: c.senders, - Recv: c.recv, - Send: c.send, - } - return json.Marshal(info) -} diff --git a/pkg/webrtc/producer.go b/pkg/webrtc/producer.go index d4136a5c..a0910c39 100644 --- a/pkg/webrtc/producer.go +++ b/pkg/webrtc/producer.go @@ -8,7 +8,7 @@ import ( func (c *Conn) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { core.Assert(media.Direction == core.DirectionRecvonly) - for _, track := range c.receivers { + for _, track := range c.Receivers { if track.Codec == codec { return track, nil } @@ -39,7 +39,7 @@ func (c *Conn) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, e } track := core.NewReceiver(media, codec) - c.receivers = append(c.receivers, track) + c.Receivers = append(c.Receivers, track) return track, nil } @@ -47,13 +47,3 @@ func (c *Conn) Start() error { c.closed.Wait() return nil } - -func (c *Conn) Stop() error { - for _, receiver := range c.receivers { - receiver.Close() - } - for _, sender := range c.senders { - sender.Close() - } - return c.pc.Close() -} diff --git a/pkg/webrtc/server.go b/pkg/webrtc/server.go index ce462e45..9cc89778 100644 --- a/pkg/webrtc/server.go +++ b/pkg/webrtc/server.go @@ -42,7 +42,7 @@ func (c *Conn) SetOffer(offer string) (err error) { } } - c.medias = UnmarshalMedias(sd.MediaDescriptions) + c.Medias = UnmarshalMedias(sd.MediaDescriptions) return } @@ -57,7 +57,7 @@ func (c *Conn) GetAnswer() (answer string, err error) { // disable transceivers if we don't have track, make direction=inactive transeivers: for _, tr := range c.pc.GetTransceivers() { - for _, sender := range c.senders { + for _, sender := range c.Senders { if sender.Media.ID == tr.Mid() { continue transeivers } diff --git a/pkg/webtorrent/client.go b/pkg/webtorrent/client.go index de6b21c7..3594679d 100644 --- a/pkg/webtorrent/client.go +++ b/pkg/webtorrent/client.go @@ -3,19 +3,21 @@ package webtorrent import ( "encoding/base64" "fmt" + "strconv" + "time" + "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/webrtc" "github.com/gorilla/websocket" pion "github.com/pion/webrtc/v3" - "strconv" - "time" ) func NewClient(tracker, share, pwd string, pc *pion.PeerConnection) (*webrtc.Conn, error) { // 1. Create WebRTC producer prod := webrtc.NewConn(pc) - prod.Desc = "WebRTC/WebTorrent sync" + prod.FormatName = "webtorrent" prod.Mode = core.ModeActiveProducer + prod.Protocol = "ws" medias := []*core.Media{ {Kind: core.KindVideo, Direction: core.DirectionRecvonly}, diff --git a/pkg/y4m/README.md b/pkg/y4m/README.md index 6f4d863e..ff97813b 100644 --- a/pkg/y4m/README.md +++ b/pkg/y4m/README.md @@ -1,5 +1,19 @@ +## Planar YUV formats + +Packed YUV - yuyv422 - YUYV 4:2:2 +Semi-Planar - nv12 - Y/CbCr 4:2:0 +Planar YUV - yuv420p - Planar YUV 4:2:0 - aka. [cosited](https://manned.org/yuv4mpeg.5) + +``` +[video4linux2,v4l2 @ 0x55fddc42a940] Raw : yuyv422 : YUYV 4:2:2 : 1920x1080 +[video4linux2,v4l2 @ 0x55fddc42a940] Raw : nv12 : Y/CbCr 4:2:0 : 1920x1080 +[video4linux2,v4l2 @ 0x55fddc42a940] Raw : yuv420p : Planar YUV 4:2:0 : 1920x1080 +``` + ## Useful links - https://learn.microsoft.com/en-us/windows/win32/medfound/recommended-8-bit-yuv-formats-for-video-rendering - https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Video_concepts - https://fourcc.org/yuv.php#YV12 +- https://docs.kernel.org/userspace-api/media/v4l/pixfmt-yuv-planar.html +- https://gist.github.com/Jim-Bar/3cbba684a71d1a9d468a6711a6eddbeb diff --git a/pkg/y4m/consumer.go b/pkg/y4m/consumer.go index 01bece31..dd9b46e9 100644 --- a/pkg/y4m/consumer.go +++ b/pkg/y4m/consumer.go @@ -9,14 +9,17 @@ import ( ) type Consumer struct { - core.SuperConsumer + core.Connection wr *core.WriteBuffer } func NewConsumer() *Consumer { + wr := core.NewWriteBuffer(nil) return &Consumer{ - core.SuperConsumer{ - Type: "YUV4MPEG2 passive consumer", + core.Connection{ + ID: core.NewID(), + Transport: wr, + FormatName: "yuv4mpegpipe", Medias: []*core.Media{ { Kind: core.KindVideo, @@ -27,7 +30,7 @@ func NewConsumer() *Consumer { }, }, }, - core.NewWriteBuffer(nil), + wr, } } @@ -60,8 +63,3 @@ func (c *Consumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv func (c *Consumer) WriteTo(wr io.Writer) (int64, error) { return c.wr.WriteTo(wr) } - -func (c *Consumer) Stop() error { - _ = c.SuperConsumer.Close() - return c.wr.Close() -} diff --git a/pkg/y4m/producer.go b/pkg/y4m/producer.go index 05f98a6f..ee2dd731 100644 --- a/pkg/y4m/producer.go +++ b/pkg/y4m/producer.go @@ -2,7 +2,6 @@ package y4m import ( "bufio" - "bytes" "errors" "io" @@ -19,41 +18,13 @@ func Open(r io.Reader) (*Producer, error) { b = b[:len(b)-1] // remove \n - sdp := string(b) - var fmtp string - - for b != nil { - // YUV4MPEG2 W1280 H720 F24:1 Ip A1:1 C420mpeg2 XYSCSS=420MPEG2 - // https://manned.org/yuv4mpeg.5 - // https://github.com/FFmpeg/FFmpeg/blob/master/libavformat/yuv4mpegenc.c - key := b[0] - var value string - if i := bytes.IndexByte(b, ' '); i > 0 { - value = string(b[1:i]) - b = b[i+1:] - } else { - value = string(b[1:]) - b = nil - } - - switch key { - case 'W': - fmtp = "width=" + value - case 'H': - fmtp += ";height=" + value - case 'C': - fmtp += ";colorspace=" + value - } - } + fmtp := ParseHeader(b) if GetSize(fmtp) == 0 { - return nil, errors.New("y4m: unsupported format: " + sdp) + return nil, errors.New("y4m: unsupported format: " + string(b)) } - prod := &Producer{rd: rd, cl: r.(io.Closer)} - prod.Type = "YUV4MPEG2 producer" - prod.SDP = sdp - prod.Medias = []*core.Media{ + medias := []*core.Media{ { Kind: core.KindVideo, Direction: core.DirectionRecvonly, @@ -67,14 +38,21 @@ func Open(r io.Reader) (*Producer, error) { }, }, } - - return prod, nil + return &Producer{ + Connection: core.Connection{ + ID: core.NewID(), + FormatName: "yuv4mpegpipe", + Medias: medias, + SDP: string(b), + Transport: r, + }, + rd: rd, + }, nil } type Producer struct { - core.SuperProducer + core.Connection rd *bufio.Reader - cl io.Closer } func (c *Producer) Start() error { @@ -103,8 +81,3 @@ func (c *Producer) Start() error { c.Receivers[0].WriteRTP(pkt) } } - -func (c *Producer) Stop() error { - _ = c.SuperProducer.Close() - return c.cl.Close() -} diff --git a/pkg/y4m/y4m.go b/pkg/y4m/y4m.go index 8184ea97..24c43164 100644 --- a/pkg/y4m/y4m.go +++ b/pkg/y4m/y4m.go @@ -1,6 +1,7 @@ package y4m import ( + "bytes" "image" "github.com/AlexxIT/go2rtc/pkg/core" @@ -10,6 +11,34 @@ const FourCC = "YUV4" const frameHdr = "FRAME\n" +func ParseHeader(b []byte) (fmtp string) { + for b != nil { + // YUV4MPEG2 W1280 H720 F24:1 Ip A1:1 C420mpeg2 XYSCSS=420MPEG2 + // https://manned.org/yuv4mpeg.5 + // https://github.com/FFmpeg/FFmpeg/blob/master/libavformat/yuv4mpegenc.c + key := b[0] + + var value string + if i := bytes.IndexByte(b, ' '); i > 0 { + value = string(b[1:i]) + b = b[i+1:] + } else { + value = string(b[1:]) + b = nil + } + + switch key { + case 'W': + fmtp = "width=" + value + case 'H': + fmtp += ";height=" + value + case 'C': + fmtp += ";colorspace=" + value + } + } + return +} + func GetSize(fmtp string) int { w := core.Atoi(core.Between(fmtp, "width=", ";")) h := core.Atoi(core.Between(fmtp, "height=", ";")) @@ -94,3 +123,27 @@ func NewImage(fmtp string) func(frame []byte) image.Image { return nil } + +// HasSameColor checks if all pixels has same color +func HasSameColor(img image.Image) bool { + var pix []byte + + switch img := img.(type) { + case *image.Gray: + pix = img.Pix + case *image.YCbCr: + pix = img.Y + } + + if len(pix) == 0 { + return false + } + + i0 := pix[0] + for _, i := range pix { + if i != i0 { + return false + } + } + return true +} diff --git a/scripts/README.md b/scripts/README.md index b893b312..acc6e0c9 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,3 +1,11 @@ +## Versions + +[Go 1.20](https://go.dev/doc/go1.20) is last version with support Windows 7 and macOS 10.13. +Go 1.21 support only Windows 10 and macOS 10.15. + +So we will set `go 1.20` (minimum version) inside `go.mod` file. And will use env `GOTOOLCHAIN=go1.20.14` for building +`win32` and `mac_amd64` binaries. All other binaries will use latest go version. + ## Build - UPX-3.96 pack broken bin for `linux_mipsel` @@ -14,6 +22,7 @@ go get -u go mod tidy go mod why github.com/pion/rtcp go list -deps .\cmd\go2rtc_rtsp\ +./goweight ``` ## Dependencies @@ -31,6 +40,7 @@ go list -deps .\cmd\go2rtc_rtsp\ - github.com/sigurn/crc8 - github.com/pion/ice/v2 - github.com/google/uuid + - github.com/wlynxg/anet - github.com/rs/zerolog - github.com/mattn/go-colorable - github.com/mattn/go-isatty @@ -44,6 +54,41 @@ go list -deps .\cmd\go2rtc_rtsp\ - golang.org/x/tools ``` +## Licenses + +- github.com/asticode/go-astits - MIT +- github.com/expr-lang/expr - MIT +- github.com/gorilla/websocket - BSD-2 +- github.com/mattn/go-isatty - MIT +- github.com/miekg/dns - BSD-3 +- github.com/pion/ice/v2 - MIT +- github.com/pion/interceptor - MIT +- github.com/pion/rtcp - MIT +- github.com/pion/rtp - MIT +- github.com/pion/sdp/v3 - MIT +- github.com/pion/srtp/v2 - MIT +- github.com/pion/stun - MIT +- github.com/pion/webrtc/v3 - MIT +- github.com/rs/zerolog - MIT +- github.com/sigurn/crc16 - MIT +- github.com/sigurn/crc8 - MIT +- github.com/stretchr/testify - MIT +- github.com/tadglines/go-pkgs - Apache +- golang.org/x/crypto - BSD-3 +- gopkg.in/yaml.v3 - MIT and Apache +- github.com/asticode/go-astikit - MIT +- github.com/davecgh/go-spew - ISC (BSD/MIT like) +- github.com/google/uuid - BSD-3 +- github.com/kr/pretty - MIT +- github.com/mattn/go-colorable - MIT +- github.com/pmezard/go-difflib - ??? +- github.com/wlynxg/anet - BSD-3 +- golang.org/x/mod - BSD-3 +- golang.org/x/net - BSD-3 +- golang.org/x/sync - BSD-3 +- golang.org/x/sys - BSD-3 +- golang.org/x/tools - BSD-3 + ## Virus - https://go.dev/doc/faq#virus diff --git a/scripts/build.cmd b/scripts/build.cmd index 54565b2d..85dd9531 100644 --- a/scripts/build.cmd +++ b/scripts/build.cmd @@ -1,15 +1,18 @@ @ECHO OFF +@SET GOTOOLCHAIN= @SET GOOS=windows @SET GOARCH=amd64 @SET FILENAME=go2rtc_win64.zip go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc.exe +@SET GOTOOLCHAIN=go1.20.14 @SET GOOS=windows @SET GOARCH=386 @SET FILENAME=go2rtc_win32.zip go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc.exe +@SET GOTOOLCHAIN= @SET GOOS=windows @SET GOARCH=arm64 @SET FILENAME=go2rtc_win_arm64.zip @@ -47,12 +50,24 @@ go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME% @SET FILENAME=go2rtc_linux_mipsel go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME% +@SET GOTOOLCHAIN=go1.20.14 @SET GOOS=darwin @SET GOARCH=amd64 @SET FILENAME=go2rtc_mac_amd64.zip go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc +@SET GOTOOLCHAIN= @SET GOOS=darwin @SET GOARCH=arm64 @SET FILENAME=go2rtc_mac_arm64.zip go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc + +@SET GOOS=freebsd +@SET GOARCH=amd64 +@SET FILENAME=go2rtc_freebsd_amd64.zip +go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc + +@SET GOOS=freebsd +@SET GOARCH=arm64 +@SET FILENAME=go2rtc_freebsd_arm64.zip +go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc diff --git a/scripts/build.sh b/scripts/build.sh index 0814ba48..e365eb54 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -79,4 +79,16 @@ go build -ldflags "-s -w" -trimpath && 7z a -mx9 -bso0 -sdel $FILENAME go2rtc export GOOS=darwin export GOARCH=arm64 FILENAME="go2rtc_mac_arm64.zip" -go build -ldflags "-s -w" -trimpath && 7z a -mx9 -bso0 -sdel $FILENAME go2rtc \ No newline at end of file +go build -ldflags "-s -w" -trimpath && 7z a -mx9 -bso0 -sdel $FILENAME go2rtc + +# FreeBSD amd64 +export GOOS=freebsd +export GOARCH=amd64 +FILENAME="go2rtc_freebsd_amd64.zip" +go build -ldflags "-s -w" -trimpath && 7z a -mx9 -bso0 -sdel $FILENAME go2rtc + +# FreeBSD arm64 +export GOOS=freebsd +export GOARCH=arm64 +FILENAME="go2rtc_freebsd_arm64.zip" +go build -ldflags "-s -w" -trimpath && 7z a -mx9 -bso0 -sdel $FILENAME go2rtc diff --git a/www/add.html b/www/add.html index 4b40f431..cec8ed36 100644 --- a/www/add.html +++ b/www/add.html @@ -35,7 +35,7 @@ function drawTable(table, data) { const cols = ['id', 'name', 'info', 'url', 'location']; const th = (row) => cols.reduce((html, k) => k in row ? `${html}${k}` : html, '') + ''; - const td = (row) => cols.reduce((html, k) => k in row ? `${html}${row[k]}` : html, '') + ''; + const td = (row) => cols.reduce((html, k) => k in row ? `${html}${row[k]}` : html, '') + ''; const thead = th(data.sources[0]); const tbody = data.sources.reduce((html, source) => `${html}${td(source)}`, ''); @@ -218,6 +218,55 @@ }); + +
+
+ + + + +
+
+ + +
+
+
+
@@ -292,6 +341,18 @@ + +
+
+
+ + +
diff --git a/www/index.html b/www/index.html index 6adf9f0a..63fedcec 100644 --- a/www/index.html +++ b/www/index.html @@ -139,7 +139,7 @@ const isChecked = checkboxStates[name] ? 'checked' : ''; tr.innerHTML = `` + - `${online} / info / probe` + + `${online} / info / probe / net` + `${links}`; } diff --git a/www/main.js b/www/main.js index 2c15e071..714c9127 100644 --- a/www/main.js +++ b/www/main.js @@ -138,6 +138,7 @@ body.dark-mode hr {
  • Add
  • Config
  • Log
  • +
  • Net
  • 🌙 diff --git a/www/network.html b/www/network.html new file mode 100644 index 00000000..79875012 --- /dev/null +++ b/www/network.html @@ -0,0 +1,83 @@ + + + + + go2rtc - Network + + + + +
    + + + + diff --git a/www/video-rtc.js b/www/video-rtc.js index 52fb5dda..fb872b45 100644 --- a/www/video-rtc.js +++ b/www/video-rtc.js @@ -439,24 +439,30 @@ export class VideoRTC extends HTMLElement { const sb = ms.addSourceBuffer(msg.value); sb.mode = 'segments'; // segments or sequence sb.addEventListener('updateend', () => { - if (sb.updating) return; - - try { - if (bufLen > 0) { + if (!sb.updating && bufLen > 0) { + try { const data = buf.slice(0, bufLen); - bufLen = 0; sb.appendBuffer(data); - } else if (sb.buffered && sb.buffered.length) { - const end = sb.buffered.end(sb.buffered.length - 1) - 15; - const start = sb.buffered.start(0); - if (end > start) { - sb.remove(start, end); - ms.setLiveSeekableRange(end, end + 15); - } - // console.debug("VideoRTC.buffered", start, end); + bufLen = 0; + } catch (e) { + // console.debug(e); } - } catch (e) { - // console.debug(e); + } + + if (!sb.updating && sb.buffered && sb.buffered.length) { + const end = sb.buffered.end(sb.buffered.length - 1); + const start = end - 5; + const start0 = sb.buffered.start(0); + if (start > start0) { + sb.remove(start0, start); + ms.setLiveSeekableRange(start, end); + } + if (this.video.currentTime < start) { + this.video.currentTime = start; + } + const gap = end - this.video.currentTime; + this.video.playbackRate = gap > 0.1 ? gap : 0.1; + // console.debug('VideoRTC.buffered', gap, this.video.playbackRate, this.video.readyState); } }); @@ -468,7 +474,7 @@ export class VideoRTC extends HTMLElement { const b = new Uint8Array(data); buf.set(b, bufLen); bufLen += b.byteLength; - // console.debug("VideoRTC.buffer", b.byteLength, bufLen); + // console.debug('VideoRTC.buffer', b.byteLength, bufLen); } else { try { sb.appendBuffer(data);