Merge branch 'AlexxIT:master' into onvif-client
This commit is contained in:
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with: { go-version: '1.22' }
|
||||
with: { go-version: '1.24' }
|
||||
|
||||
- name: Build go2rtc_win64
|
||||
env: { GOOS: windows, GOARCH: amd64 }
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
go-version: '1.24'
|
||||
|
||||
- name: Build Go binary
|
||||
run: go build -ldflags "-s -w" -trimpath -o ./go2rtc
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
go2rtc.yaml
|
||||
go2rtc.json
|
||||
|
||||
go2rtc_freebsd*
|
||||
go2rtc_linux*
|
||||
go2rtc_mac*
|
||||
go2rtc_win*
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
# 0. Prepare images
|
||||
ARG PYTHON_VERSION="3.11"
|
||||
ARG GO_VERSION="1.22"
|
||||
ARG GO_VERSION="1.24"
|
||||
|
||||
|
||||
# 1. Download ngrok binary (for support arm/v6)
|
||||
@@ -42,7 +42,7 @@ FROM python:${PYTHON_VERSION}-alpine AS base
|
||||
# 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
|
||||
|
||||
@@ -126,6 +126,8 @@ Download binary for your OS from [latest release](https://github.com/AlexxIT/go2
|
||||
- `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` - 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.
|
||||
|
||||
@@ -231,7 +233,7 @@ streams:
|
||||
sonoff_camera: rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0
|
||||
dahua_camera:
|
||||
- rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif
|
||||
- rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=1
|
||||
- rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=1#backchannel=0
|
||||
amcrest_doorbell:
|
||||
- rtsp://username:password@192.168.1.123:554/cam/realmonitor?channel=1&subtype=0#backchannel=0
|
||||
unifi_camera: rtspx://192.168.1.123:7441/fD6ouM72bWoFijxK
|
||||
@@ -241,7 +243,7 @@ streams:
|
||||
**Recommendations**
|
||||
|
||||
- **Amcrest Doorbell** users may want to disable two way audio, because with an active stream you won't have a call button working. You need to add `#backchannel=0` to the end of your RTSP link in YAML config file
|
||||
- **Dahua Doorbell** users may want to change backchannel [audio codec](https://github.com/AlexxIT/go2rtc/issues/52)
|
||||
- **Dahua Doorbell** users may want to change [audio codec](https://github.com/AlexxIT/go2rtc/issues/49#issuecomment-2127107379) for proper 2-way audio. Make sure not to request backchannel multiple times by adding `#backchannel=0` to other stream sources of the same doorbell. The `unicast=true&proto=Onvif` is preferred for 2-way audio as this makes the doorbell accept multiple codecs for the incoming audio
|
||||
- **Reolink** users may want NOT to use RTSP protocol at all, some camera models have a very awful unusable stream implementation
|
||||
- **Ubiquiti UniFi** users may want to disable HTTPS verification. Use `rtspx://` prefix instead of `rtsps://`. And don't use `?enableSrtp` [suffix](https://github.com/AlexxIT/go2rtc/issues/81)
|
||||
- **TP-Link Tapo** users may skip login and password, because go2rtc support login [without them](https://drmnsamoliu.github.io/video.html)
|
||||
@@ -350,7 +352,7 @@ streams:
|
||||
mjpeg: ffmpeg:http://185.97.122.128/cgi-bin/faststream.jpg#video=h264
|
||||
|
||||
# [RTSP] video with rotation, should be transcoded, so select H264
|
||||
rotate: ffmpeg:rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0#video=h264#rotate=90
|
||||
rotate: ffmpeg:rtsp://12345678@192.168.1.123/av_stream/ch0#video=h264#rotate=90
|
||||
```
|
||||
|
||||
All trascoding formats has [built-in templates](https://github.com/AlexxIT/go2rtc/blob/master/internal/ffmpeg/ffmpeg.go): `h264`, `h265`, `opus`, `pcmu`, `pcmu/16000`, `pcmu/48000`, `pcma`, `pcma/16000`, `pcma/48000`, `aac`, `aac/16000`.
|
||||
@@ -680,13 +682,18 @@ Supports connection to [Wyze](https://www.wyze.com/) cameras, using WebRTC proto
|
||||
|
||||
Supports [Amazon Kinesis Video Streams](https://aws.amazon.com/kinesis/video-streams/), using WebRTC protocol. You need to specify signalling WebSocket URL with all credentials in query params, `client_id` and `ice_servers` list in [JSON format](https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer).
|
||||
|
||||
**switchbot**
|
||||
|
||||
Support connection to [SwitchBot](https://us.switch-bot.com/) cameras that are based on Kinesis Video Streams. Specifically, this includes [Pan/Tilt Cam Plus 2K](https://us.switch-bot.com/pages/switchbot-pan-tilt-cam-plus-2k) and [Pan/Tilt Cam Plus 3K](https://us.switch-bot.com/pages/switchbot-pan-tilt-cam-plus-3k). `Outdoor Spotlight Cam 1080P`, `Outdoor Spotlight Cam 2K`, `Pan/Tilt Cam`, `Pan/Tilt Cam 2K`, `Indoor Cam` are based on Tuya, so this feature is not available.
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
webrtc-whep: webrtc:http://192.168.1.123:1984/api/webrtc?src=camera1
|
||||
webrtc-go2rtc: webrtc:ws://192.168.1.123:1984/api/ws?src=camera1
|
||||
webrtc-openipc: webrtc:ws://192.168.1.123/webrtc_ws#format=openipc#ice_servers=[{"urls":"stun:stun.kinesisvideo.eu-north-1.amazonaws.com:443"}]
|
||||
webrtc-wyze: webrtc:http://192.168.1.123:5000/signaling/camera1?kvs#format=wyze
|
||||
webrtc-kinesis: webrtc:wss://...amazonaws.com/?...#format=kinesis#client_id=...#ice_servers=[{...},{...}]
|
||||
webrtc-whep: webrtc:http://192.168.1.123:1984/api/webrtc?src=camera1
|
||||
webrtc-go2rtc: webrtc:ws://192.168.1.123:1984/api/ws?src=camera1
|
||||
webrtc-openipc: webrtc:ws://192.168.1.123/webrtc_ws#format=openipc#ice_servers=[{"urls":"stun:stun.kinesisvideo.eu-north-1.amazonaws.com:443"}]
|
||||
webrtc-wyze: webrtc:http://192.168.1.123:5000/signaling/camera1?kvs#format=wyze
|
||||
webrtc-kinesis: webrtc:wss://...amazonaws.com/?...#format=kinesis#client_id=...#ice_servers=[{...},{...}]
|
||||
webrtc-switchbot: webrtc:wss://...amazonaws.com/?...#format=switchbot#resolution=hd#client_id=...#ice_servers=[{...},{...}]
|
||||
```
|
||||
|
||||
**PS.** For `kinesis` sources you can use [echo](#source-echo) to get connection params using `bash`/`python` or any other script language.
|
||||
@@ -881,7 +888,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:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
## Example
|
||||
|
||||
```shell
|
||||
go run examples/onvif_client/main.go http://admin:password@192.168.10.90 GetAudioEncoderConfigurations
|
||||
```
|
||||
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
@@ -41,7 +40,13 @@ func main() {
|
||||
onvif.DeviceGetSystemDateAndTime,
|
||||
onvif.DeviceSystemReboot:
|
||||
b, err = client.DeviceRequest(operation)
|
||||
case onvif.MediaGetProfiles, onvif.MediaGetVideoSources:
|
||||
case onvif.MediaGetProfiles,
|
||||
onvif.MediaGetVideoEncoderConfigurations,
|
||||
onvif.MediaGetVideoSources,
|
||||
onvif.MediaGetVideoSourceConfigurations,
|
||||
onvif.MediaGetAudioEncoderConfigurations,
|
||||
onvif.MediaGetAudioSources,
|
||||
onvif.MediaGetAudioSourceConfigurations:
|
||||
b, err = client.MediaRequest(operation)
|
||||
case onvif.MediaGetProfile:
|
||||
b, err = client.GetProfile(token)
|
||||
@@ -64,9 +69,7 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
host, _, _ := net.SplitHostPort(u.Host)
|
||||
|
||||
if err = os.WriteFile(host+"_"+operation+".xml", b, 0644); err != nil {
|
||||
if err = os.WriteFile(u.Hostname()+"_"+operation+".xml", b, 0644); err != nil {
|
||||
log.Printf("%s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,15 @@ go 1.20
|
||||
require (
|
||||
github.com/asticode/go-astits v1.13.0
|
||||
github.com/expr-lang/expr v1.16.9
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/mattn/go-isatty v0.0.20
|
||||
github.com/miekg/dns v1.1.62
|
||||
github.com/miekg/dns v1.1.63
|
||||
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/rtp v1.8.11
|
||||
github.com/pion/sdp/v3 v3.0.10
|
||||
github.com/pion/srtp/v2 v2.0.20
|
||||
github.com/pion/stun v0.6.1
|
||||
github.com/pion/webrtc/v3 v3.3.5
|
||||
@@ -21,30 +22,29 @@ require (
|
||||
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9
|
||||
golang.org/x/crypto v0.31.0
|
||||
golang.org/x/crypto v0.33.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/asticode/go-astikit v0.45.0 // indirect
|
||||
github.com/asticode/go-astikit v0.52.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // 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/logging v0.2.3 // indirect
|
||||
github.com/pion/mdns v0.0.12 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/sctp v1.8.35 // indirect
|
||||
github.com/pion/sctp v1.8.36 // 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
|
||||
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/net v0.35.0 // indirect
|
||||
golang.org/x/sync v0.11.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/tools v0.24.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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-astikit v0.52.0 h1:kTl2XjgiVQhUl1H7kim7NhmTtCMwVBbPrXKqhQhbk8Y=
|
||||
github.com/asticode/go-astikit v0.52.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE=
|
||||
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=
|
||||
@@ -23,14 +23,15 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
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-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
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.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
|
||||
github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
|
||||
github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY=
|
||||
github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs=
|
||||
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=
|
||||
@@ -40,8 +41,9 @@ 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/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
|
||||
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
|
||||
github.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8=
|
||||
github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
@@ -50,12 +52,12 @@ github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9
|
||||
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.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/rtp v1.8.11 h1:17xjnY5WO5hgO6SD3/NTIUPvSFw/PbLsIJyz1r1yNIk=
|
||||
github.com/pion/rtp v1.8.11/go.mod h1:8uMBJj32Pa1wwx8Fuv/AsFhn8jsgw+3rUC2PfoBZ8p4=
|
||||
github.com/pion/sctp v1.8.36 h1:owNudmnz1xmhfYje5L/FCav3V9wpPRePHle3Zi+P+M0=
|
||||
github.com/pion/sctp v1.8.36/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
|
||||
github.com/pion/sdp/v3 v3.0.10 h1:6MChLE/1xYB+CjumMw+gZ9ufp2DPApuVSnDT8t5MIgA=
|
||||
github.com/pion/sdp/v3 v3.0.10/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
|
||||
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=
|
||||
@@ -90,13 +92,11 @@ github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f/go.mod h1:vQhwQ4meQEDf
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
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/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=
|
||||
@@ -110,13 +110,12 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
|
||||
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
||||
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.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
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.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=
|
||||
@@ -125,13 +124,13 @@ 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.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
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/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
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.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.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=
|
||||
@@ -146,8 +145,8 @@ golang.org/x/sys v0.9.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.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.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=
|
||||
@@ -166,14 +165,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
|
||||
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.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=
|
||||
|
||||
+1
-1
@@ -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.22-bookworm"
|
||||
ARG GO_VERSION="1.24-bookworm"
|
||||
ARG NGROK_VERSION="3"
|
||||
|
||||
FROM debian:${DEBIAN_VERSION} AS base
|
||||
|
||||
+2
-4
@@ -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
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/www"
|
||||
"net/http"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/www"
|
||||
)
|
||||
|
||||
func initStatic(staticDir string) {
|
||||
|
||||
@@ -18,7 +18,7 @@ func LoadConfig(v any) {
|
||||
}
|
||||
}
|
||||
|
||||
func PatchConfig(key string, value any, path ...string) error {
|
||||
func PatchConfig(path []string, value any) error {
|
||||
if ConfigPath == "" {
|
||||
return errors.New("config file disabled")
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func PatchConfig(key string, value any, path ...string) error {
|
||||
// empty config is OK
|
||||
b, _ := os.ReadFile(ConfigPath)
|
||||
|
||||
b, err := yaml.Patch(b, key, value, path...)
|
||||
b, err := yaml.Patch(b, path, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+33
-15
@@ -3,12 +3,14 @@ package app
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
var MemoryLog = newBuffer(16)
|
||||
var MemoryLog = newBuffer()
|
||||
|
||||
func GetLogger(module string) zerolog.Logger {
|
||||
if s, ok := modules[module]; ok {
|
||||
@@ -38,11 +40,17 @@ func initLogger() {
|
||||
|
||||
var writer io.Writer
|
||||
|
||||
switch modules["output"] {
|
||||
switch output, path, _ := strings.Cut(modules["output"], ":"); output {
|
||||
case "stderr":
|
||||
writer = os.Stderr
|
||||
case "stdout":
|
||||
writer = os.Stdout
|
||||
case "file":
|
||||
if path == "" {
|
||||
path = "go2rtc.log"
|
||||
}
|
||||
// if fail - only MemoryLog will be available
|
||||
writer, _ = os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
}
|
||||
|
||||
timeFormat := modules["time"]
|
||||
@@ -99,15 +107,19 @@ var modules = map[string]string{
|
||||
"time": zerolog.TimeFormatUnixMs,
|
||||
}
|
||||
|
||||
const chunkSize = 1 << 16
|
||||
const (
|
||||
chunkCount = 16
|
||||
chunkSize = 1 << 16
|
||||
)
|
||||
|
||||
type circularBuffer struct {
|
||||
chunks [][]byte
|
||||
r, w int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newBuffer(chunks int) *circularBuffer {
|
||||
b := &circularBuffer{chunks: make([][]byte, 0, chunks)}
|
||||
func newBuffer() *circularBuffer {
|
||||
b := &circularBuffer{chunks: make([][]byte, 0, chunkCount)}
|
||||
// create first chunk
|
||||
b.chunks = append(b.chunks, make([]byte, 0, chunkSize))
|
||||
return b
|
||||
@@ -116,16 +128,17 @@ func newBuffer(chunks int) *circularBuffer {
|
||||
func (b *circularBuffer) Write(p []byte) (n int, err error) {
|
||||
n = len(p)
|
||||
|
||||
b.mu.Lock()
|
||||
// check if chunk has size
|
||||
if len(b.chunks[b.w])+n > chunkSize {
|
||||
// increase write chunk index
|
||||
if b.w++; b.w == cap(b.chunks) {
|
||||
if b.w++; b.w == chunkCount {
|
||||
b.w = 0
|
||||
}
|
||||
// check overflow
|
||||
if b.r == b.w {
|
||||
// increase read chunk index
|
||||
if b.r++; b.r == cap(b.chunks) {
|
||||
if b.r++; b.r == chunkCount {
|
||||
b.r = 0
|
||||
}
|
||||
}
|
||||
@@ -140,29 +153,34 @@ func (b *circularBuffer) Write(p []byte) (n int, err error) {
|
||||
}
|
||||
|
||||
b.chunks[b.w] = append(b.chunks[b.w], p...)
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
func (b *circularBuffer) WriteTo(w io.Writer) (n int64, err error) {
|
||||
for i := b.r; ; {
|
||||
var nn int
|
||||
if nn, err = w.Write(b.chunks[i]); err != nil {
|
||||
return
|
||||
}
|
||||
n += int64(nn)
|
||||
buf := make([]byte, 0, chunkCount*chunkSize)
|
||||
|
||||
// use temp buffer inside mutex because w.Write can take some time
|
||||
b.mu.Lock()
|
||||
for i := b.r; ; {
|
||||
buf = append(buf, b.chunks[i]...)
|
||||
if i == b.w {
|
||||
break
|
||||
}
|
||||
if i++; i == cap(b.chunks) {
|
||||
if i++; i == chunkCount {
|
||||
i = 0
|
||||
}
|
||||
}
|
||||
return
|
||||
b.mu.Unlock()
|
||||
|
||||
nn, err := w.Write(buf)
|
||||
return int64(nn), err
|
||||
}
|
||||
|
||||
func (b *circularBuffer) Reset() {
|
||||
b.mu.Lock()
|
||||
b.chunks[0] = b.chunks[0][:0]
|
||||
b.r = 0
|
||||
b.w = 0
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
// closer support custom killsignal with custom killtimeout
|
||||
type closer struct {
|
||||
cmd *exec.Cmd
|
||||
query url.Values
|
||||
}
|
||||
|
||||
func (c *closer) Close() (err error) {
|
||||
sig := os.Kill
|
||||
if s := c.query.Get("killsignal"); s != "" {
|
||||
sig = syscall.Signal(core.Atoi(s))
|
||||
}
|
||||
|
||||
log.Trace().Msgf("[exec] kill with signal=%d", sig)
|
||||
err = c.cmd.Process.Signal(sig)
|
||||
|
||||
if s := c.query.Get("killtimeout"); s != "" {
|
||||
timeout := time.Duration(core.Atoi(s)) * time.Second
|
||||
timer := time.AfterFunc(timeout, func() {
|
||||
log.Trace().Msgf("[exec] kill after timeout=%s", s)
|
||||
_ = c.cmd.Process.Kill()
|
||||
})
|
||||
defer timer.Stop() // stop timer if Wait ends before timeout
|
||||
}
|
||||
|
||||
return errors.Join(err, c.cmd.Wait())
|
||||
}
|
||||
+37
-23
@@ -9,9 +9,9 @@ import (
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
@@ -49,7 +49,7 @@ 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)
|
||||
|
||||
@@ -67,39 +67,55 @@ func execHandle(rawURL string) (core.Producer, error) {
|
||||
rawURL = rawURL[:i] + "rtsp://127.0.0.1:" + rtsp.Port + path + rawURL[i+8:]
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
|
||||
if s := query.Get("killtimeout"); s != "" {
|
||||
cmd.WaitDelay = time.Duration(core.Atoi(s)) * time.Second
|
||||
}
|
||||
|
||||
if query.Get("backchannel") == "1" {
|
||||
return stdin.NewClient(cmd)
|
||||
}
|
||||
|
||||
cl := &closer{cmd: cmd, query: query}
|
||||
|
||||
if path == "" {
|
||||
return handlePipe(rawURL, cmd, cl)
|
||||
prod, err = handlePipe(rawURL, cmd)
|
||||
} else {
|
||||
prod, err = handleRTSP(rawURL, cmd, path)
|
||||
}
|
||||
|
||||
return handleRTSP(rawURL, cmd, cl, path)
|
||||
if err != nil {
|
||||
_ = cmd.Close()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func handlePipe(source string, cmd *exec.Cmd, cl io.Closer) (core.Producer, error) {
|
||||
func handlePipe(source string, cmd *shell.Command) (core.Producer, error) {
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rc := struct {
|
||||
rd := struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}{
|
||||
// add buffer for pipe reader to reduce syscall
|
||||
bufio.NewReaderSize(stdout, core.BufferSize),
|
||||
cl,
|
||||
// stop cmd on close pipe call
|
||||
cmd,
|
||||
}
|
||||
|
||||
log.Debug().Strs("args", cmd.Args).Msg("[exec] run pipe")
|
||||
@@ -110,9 +126,8 @@ func handlePipe(source string, cmd *exec.Cmd, cl io.Closer) (core.Producer, erro
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prod, err := magic.Open(rc)
|
||||
prod, err := magic.Open(rd)
|
||||
if err != nil {
|
||||
_ = rc.Close()
|
||||
return nil, fmt.Errorf("exec/pipe: %w\n%s", err, cmd.Stderr)
|
||||
}
|
||||
|
||||
@@ -126,7 +141,7 @@ func handlePipe(source string, cmd *exec.Cmd, cl io.Closer) (core.Producer, erro
|
||||
return prod, nil
|
||||
}
|
||||
|
||||
func handleRTSP(source string, cmd *exec.Cmd, cl io.Closer, 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
|
||||
}
|
||||
@@ -152,23 +167,22 @@ func handleRTSP(source string, cmd *exec.Cmd, cl io.Closer, path string) (core.P
|
||||
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.Minute):
|
||||
case <-timeout.C:
|
||||
// haven't received data from app in timeout
|
||||
log.Error().Str("source", source).Msg("[exec] timeout")
|
||||
_ = cl.Close()
|
||||
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 = cl.Close
|
||||
prod.OnClose = cmd.Close
|
||||
return prod, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ func apiPair(id, url string) error {
|
||||
|
||||
streams.New(id, conn.URL())
|
||||
|
||||
return app.PatchConfig(id, conn.URL(), "streams")
|
||||
return app.PatchConfig([]string{"streams", id}, conn.URL())
|
||||
}
|
||||
|
||||
func apiUnpair(id string) error {
|
||||
@@ -112,7 +112,7 @@ func apiUnpair(id string) error {
|
||||
return errors.New(api.StreamNotFound)
|
||||
}
|
||||
|
||||
rawURL := findHomeKitURL(stream)
|
||||
rawURL := findHomeKitURL(stream.Sources())
|
||||
if rawURL == "" {
|
||||
return errors.New("not homekit source")
|
||||
}
|
||||
@@ -123,15 +123,15 @@ func apiUnpair(id string) error {
|
||||
|
||||
streams.Delete(id)
|
||||
|
||||
return app.PatchConfig(id, nil, "streams")
|
||||
return app.PatchConfig([]string{"streams", id}, nil)
|
||||
}
|
||||
|
||||
func findHomeKitURLs() map[string]*url.URL {
|
||||
urls := map[string]*url.URL{}
|
||||
for id, stream := range streams.Streams() {
|
||||
if rawURL := findHomeKitURL(stream); rawURL != "" {
|
||||
for name, sources := range streams.GetAllSources() {
|
||||
if rawURL := findHomeKitURL(sources); rawURL != "" {
|
||||
if u, err := url.Parse(rawURL); err == nil {
|
||||
urls[id] = u
|
||||
urls[name] = u
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+27
-24
@@ -79,7 +79,7 @@ func Init() {
|
||||
Handler: homekit.ServerHandler(srv),
|
||||
}
|
||||
|
||||
if url := findHomeKitURL(stream); url != "" {
|
||||
if url := findHomeKitURL(stream.Sources()); url != "" {
|
||||
// 1. Act as transparent proxy for HomeKit camera
|
||||
dial := func() (net.Conn, error) {
|
||||
client, err := homekit.Dial(url, srtp.Server)
|
||||
@@ -118,8 +118,8 @@ func Init() {
|
||||
servers[host] = srv
|
||||
}
|
||||
|
||||
api.HandleFunc(hap.PathPairSetup, hapPairSetup)
|
||||
api.HandleFunc(hap.PathPairVerify, hapPairVerify)
|
||||
api.HandleFunc(hap.PathPairSetup, hapHandler)
|
||||
api.HandleFunc(hap.PathPairVerify, hapHandler)
|
||||
|
||||
log.Trace().Msgf("[homekit] mdns: %s", entries)
|
||||
|
||||
@@ -148,13 +148,19 @@ func streamHandler(rawURL string) (core.Producer, error) {
|
||||
return client, err
|
||||
}
|
||||
|
||||
func hapPairSetup(w http.ResponseWriter, r *http.Request) {
|
||||
srv, ok := servers[r.Host]
|
||||
if !ok {
|
||||
log.Error().Msg("[homekit] unknown host: " + r.Host)
|
||||
return
|
||||
func resolve(host string) *server {
|
||||
if len(servers) == 1 {
|
||||
for _, srv := range servers {
|
||||
return srv
|
||||
}
|
||||
}
|
||||
if srv, ok := servers[host]; ok {
|
||||
return srv
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hapHandler(w http.ResponseWriter, r *http.Request) {
|
||||
conn, rw, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
@@ -162,32 +168,29 @@ func hapPairSetup(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
if err = srv.hap.PairSetup(r, rw, conn); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
|
||||
func hapPairVerify(w http.ResponseWriter, r *http.Request) {
|
||||
srv, ok := servers[r.Host]
|
||||
if !ok {
|
||||
// Can support multiple HomeKit cameras on single port ONLY for Apple devices.
|
||||
// Doesn't support Home Assistant and any other open source projects
|
||||
// because they don't send the host header in requests.
|
||||
srv := resolve(r.Host)
|
||||
if srv == nil {
|
||||
log.Error().Msg("[homekit] unknown host: " + r.Host)
|
||||
_ = hap.WriteBackoff(rw)
|
||||
return
|
||||
}
|
||||
|
||||
conn, rw, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
switch r.RequestURI {
|
||||
case hap.PathPairSetup:
|
||||
err = srv.hap.PairSetup(r, rw, conn)
|
||||
case hap.PathPairVerify:
|
||||
err = srv.hap.PairVerify(r, rw, conn)
|
||||
}
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
if err = srv.hap.PairVerify(r, rw, conn); err != nil && err != io.EOF {
|
||||
if err != nil && err != io.EOF {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
|
||||
func findHomeKitURL(stream *streams.Stream) string {
|
||||
sources := stream.Sources()
|
||||
func findHomeKitURL(sources []string) string {
|
||||
if len(sources) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ func (s *server) DelPair(conn net.Conn, id string) {
|
||||
}
|
||||
|
||||
func (s *server) PatchConfig() {
|
||||
if err := app.PatchConfig("pairings", s.pairings, "homekit", s.stream); err != nil {
|
||||
if err := app.PatchConfig([]string{"homekit", s.stream, "pairings"}, s.pairings); err != nil {
|
||||
log.Error().Err(err).Msgf(
|
||||
"[homekit] can't save %s pairings=%v", s.stream, s.pairings,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ package nest
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
@@ -38,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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+12
-3
@@ -72,7 +72,11 @@ func onvifDeviceService(w http.ResponseWriter, r *http.Request) {
|
||||
onvif.DeviceGetNetworkDefaultGateway,
|
||||
onvif.DeviceGetNetworkProtocols,
|
||||
onvif.DeviceGetNTP,
|
||||
onvif.DeviceGetScopes:
|
||||
onvif.DeviceGetScopes,
|
||||
onvif.MediaGetVideoEncoderConfigurations,
|
||||
onvif.MediaGetAudioEncoderConfigurations,
|
||||
onvif.MediaGetAudioSources,
|
||||
onvif.MediaGetAudioSourceConfigurations:
|
||||
b = onvif.StaticResponse(operation)
|
||||
|
||||
case onvif.DeviceGetCapabilities:
|
||||
@@ -99,16 +103,20 @@ func onvifDeviceService(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
|
||||
case onvif.MediaGetVideoSources:
|
||||
b = onvif.GetVideoSourcesResponse(streams.GetAll())
|
||||
b = onvif.GetVideoSourcesResponse(streams.GetAllNames())
|
||||
|
||||
case onvif.MediaGetProfiles:
|
||||
// important for Hass: H264 codec, width, height
|
||||
b = onvif.GetProfilesResponse(streams.GetAll())
|
||||
b = onvif.GetProfilesResponse(streams.GetAllNames())
|
||||
|
||||
case onvif.MediaGetProfile:
|
||||
token := onvif.FindTagValue(b, "ProfileToken")
|
||||
b = onvif.GetProfileResponse(token)
|
||||
|
||||
case onvif.MediaGetVideoSourceConfigurations:
|
||||
// important for Happytime Onvif Client
|
||||
b = onvif.GetVideoSourceConfigurationsResponse(streams.GetAllNames())
|
||||
|
||||
case onvif.MediaGetVideoSourceConfiguration:
|
||||
token := onvif.FindTagValue(b, "ConfigurationToken")
|
||||
b = onvif.GetVideoSourceConfigurationResponse(token)
|
||||
@@ -129,6 +137,7 @@ func onvifDeviceService(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
default:
|
||||
http.Error(w, "unsupported operation", http.StatusBadRequest)
|
||||
log.Warn().Msgf("[onvif] unsupported operation: %s", operation)
|
||||
log.Debug().Msgf("[onvif] unsupported request:\n%s", b)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
## Tested client
|
||||
|
||||
| From | To | Comment |
|
||||
|--------|---------------------------------|---------|
|
||||
| go2rtc | Reolink RLC-520A fw. v3.1.0.801 | OK |
|
||||
|
||||
**go2rtc.yaml**
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
rtmp-reolink1: rtmp://192.168.10.92/bcs/channel0_main.bcs?channel=0&stream=0&user=admin&password=password
|
||||
rtmp-reolink2: rtmp://192.168.10.92/bcs/channel0_sub.bcs?channel=0&stream=1&user=admin&password=password
|
||||
rtmp-reolink3: rtmp://192.168.10.92/bcs/channel0_ext.bcs?channel=0&stream=1&user=admin&password=password
|
||||
```
|
||||
|
||||
## Tested server
|
||||
|
||||
| From | To | Comment |
|
||||
|------------------------|--------|---------------------|
|
||||
| OBS 31.0.2 | go2rtc | OK |
|
||||
| OpenIPC 2.5.03.02-lite | go2rtc | OK |
|
||||
| FFmpeg 6.1 | go2rtc | OK |
|
||||
| GoPro Black 12 | go2rtc | OK, 1080p, 5000kbps |
|
||||
|
||||
**go2rtc.yaml**
|
||||
|
||||
```yaml
|
||||
rtmp:
|
||||
listen: :1935
|
||||
streams:
|
||||
tmp:
|
||||
```
|
||||
|
||||
**OBS**
|
||||
|
||||
Settings > Stream:
|
||||
|
||||
- Service: Custom
|
||||
- Server: rtmp://192.168.10.101/tmp
|
||||
- Stream Key: <empty>
|
||||
- Use auth: <disabled>
|
||||
|
||||
**OpenIPC**
|
||||
|
||||
WebUI > Majestic > Settings > Outgoing
|
||||
|
||||
- Enable
|
||||
- Address: rtmp://192.168.10.101/tmp
|
||||
- Save
|
||||
- Restart
|
||||
|
||||
**FFmpeg**
|
||||
|
||||
```shell
|
||||
ffmpeg -re -i bbb.mp4 -c copy -f flv rtmp://192.168.10.101/tmp
|
||||
```
|
||||
|
||||
**GoPro**
|
||||
|
||||
GoPro Quik > Camera > Translation > Other
|
||||
+20
-1
@@ -1,6 +1,7 @@
|
||||
package rtsp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
@@ -185,6 +186,22 @@ 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))
|
||||
}
|
||||
@@ -237,7 +254,9 @@ func tcpHandler(conn *rtsp.Conn) {
|
||||
})
|
||||
|
||||
if err := conn.Accept(); err != nil {
|
||||
if err != io.EOF {
|
||||
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 {
|
||||
|
||||
@@ -1,8 +1,55 @@
|
||||
## Testing notes
|
||||
## Examples
|
||||
|
||||
```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}
|
||||
# known RTSP sources
|
||||
rtsp-dahua1: rtsp://admin:password@192.168.10.90/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif
|
||||
rtsp-dahua2: rtsp://admin:password@192.168.10.90/cam/realmonitor?channel=1&subtype=1
|
||||
rtsp-tplink1: rtsp://admin:password@192.168.10.91/stream1
|
||||
rtsp-tplink2: rtsp://admin:password@192.168.10.91/stream2
|
||||
rtsp-reolink1: rtsp://admin:password@192.168.10.92/h264Preview_01_main
|
||||
rtsp-reolink2: rtsp://admin:password@192.168.10.92/h264Preview_01_sub
|
||||
rtsp-sonoff1: rtsp://admin:password@192.168.10.93/av_stream/ch0
|
||||
rtsp-sonoff2: rtsp://admin:password@192.168.10.93/av_stream/ch1
|
||||
|
||||
# known RTMP sources
|
||||
rtmp-reolink1: rtmp://192.168.10.92/bcs/channel0_main.bcs?channel=0&stream=0&user=admin&password=password
|
||||
rtmp-reolink2: rtmp://192.168.10.92/bcs/channel0_sub.bcs?channel=0&stream=1&user=admin&password=password
|
||||
rtmp-reolink3: rtmp://192.168.10.92/bcs/channel0_ext.bcs?channel=0&stream=1&user=admin&password=password
|
||||
|
||||
# known HTTP sources
|
||||
http-reolink1: http://192.168.10.92/flv?port=1935&app=bcs&stream=channel0_main.bcs&user=admin&password=password
|
||||
http-reolink2: http://192.168.10.92/flv?port=1935&app=bcs&stream=channel0_sub.bcs&user=admin&password=password
|
||||
http-reolink3: http://192.168.10.92/flv?port=1935&app=bcs&stream=channel0_ext.bcs&user=admin&password=password
|
||||
|
||||
# known ONVIF sources
|
||||
onvif-dahua1: onvif://admin:password@192.168.10.90?subtype=MediaProfile00000
|
||||
onvif-dahua2: onvif://admin:password@192.168.10.90?subtype=MediaProfile00001
|
||||
onvif-dahua3: onvif://admin:password@192.168.10.90?subtype=MediaProfile00000&snapshot
|
||||
onvif-tplink1: onvif://admin:password@192.168.10.91:2020?subtype=profile_1
|
||||
onvif-tplink2: onvif://admin:password@192.168.10.91:2020?subtype=profile_2
|
||||
onvif-reolink1: onvif://admin:password@192.168.10.92:8000?subtype=000
|
||||
onvif-reolink2: onvif://admin:password@192.168.10.92:8000?subtype=001
|
||||
onvif-reolink3: onvif://admin:password@192.168.10.92:8000?subtype=000&snapshot
|
||||
onvif-openipc1: onvif://admin:password@192.168.10.95:80?subtype=PROFILE_000
|
||||
onvif-openipc2: onvif://admin:password@192.168.10.95:80?subtype=PROFILE_001
|
||||
|
||||
# some EXEC examples
|
||||
exec-h264-pipe: exec:ffmpeg -re -i bbb.mp4 -c copy -f h264 -
|
||||
exec-flv-pipe: exec:ffmpeg -re -i bbb.mp4 -c copy -f flv -
|
||||
exec-mpegts-pipe: exec:ffmpeg -re -i bbb.mp4 -c copy -f mpegts -
|
||||
exec-adts-pipe: exec:ffmpeg -re -i bbb.mp4 -c copy -f adts -
|
||||
exec-mjpeg-pipe: exec:ffmpeg -re -i bbb.mp4 -c mjpeg -f mjpeg -
|
||||
exec-hevc-pipe: exec:ffmpeg -re -i bbb.mp4 -c libx265 -preset superfast -tune zerolatency -f hevc -
|
||||
exec-wav-pipe: exec:ffmpeg -re -i bbb.mp4 -c pcm_alaw -ar 8000 -ac 1 -f wav -
|
||||
exec-y4m-pipe: exec:ffmpeg -re -i bbb.mp4 -c rawvideo -f yuv4mpegpipe -
|
||||
exec-pcma-pipe: exec:ffmpeg -re -i numb.mp3 -c:a pcm_alaw -ar:a 8000 -ac:a 1 -f wav -
|
||||
exec-pcmu-pipe: exec:ffmpeg -re -i numb.mp3 -c:a pcm_mulaw -ar:a 8000 -ac:a 1 -f wav -
|
||||
exec-s16le-pipe: exec:ffmpeg -re -i numb.mp3 -c:a pcm_s16le -ar:a 16000 -ac:a 1 -f wav -
|
||||
|
||||
# some FFmpeg examples
|
||||
ffmpeg-video-h264: ffmpeg:virtual?video#video=h264
|
||||
ffmpeg-video-4K: ffmpeg:virtual?video&size=4K#video=h264
|
||||
ffmpeg-video-10s: ffmpeg:virtual?video&duration=10#video=h264
|
||||
ffmpeg-video-src2: ffmpeg:virtual?video=testsrc2&size=2K#video=h264
|
||||
```
|
||||
|
||||
@@ -53,7 +53,7 @@ func apiStreams(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.PatchConfig(name, query["src"], "streams"); err != nil {
|
||||
if err := app.PatchConfig([]string{"streams", name}, query["src"]); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ func apiStreams(w http.ResponseWriter, r *http.Request) {
|
||||
case "DELETE":
|
||||
delete(streams, src)
|
||||
|
||||
if err := app.PatchConfig(src, nil, "streams"); err != nil {
|
||||
if err := app.PatchConfig([]string{"streams", src}, nil); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,5 +171,6 @@ func (c *conn) label() string {
|
||||
if c.UserAgent != "" {
|
||||
sb.WriteString("\nuser_agent=" + c.UserAgent)
|
||||
}
|
||||
return sb.String()
|
||||
// escape quotes https://github.com/AlexxIT/go2rtc/issues/1603
|
||||
return strings.ReplaceAll(sb.String(), `"`, `'`)
|
||||
}
|
||||
|
||||
@@ -47,11 +47,12 @@ func NewStream(source any) *Stream {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Stream) Sources() (sources []string) {
|
||||
func (s *Stream) Sources() []string {
|
||||
sources := make([]string, 0, len(s.producers))
|
||||
for _, prod := range s.producers {
|
||||
sources = append(sources, prod.url)
|
||||
}
|
||||
return
|
||||
return sources
|
||||
}
|
||||
|
||||
func (s *Stream) SetSource(source string) {
|
||||
|
||||
+34
-17
@@ -42,10 +42,6 @@ func Init() {
|
||||
})
|
||||
}
|
||||
|
||||
func Get(name string) *Stream {
|
||||
return streams[name]
|
||||
}
|
||||
|
||||
var sanitize = regexp.MustCompile(`\s`)
|
||||
|
||||
// Validate - not allow creating dynamic streams with spaces in the source
|
||||
@@ -68,6 +64,7 @@ func New(name string, sources ...string) *Stream {
|
||||
streamsMu.Lock()
|
||||
streams[name] = stream
|
||||
streamsMu.Unlock()
|
||||
|
||||
return stream
|
||||
}
|
||||
|
||||
@@ -124,7 +121,7 @@ func GetOrPatch(query url.Values) *Stream {
|
||||
}
|
||||
|
||||
// check if src is stream name
|
||||
if stream, ok := streams[source]; ok {
|
||||
if stream := Get(source); stream != nil {
|
||||
return stream
|
||||
}
|
||||
|
||||
@@ -139,21 +136,41 @@ func GetOrPatch(query url.Values) *Stream {
|
||||
return Patch(source, source)
|
||||
}
|
||||
|
||||
func GetAll() (names []string) {
|
||||
var log zerolog.Logger
|
||||
|
||||
// streams map
|
||||
|
||||
var streams = map[string]*Stream{}
|
||||
var streamsMu sync.Mutex
|
||||
|
||||
func Get(name string) *Stream {
|
||||
streamsMu.Lock()
|
||||
defer streamsMu.Unlock()
|
||||
return streams[name]
|
||||
}
|
||||
|
||||
func Delete(name string) {
|
||||
streamsMu.Lock()
|
||||
defer streamsMu.Unlock()
|
||||
delete(streams, name)
|
||||
}
|
||||
|
||||
func GetAllNames() []string {
|
||||
streamsMu.Lock()
|
||||
names := make([]string, 0, len(streams))
|
||||
for name := range streams {
|
||||
names = append(names, name)
|
||||
}
|
||||
return
|
||||
streamsMu.Unlock()
|
||||
return names
|
||||
}
|
||||
|
||||
func Streams() map[string]*Stream {
|
||||
return streams
|
||||
func GetAllSources() map[string][]string {
|
||||
streamsMu.Lock()
|
||||
sources := make(map[string][]string, len(streams))
|
||||
for name, stream := range streams {
|
||||
sources[name] = stream.Sources()
|
||||
}
|
||||
streamsMu.Unlock()
|
||||
return sources
|
||||
}
|
||||
|
||||
func Delete(id string) {
|
||||
delete(streams, id)
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
var streams = map[string]*Stream{}
|
||||
var streamsMu sync.Mutex
|
||||
|
||||
@@ -11,13 +11,15 @@ If an external connection via STUN is used:
|
||||
|
||||
- Uses [UDP hole punching](https://en.wikipedia.org/wiki/UDP_hole_punching) technology to bypass NAT even if you not open your server to the World
|
||||
- For about 20% of users, the techology will not work because of the [Symmetric NAT](https://tomchen.github.io/symmetric-nat-test/)
|
||||
- UDP is not suitable for transmitting 2K and 4K high bitrate video over open networks because of the high loss rate
|
||||
- UDP is not suitable for transmitting 2K and 4K high bitrate video over open networks because of the high loss rate:
|
||||
- https://habr.com/ru/companies/flashphoner/articles/480006/
|
||||
- https://www.youtube.com/watch?v=FXVg2ckuKfs
|
||||
|
||||
## Default config
|
||||
|
||||
```yaml
|
||||
webrtc:
|
||||
listen: ":8555/tcp"
|
||||
listen: ":8555"
|
||||
ice_servers:
|
||||
- urls: [ "stun:stun.l.google.com:19302" ]
|
||||
```
|
||||
@@ -29,7 +31,7 @@ webrtc:
|
||||
```yaml
|
||||
webrtc:
|
||||
# fix local TCP or UDP or both ports for WebRTC media
|
||||
listen: ":8555/tcp" # address of your local server
|
||||
listen: ":8555" # address of your local server
|
||||
|
||||
# add additional host candidates manually
|
||||
# order is important, the first will have a higher priority
|
||||
@@ -53,17 +55,20 @@ webrtc:
|
||||
# including candidates from the `listen` option
|
||||
# use `candidates: []` to remove all auto discovery candidates
|
||||
candidates: [ 192.168.1.123 ]
|
||||
|
||||
# enable localhost candidates
|
||||
loopback: true
|
||||
|
||||
# list of network types to be used for connection
|
||||
# including candidates from the `listen` option
|
||||
networks: [ udp4, udp6, tcp4, tcp6 ]
|
||||
|
||||
# list of interfaces to be used for connection
|
||||
# not related to the `listen` option
|
||||
# including interfaces from unspecified `listen` option (empty host)
|
||||
interfaces: [ eno1 ]
|
||||
|
||||
# list of host IP-addresses to be used for connection
|
||||
# not related to the `listen` option
|
||||
# including IPs from unspecified `listen` option (empty host)
|
||||
ips: [ 192.168.1.123 ]
|
||||
|
||||
# range for random UDP ports [min, max] to be used for connection
|
||||
@@ -71,14 +76,16 @@ webrtc:
|
||||
udp_ports: [ 50000, 50100 ]
|
||||
```
|
||||
|
||||
By default go2rtc uses **fixed TCP** port and multiple **random UDP** ports for each WebRTC connection - `listen: ":8555/tcp"`.
|
||||
By default go2rtc uses **fixed TCP** port and **fixed UDP** ports for each **direct** WebRTC connection - `listen: ":8555"`.
|
||||
|
||||
You can set **fixed TCP** and **fixed UDP** port for all connections - `listen: ":8555"`. This may has lower performance, but it's your choice.
|
||||
You can set **fixed TCP** and **random UDP** port for all connections - `listen: ":8555/tcp"`.
|
||||
|
||||
Don't know why, but you can disable TCP port and leave only random UDP ports - `listen: ""`.
|
||||
|
||||
## Config filters
|
||||
|
||||
**Importan!** By default go2rtc exclude all Docker-like candidates (`172.16.0.0/12`). This can not be disabled.
|
||||
|
||||
Filters allow you to exclude unnecessary candidates. Extra candidates don't make your connection worse or better. But the wrong filter settings can break everything. Skip this setting if you don't understand it.
|
||||
|
||||
For example, go2rtc is installed on the host system. And there are unnecessary interfaces. You can keep only the relevant via `interfaces` or `ips` options. You can also exclude IPv6 candidates if your server supports them but your home network does not.
|
||||
@@ -97,8 +104,6 @@ For example, go2rtc inside closed docker container (ex. [Frigate](https://frigat
|
||||
webrtc:
|
||||
listen: ":8555" # use fixed TCP and UDP ports
|
||||
candidates: [ 192.168.1.2:8555 ] # add manual host candidate (use docker port forwarding)
|
||||
filters:
|
||||
candidates: [] # skip all internal docker candidates
|
||||
```
|
||||
|
||||
## Userful links
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
"github.com/AlexxIT/go2rtc/pkg/xnet"
|
||||
pion "github.com/pion/webrtc/v3"
|
||||
)
|
||||
|
||||
@@ -73,6 +74,11 @@ func FilterCandidate(candidate *pion.ICECandidate) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// remove any Docker-like IP from candidates
|
||||
if ip := net.ParseIP(candidate.Address); ip != nil && xnet.Docker.Contains(ip) {
|
||||
return false
|
||||
}
|
||||
|
||||
// host candidate should be in the hosts list
|
||||
if candidate.Typ == pion.ICECandidateTypeHost && filters.Candidates != nil {
|
||||
if !core.Contains(filters.Candidates, candidate.Address) {
|
||||
|
||||
@@ -41,9 +41,11 @@ 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", nil)
|
||||
} else if format == "openipc" {
|
||||
return openIPCClient(rawURL, query)
|
||||
} else if format == "switchbot" {
|
||||
return switchbotClient(rawURL, query)
|
||||
} else {
|
||||
return go2rtcClient(rawURL)
|
||||
}
|
||||
@@ -54,6 +56,8 @@ func streamsHandler(rawURL string) (core.Producer, error) {
|
||||
} else if format == "wyze" {
|
||||
// https://github.com/mrlt8/docker-wyze-bridge
|
||||
return wyzeClient(rawURL)
|
||||
} else if format == "creality" {
|
||||
return crealityClient(rawURL)
|
||||
} else {
|
||||
return whepClient(rawURL)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
)
|
||||
|
||||
// https://github.com/AlexxIT/go2rtc/issues/1600
|
||||
func crealityClient(url string) (core.Producer, error) {
|
||||
pc, err := PeerConnection(true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prod := webrtc.NewConn(pc)
|
||||
prod.FormatName = "webrtc/creality"
|
||||
prod.Mode = core.ModeActiveProducer
|
||||
prod.Protocol = "http"
|
||||
prod.URL = url
|
||||
|
||||
medias := []*core.Media{
|
||||
{Kind: core.KindVideo, Direction: core.DirectionRecvonly},
|
||||
{Kind: core.KindAudio, Direction: core.DirectionRecvonly},
|
||||
}
|
||||
|
||||
// TODO: return webrtc.SessionDescription
|
||||
offer, err := prod.CreateCompleteOffer(medias)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := offerToB64(offer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "plain/text")
|
||||
|
||||
// TODO: change http.DefaultClient settings
|
||||
client := http.Client{Timeout: time.Second * 5000}
|
||||
defer client.CloseIdleConnections()
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
answer, err := answerFromB64(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = prod.SetAnswer(answer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return prod, nil
|
||||
}
|
||||
|
||||
func offerToB64(sdp string) (io.Reader, error) {
|
||||
// JS object
|
||||
v := map[string]string{
|
||||
"type": "offer",
|
||||
"sdp": sdp,
|
||||
}
|
||||
|
||||
// bytes
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// base64, why? who knows...
|
||||
s := base64.StdEncoding.EncodeToString(b)
|
||||
|
||||
return strings.NewReader(s), nil
|
||||
}
|
||||
|
||||
func answerFromB64(r io.Reader) (string, error) {
|
||||
// base64
|
||||
b, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// bytes
|
||||
if b, err = base64.StdEncoding.DecodeString(string(b)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// JS object
|
||||
var v map[string]string
|
||||
if err = json.Unmarshal(b, &v); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// string "v=0..."
|
||||
return v["sdp"], nil
|
||||
}
|
||||
+29
-16
@@ -34,7 +34,10 @@ func (k kinesisResponse) String() string {
|
||||
return fmt.Sprintf("type=%s, payload=%s", k.Type, k.Payload)
|
||||
}
|
||||
|
||||
func kinesisClient(rawURL string, query url.Values, format string) (core.Producer, error) {
|
||||
func kinesisClient(
|
||||
rawURL string, query url.Values, format string,
|
||||
sdpOffer func(prod *webrtc.Conn, query url.Values) (any, error),
|
||||
) (core.Producer, error) {
|
||||
// 1. Connect to signalign server
|
||||
conn, _, err := websocket.DefaultDialer.Dial(rawURL, nil)
|
||||
if err != nil {
|
||||
@@ -108,23 +111,33 @@ func kinesisClient(rawURL string, query url.Values, format string) (core.Produce
|
||||
}
|
||||
})
|
||||
|
||||
medias := []*core.Media{
|
||||
{Kind: core.KindVideo, Direction: core.DirectionRecvonly},
|
||||
{Kind: core.KindAudio, Direction: core.DirectionRecvonly},
|
||||
var payload any
|
||||
|
||||
if sdpOffer == nil {
|
||||
medias := []*core.Media{
|
||||
{Kind: core.KindVideo, Direction: core.DirectionRecvonly},
|
||||
{Kind: core.KindAudio, Direction: core.DirectionRecvonly},
|
||||
}
|
||||
|
||||
// 4. Create offer
|
||||
var offer string
|
||||
if offer, err = prod.CreateOffer(medias); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 5. Send offer
|
||||
payload = pion.SessionDescription{
|
||||
Type: pion.SDPTypeOffer,
|
||||
SDP: offer,
|
||||
}
|
||||
} else {
|
||||
if payload, err = sdpOffer(prod, query); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Create offer
|
||||
offer, err := prod.CreateOffer(medias)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 5. Send offer
|
||||
req.Action = "SDP_OFFER"
|
||||
req.Payload, _ = json.Marshal(pion.SessionDescription{
|
||||
Type: pion.SDPTypeOffer,
|
||||
SDP: offer,
|
||||
})
|
||||
req.Payload, _ = json.Marshal(payload)
|
||||
if err = conn.WriteJSON(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -218,5 +231,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", nil)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -62,8 +63,8 @@ func syncHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// 2. application/sdp - receive/response SDP via WebRTC-HTTP Egress Protocol (WHEP)
|
||||
// 3. other - receive/response raw SDP
|
||||
func outputWebRTC(w http.ResponseWriter, r *http.Request) {
|
||||
url := r.URL.Query().Get("src")
|
||||
stream := streams.Get(url)
|
||||
u := r.URL.Query().Get("src")
|
||||
stream := streams.Get(u)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
@@ -87,6 +88,21 @@ func outputWebRTC(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
offer = desc.SDP
|
||||
|
||||
case "application/x-www-form-urlencoded":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
offerB64 := r.Form.Get("data")
|
||||
b, err := base64.StdEncoding.DecodeString(offerB64)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
offer = string(b)
|
||||
|
||||
default:
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
@@ -124,6 +140,11 @@ func outputWebRTC(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
err = json.NewEncoder(w).Encode(v)
|
||||
|
||||
case "application/x-www-form-urlencoded":
|
||||
w.Header().Set("Content-Type", mediaType)
|
||||
answerB64 := base64.StdEncoding.EncodeToString([]byte(answer))
|
||||
_, err = w.Write([]byte(answerB64))
|
||||
|
||||
case MimeSDP:
|
||||
w.Header().Set("Content-Type", mediaType)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
)
|
||||
|
||||
func switchbotClient(rawURL string, query url.Values) (core.Producer, error) {
|
||||
return kinesisClient(rawURL, query, "webrtc/switchbot", func(prod *webrtc.Conn, query url.Values) (any, error) {
|
||||
medias := []*core.Media{
|
||||
{Kind: core.KindVideo, Direction: core.DirectionRecvonly},
|
||||
}
|
||||
|
||||
offer, err := prod.CreateOffer(medias)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v := struct {
|
||||
Type string `json:"type"`
|
||||
SDP string `json:"sdp"`
|
||||
Resolution int `json:"resolution"`
|
||||
PlayType int `json:"play_type"`
|
||||
}{
|
||||
Type: "offer",
|
||||
SDP: offer,
|
||||
}
|
||||
|
||||
switch query.Get("resolution") {
|
||||
case "hd":
|
||||
v.Resolution = 0
|
||||
case "sd":
|
||||
v.Resolution = 1
|
||||
}
|
||||
|
||||
return v, nil
|
||||
})
|
||||
}
|
||||
@@ -24,7 +24,7 @@ func Init() {
|
||||
} `yaml:"webrtc"`
|
||||
}
|
||||
|
||||
cfg.Mod.Listen = ":8555/tcp"
|
||||
cfg.Mod.Listen = ":8555"
|
||||
cfg.Mod.IceServers = []pion.ICEServer{
|
||||
{URLs: []string{"stun:stun.l.google.com:19302"}},
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ package webtorrent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/webtorrent"
|
||||
"github.com/gorilla/websocket"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var upgrader *websocket.Upgrader
|
||||
|
||||
@@ -39,7 +39,7 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
app.Version = "1.9.8"
|
||||
app.Version = "1.9.9"
|
||||
|
||||
// 1. Core modules: app, api/ws, streams
|
||||
|
||||
@@ -81,7 +81,7 @@ func main() {
|
||||
mpegts.Init() // mpegts passive source
|
||||
roborock.Init() // roborock source
|
||||
homekit.Init() // homekit source
|
||||
ring.Init() // ring source
|
||||
ring.Init() // ring source
|
||||
nest.Init() // nest source
|
||||
bubble.Init() // bubble source
|
||||
expr.Init() // expr source
|
||||
|
||||
@@ -141,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)
|
||||
}
|
||||
|
||||
+7
-4
@@ -140,6 +140,7 @@ func (s *Sender) Start() {
|
||||
s.done = make(chan struct{})
|
||||
|
||||
go func() {
|
||||
// for range on nil chan is OK
|
||||
for packet := range s.buf {
|
||||
s.Output(packet)
|
||||
}
|
||||
@@ -148,7 +149,7 @@ func (s *Sender) Start() {
|
||||
}
|
||||
|
||||
func (s *Sender) Wait() {
|
||||
if done := s.done; s.done != nil {
|
||||
if done := s.done; done != nil {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
@@ -165,10 +166,12 @@ func (s *Sender) State() string {
|
||||
|
||||
func (s *Sender) Close() {
|
||||
// close buffer if exists
|
||||
if buf := s.buf; buf != nil {
|
||||
s.buf = nil
|
||||
defer close(buf)
|
||||
s.mu.Lock()
|
||||
if s.buf != nil {
|
||||
close(s.buf) // exit from for range loop
|
||||
s.buf = nil // prevent writing to closed chan
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
s.Node.Close()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSenser(t *testing.T) {
|
||||
recv := make(chan *Packet) // blocking receiver
|
||||
|
||||
sender := NewSender(nil, &Codec{})
|
||||
sender.Output = func(packet *Packet) {
|
||||
recv <- packet
|
||||
}
|
||||
require.Equal(t, "new", sender.State())
|
||||
|
||||
sender.Start()
|
||||
require.Equal(t, "connected", sender.State())
|
||||
|
||||
sender.Input(&Packet{})
|
||||
sender.Input(&Packet{})
|
||||
|
||||
require.Equal(t, 2, sender.Packets)
|
||||
require.Equal(t, 0, sender.Drops)
|
||||
|
||||
// important to read one before close
|
||||
// because goroutine in Start() can run with nil chan
|
||||
// it's OK in real life, but bad for test
|
||||
_, ok := <-recv
|
||||
require.True(t, ok)
|
||||
|
||||
sender.Close()
|
||||
require.Equal(t, "closed", sender.State())
|
||||
|
||||
sender.Input(&Packet{})
|
||||
|
||||
require.Equal(t, 2, sender.Packets)
|
||||
require.Equal(t, 1, sender.Drops)
|
||||
|
||||
// read 2nd
|
||||
_, ok = <-recv
|
||||
require.True(t, ok)
|
||||
|
||||
// read 3rd
|
||||
select {
|
||||
case <-recv:
|
||||
ok = true
|
||||
default:
|
||||
ok = false
|
||||
}
|
||||
require.False(t, ok)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package h265
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@ package h265
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"math"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
)
|
||||
|
||||
//
|
||||
|
||||
@@ -2,12 +2,21 @@ package camera
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNilCharacter(t *testing.T) {
|
||||
var res SetupEndpoints
|
||||
char := &hap.Character{}
|
||||
err := char.ReadTLV8(&res)
|
||||
require.NotNil(t, err)
|
||||
require.NotNil(t, strings.Contains(err.Error(), "can't read value"))
|
||||
}
|
||||
|
||||
type testTLV8 struct {
|
||||
name string
|
||||
value string
|
||||
|
||||
+10
-3
@@ -3,6 +3,7 @@ package hap
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
@@ -126,11 +127,17 @@ func (c *Character) Write(v any) (err error) {
|
||||
|
||||
// ReadTLV8 value to right struct
|
||||
func (c *Character) ReadTLV8(v any) (err error) {
|
||||
return tlv8.UnmarshalBase64(c.Value.(string), v)
|
||||
if s, ok := c.Value.(string); ok {
|
||||
return tlv8.UnmarshalBase64(s, v)
|
||||
}
|
||||
return fmt.Errorf("hap: can't read value: %v", v)
|
||||
}
|
||||
|
||||
func (c *Character) ReadBool() bool {
|
||||
return c.Value.(bool)
|
||||
func (c *Character) ReadBool() (bool, error) {
|
||||
if v, ok := c.Value.(bool); ok {
|
||||
return v, nil
|
||||
}
|
||||
return false, fmt.Errorf("hap: can't read value: %v", c.Value)
|
||||
}
|
||||
|
||||
func (c *Character) String() string {
|
||||
|
||||
@@ -235,3 +235,18 @@ func WriteResponse(w *bufio.Writer, statusCode int, contentType string, body []b
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
func WriteBackoff(rw *bufio.ReadWriter) error {
|
||||
plainM2 := struct {
|
||||
State byte `tlv8:"6"`
|
||||
Error byte `tlv8:"7"`
|
||||
}{
|
||||
State: StateM2,
|
||||
Error: 3, // BackoffError
|
||||
}
|
||||
body, err := tlv8.Marshal(plainM2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return WriteResponse(rw.Writer, http.StatusOK, MimeTLV8, body)
|
||||
}
|
||||
|
||||
+2
-1
@@ -2,8 +2,9 @@ package hass
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gorilla/websocket"
|
||||
"os"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
|
||||
+12
-37
@@ -10,10 +10,14 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/xnet"
|
||||
"github.com/miekg/dns" // awesome library for parsing mDNS records
|
||||
)
|
||||
|
||||
const ServiceHAP = "_hap._tcp.local." // HomeKit Accessory Protocol
|
||||
const (
|
||||
ServiceDNSSD = "_services._dns-sd._udp.local."
|
||||
ServiceHAP = "_hap._tcp.local." // HomeKit Accessory Protocol
|
||||
)
|
||||
|
||||
type ServiceEntry struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
@@ -153,6 +157,7 @@ type Browser struct {
|
||||
Service string
|
||||
|
||||
Addr net.Addr
|
||||
Nets []*net.IPNet
|
||||
Recv net.PacketConn
|
||||
Sends []net.PacketConn
|
||||
|
||||
@@ -165,7 +170,9 @@ type Browser struct {
|
||||
// Receiver will get multicast responses on senders requests.
|
||||
func (b *Browser) ListenMulticastUDP() error {
|
||||
// 1. Collect IPv4 interfaces
|
||||
ip4s, err := InterfacesIP4()
|
||||
nets, err := xnet.IPNets(func(ip net.IP) bool {
|
||||
return !xnet.Docker.Contains(ip)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -182,11 +189,12 @@ func (b *Browser) ListenMulticastUDP() error {
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
for _, ip4 := range ip4s {
|
||||
conn, err := lc1.ListenPacket(ctx, "udp4", ip4.String()+":5353") // same port important
|
||||
for _, ipn := range nets {
|
||||
conn, err := lc1.ListenPacket(ctx, "udp4", ipn.IP.String()+":5353") // same port important
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
b.Nets = append(b.Nets, ipn)
|
||||
b.Sends = append(b.Sends, conn)
|
||||
}
|
||||
|
||||
@@ -364,36 +372,3 @@ func NewServiceEntries(msg *dns.Msg, ip net.IP) (entries []*ServiceEntry) {
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func InterfacesIP4() ([]net.IP, error) {
|
||||
intfs, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var ips []net.IP
|
||||
|
||||
loop:
|
||||
for _, intf := range intfs {
|
||||
if intf.Flags&net.FlagUp == 0 || intf.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
addrs, err := intf.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
if ip := v.IP.To4(); ip != nil {
|
||||
ips = append(ips, ip)
|
||||
continue loop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package mdns
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDiscovery(t *testing.T) {
|
||||
|
||||
+101
-96
@@ -20,7 +20,11 @@ func Serve(service string, entries []*ServiceEntry) error {
|
||||
}
|
||||
|
||||
func (b *Browser) Serve(entries []*ServiceEntry) error {
|
||||
var msg dns.Msg
|
||||
names := make(map[string]*ServiceEntry, len(entries))
|
||||
for _, entry := range entries {
|
||||
name := entry.name() + "." + b.Service
|
||||
names[name] = entry
|
||||
}
|
||||
|
||||
buf := make([]byte, 1500)
|
||||
for {
|
||||
@@ -29,129 +33,130 @@ func (b *Browser) Serve(entries []*ServiceEntry) error {
|
||||
break
|
||||
}
|
||||
|
||||
if err = msg.Unpack(buf[:n]); err != nil {
|
||||
var req dns.Msg // request
|
||||
if err = req.Unpack(buf[:n]); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if !HasQuestionPTP(&msg, b.Service) {
|
||||
// skip messages without Questions
|
||||
if req.Question == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
remoteIP := addr.(*net.UDPAddr).IP
|
||||
localIP := MatchLocalIP(remoteIP)
|
||||
localIP := b.MatchLocalIP(remoteIP)
|
||||
|
||||
// skip messages from unknown networks (can be docker network)
|
||||
if localIP == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
answer, err := NewDNSAnswer(entries, b.Service, localIP).Pack()
|
||||
var res dns.Msg // response
|
||||
for _, q := range req.Question {
|
||||
if q.Qtype != dns.TypePTR || q.Qclass != dns.ClassINET {
|
||||
continue
|
||||
}
|
||||
|
||||
if q.Name == ServiceDNSSD {
|
||||
AppendDNSSD(&res, b.Service)
|
||||
} else if q.Name == b.Service {
|
||||
for _, entry := range entries {
|
||||
AppendEntry(&res, entry, b.Service, localIP)
|
||||
}
|
||||
} else if entry, ok := names[q.Name]; ok {
|
||||
AppendEntry(&res, entry, b.Service, localIP)
|
||||
}
|
||||
}
|
||||
|
||||
if res.Answer == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
res.MsgHdr.Response = true
|
||||
res.MsgHdr.Authoritative = true
|
||||
|
||||
data, err := res.Pack()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, send := range b.Sends {
|
||||
_, _ = send.WriteTo(answer, MulticastAddr)
|
||||
_, _ = send.WriteTo(data, MulticastAddr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func HasQuestionPTP(msg *dns.Msg, name string) bool {
|
||||
for _, q := range msg.Question {
|
||||
if q.Qtype == dns.TypePTR && q.Name == name {
|
||||
return true
|
||||
func (b *Browser) MatchLocalIP(remote net.IP) net.IP {
|
||||
for _, ipn := range b.Nets {
|
||||
if ipn.Contains(remote) {
|
||||
return ipn.IP
|
||||
}
|
||||
}
|
||||
return false
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewDNSAnswer(entries []*ServiceEntry, service string, ip net.IP) *dns.Msg {
|
||||
msg := dns.Msg{
|
||||
MsgHdr: dns.MsgHdr{
|
||||
Response: true,
|
||||
Authoritative: true,
|
||||
func AppendDNSSD(msg *dns.Msg, service string) {
|
||||
msg.Answer = append(
|
||||
msg.Answer,
|
||||
&dns.PTR{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: ServiceDNSSD, // _services._dns-sd._udp.local.
|
||||
Rrtype: dns.TypePTR, // 12
|
||||
Class: dns.ClassINET, // 1
|
||||
Ttl: 4500,
|
||||
},
|
||||
Ptr: service, // _home-assistant._tcp.local.
|
||||
},
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
ptrName := entry.name() + "." + service
|
||||
srvName := entry.name() + ".local."
|
||||
|
||||
msg.Answer = append(
|
||||
msg.Answer,
|
||||
&dns.PTR{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: service,
|
||||
Rrtype: dns.TypePTR,
|
||||
Class: dns.ClassINET,
|
||||
Ttl: 4500,
|
||||
},
|
||||
Ptr: ptrName,
|
||||
},
|
||||
)
|
||||
msg.Extra = append(
|
||||
msg.Extra,
|
||||
&dns.TXT{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: ptrName,
|
||||
Rrtype: dns.TypeTXT,
|
||||
Class: ClassCacheFlush,
|
||||
Ttl: 4500,
|
||||
},
|
||||
Txt: entry.TXT(),
|
||||
},
|
||||
&dns.SRV{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: ptrName,
|
||||
Rrtype: dns.TypeSRV,
|
||||
Class: ClassCacheFlush,
|
||||
Ttl: 120,
|
||||
Rdlength: 0,
|
||||
},
|
||||
Port: entry.Port,
|
||||
Target: srvName,
|
||||
},
|
||||
&dns.A{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: srvName,
|
||||
Rrtype: dns.TypeA,
|
||||
Class: ClassCacheFlush,
|
||||
Ttl: 120,
|
||||
Rdlength: 0,
|
||||
},
|
||||
A: ip,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return &msg
|
||||
)
|
||||
}
|
||||
|
||||
func MatchLocalIP(remote net.IP) net.IP {
|
||||
intfs, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
func AppendEntry(msg *dns.Msg, entry *ServiceEntry, service string, ip net.IP) {
|
||||
ptrName := entry.name() + "." + service
|
||||
srvName := entry.name() + ".local."
|
||||
|
||||
for _, intf := range intfs {
|
||||
if intf.Flags&net.FlagUp == 0 || intf.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
addrs, err := intf.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
if local := v.IP.To4(); local != nil && v.Contains(remote) {
|
||||
return local
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
msg.Answer = append(
|
||||
msg.Answer,
|
||||
&dns.PTR{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: service, // _home-assistant._tcp.local.
|
||||
Rrtype: dns.TypePTR, // 12
|
||||
Class: dns.ClassINET, // 1
|
||||
Ttl: 4500,
|
||||
},
|
||||
Ptr: ptrName, // Home\ Assistant._home-assistant._tcp.local.
|
||||
},
|
||||
)
|
||||
msg.Extra = append(
|
||||
msg.Extra,
|
||||
&dns.TXT{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: ptrName, // Home\ Assistant._home-assistant._tcp.local.
|
||||
Rrtype: dns.TypeTXT, // 16
|
||||
Class: ClassCacheFlush, // 32769
|
||||
Ttl: 4500,
|
||||
},
|
||||
Txt: entry.TXT(),
|
||||
},
|
||||
&dns.SRV{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: ptrName, // Home\ Assistant._home-assistant._tcp.local.
|
||||
Rrtype: dns.TypeSRV, // 33
|
||||
Class: ClassCacheFlush, // 32769
|
||||
Ttl: 120,
|
||||
},
|
||||
Port: entry.Port, // 8123
|
||||
Target: srvName, // 963f1fa82b7142809711cebe7c826322.local.
|
||||
},
|
||||
&dns.A{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: srvName, // 963f1fa82b7142809711cebe7c826322.local.
|
||||
Rrtype: dns.TypeA, // 1
|
||||
Class: ClassCacheFlush, // 32769
|
||||
Ttl: 120,
|
||||
},
|
||||
A: ip,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+3
-2
@@ -3,10 +3,11 @@ package mjpeg
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/pion/rtp"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
func RTPDepay(handlerFunc core.HandlerFunc) core.HandlerFunc {
|
||||
|
||||
+151
-18
@@ -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
|
||||
|
||||
@@ -80,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 {
|
||||
@@ -108,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
|
||||
@@ -190,11 +208,20 @@ 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 {
|
||||
@@ -223,8 +250,10 @@ func (a *API) ExtendStream() error {
|
||||
|
||||
var resv struct {
|
||||
Results struct {
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
MediaSessionID string `json:"mediaSessionId"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
MediaSessionID string `json:"mediaSessionId"`
|
||||
StreamExtensionToken string `json:"streamExtensionToken"`
|
||||
StreamToken string `json:"streamToken"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
@@ -234,6 +263,111 @@ func (a *API) ExtendStream() error {
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
if res.StatusCode != 200 {
|
||||
return errors.New("nest: wrong status: " + res.Status)
|
||||
}
|
||||
|
||||
a.StreamProjectID = ""
|
||||
a.StreamDeviceID = ""
|
||||
a.StreamExtensionToken = ""
|
||||
a.StreamToken = ""
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -266,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() {
|
||||
@@ -282,7 +416,6 @@ func (a *API) StartExtendStreamTimer() {
|
||||
duration = time.Until(a.StreamExpiresAt.Add(-30 * time.Second))
|
||||
a.extendTimer.Reset(duration)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func (a *API) StopExtendStreamTimer() {
|
||||
|
||||
+71
-13
@@ -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 Dial(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 Dial(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
|
||||
@@ -77,31 +120,46 @@ func Dial(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()
|
||||
}
|
||||
|
||||
+2
-1
@@ -3,10 +3,11 @@ package ngrok
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
type Ngrok struct {
|
||||
|
||||
+5
-2
@@ -169,9 +169,12 @@ func (c *Client) GetServiceCapabilities() ([]byte, error) {
|
||||
}
|
||||
|
||||
func (c *Client) DeviceRequest(operation string) ([]byte, error) {
|
||||
if operation == DeviceGetServices {
|
||||
switch operation {
|
||||
case DeviceGetServices:
|
||||
operation = `<tds:GetServices><tds:IncludeCapability>true</tds:IncludeCapability></tds:GetServices>`
|
||||
} else {
|
||||
case DeviceGetCapabilities:
|
||||
operation = `<tds:GetCapabilities><tds:Category>All</tds:Category></tds:GetCapabilities>`
|
||||
default:
|
||||
operation = `<tds:` + operation + `/>`
|
||||
}
|
||||
return c.Request(c.deviceURL, operation)
|
||||
|
||||
+37
-11
@@ -179,18 +179,35 @@ func appendProfile(e *Envelope, tag, name string) {
|
||||
`)
|
||||
}
|
||||
|
||||
func GetVideoSourceConfigurationsResponse(names []string) []byte {
|
||||
e := NewEnvelope()
|
||||
e.Append(`<trt:GetVideoSourceConfigurationsResponse>
|
||||
`)
|
||||
for _, name := range names {
|
||||
appendProfile(e, "Configurations", name)
|
||||
}
|
||||
e.Append(`</trt:GetVideoSourceConfigurationsResponse>`)
|
||||
return e.Bytes()
|
||||
}
|
||||
|
||||
func GetVideoSourceConfigurationResponse(name string) []byte {
|
||||
e := NewEnvelope()
|
||||
e.Append(`<trt:GetVideoSourceConfigurationResponse>
|
||||
<trt:Configuration token="`, name, `">
|
||||
<tt:Name>VSC</tt:Name>
|
||||
<tt:SourceToken>`, name, `</tt:SourceToken>
|
||||
<tt:Bounds x="0" y="0" width="1920" height="1080"></tt:Bounds>
|
||||
</trt:Configuration>
|
||||
</trt:GetVideoSourceConfigurationResponse>`)
|
||||
`)
|
||||
appendVideoSourceConfiguration(e, "Configuration", name)
|
||||
e.Append(`</trt:GetVideoSourceConfigurationResponse>`)
|
||||
return e.Bytes()
|
||||
}
|
||||
|
||||
func appendVideoSourceConfiguration(e *Envelope, tag, name string) {
|
||||
e.Append(`<trt:`, tag, ` token="`, name, `" fixed="true">
|
||||
<tt:Name>VSC</tt:Name>
|
||||
<tt:SourceToken>`, name, `</tt:SourceToken>
|
||||
<tt:Bounds x="0" y="0" width="1920" height="1080"></tt:Bounds>
|
||||
</trt:`, tag, `>
|
||||
`)
|
||||
}
|
||||
|
||||
func GetVideoSourcesResponse(names []string) []byte {
|
||||
e := NewEnvelope()
|
||||
e.Append(`<trt:GetVideoSourcesResponse>
|
||||
@@ -226,11 +243,7 @@ func StaticResponse(operation string) []byte {
|
||||
|
||||
e := NewEnvelope()
|
||||
e.Append(responses[operation])
|
||||
b := e.Bytes()
|
||||
if operation == DeviceGetNetworkInterfaces {
|
||||
println()
|
||||
}
|
||||
return b
|
||||
return e.Bytes()
|
||||
}
|
||||
|
||||
var responses = map[string]string{
|
||||
@@ -249,4 +262,17 @@ var responses = map[string]string{
|
||||
<tds:Scopes><tt:ScopeDef>Fixed</tt:ScopeDef><tt:ScopeItem>onvif://www.onvif.org/Profile/Streaming</tt:ScopeItem></tds:Scopes>
|
||||
<tds:Scopes><tt:ScopeDef>Fixed</tt:ScopeDef><tt:ScopeItem>onvif://www.onvif.org/type/Network_Video_Transmitter</tt:ScopeItem></tds:Scopes>
|
||||
</tds:GetScopesResponse>`,
|
||||
|
||||
MediaGetVideoEncoderConfigurations: `<trt:GetVideoEncoderConfigurationsResponse>
|
||||
<tt:VideoEncoderConfiguration token="vec">
|
||||
<tt:Name>VEC</tt:Name>
|
||||
<tt:Encoding>H264</tt:Encoding>
|
||||
<tt:Resolution><tt:Width>1920</tt:Width><tt:Height>1080</tt:Height></tt:Resolution>
|
||||
<tt:RateControl />
|
||||
</tt:VideoEncoderConfiguration>
|
||||
</trt:GetVideoEncoderConfigurationsResponse>`,
|
||||
|
||||
MediaGetAudioEncoderConfigurations: `<trt:GetAudioEncoderConfigurationsResponse />`,
|
||||
MediaGetAudioSources: `<trt:GetAudioSourcesResponse />`,
|
||||
MediaGetAudioSourceConfigurations: `<trt:GetAudioSourceConfigurationsResponse />`,
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
v2 "github.com/AlexxIT/go2rtc/pkg/pcm"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPCMUtoPCM(t *testing.T) {
|
||||
|
||||
+2
-3
@@ -514,7 +514,6 @@ func (c *Client) Stop() error {
|
||||
|
||||
if c.prod != nil {
|
||||
_ = c.prod.Stop()
|
||||
c.prod = nil
|
||||
}
|
||||
|
||||
if c.ws != nil {
|
||||
@@ -537,6 +536,6 @@ func (c *Client) MarshalJSON() ([]byte, error) {
|
||||
if webrtcProd, ok := c.prod.(*webrtc.Conn); ok {
|
||||
return webrtcProd.MarshalJSON()
|
||||
}
|
||||
|
||||
return nil, errors.New("ring: can't marshal")
|
||||
|
||||
return json.Marshal(c.prod)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ func NewSnapshotProducer(client *RingRestClient, camera *CameraData) *SnapshotPr
|
||||
ID: core.NewID(),
|
||||
FormatName: "ring/snapshot",
|
||||
Protocol: "https",
|
||||
RemoteAddr: "app-snaps.ring.com",
|
||||
Medias: []*core.Media{
|
||||
{
|
||||
Kind: core.KindVideo,
|
||||
@@ -43,7 +44,7 @@ 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 fmt.Errorf("failed to get snapshot: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
pkt := &rtp.Packet{
|
||||
@@ -51,10 +52,7 @@ func (p *SnapshotProducer) Start() error {
|
||||
Payload: response,
|
||||
}
|
||||
|
||||
// Send to all receivers
|
||||
for _, receiver := range p.Receivers {
|
||||
receiver.WriteRTP(pkt)
|
||||
}
|
||||
p.Receivers[0].WriteRTP(pkt)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+2
-1
@@ -7,11 +7,12 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
type UserInfo struct {
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
## Tests
|
||||
|
||||
- go2rtc rtmp client => Reolink
|
||||
- go2rtc rtmp server <= Dahua
|
||||
- go2rtc rtmp publish => YouTube
|
||||
- go2rtc rtmp publish => Telegram
|
||||
|
||||
## Logs
|
||||
|
||||
```
|
||||
|
||||
+15
-6
@@ -46,7 +46,7 @@ func (c *Conn) Close() error {
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
func (c *Conn) readResponse(transID float64) ([]any, error) {
|
||||
func (c *Conn) readResponse(wait func(items []any) bool) ([]any, error) {
|
||||
for {
|
||||
msgType, _, b, err := c.readMessage()
|
||||
if err != nil {
|
||||
@@ -59,7 +59,7 @@ func (c *Conn) readResponse(transID float64) ([]any, error) {
|
||||
c.rdPacketSize = binary.BigEndian.Uint32(b)
|
||||
case TypeCommand:
|
||||
items, _ := amf.NewReader(b).ReadItems()
|
||||
if len(items) >= 3 && (items[1] == transID || items[1] == float64(0)) {
|
||||
if wait(items) {
|
||||
return items, nil
|
||||
}
|
||||
}
|
||||
@@ -250,7 +250,9 @@ func (c *Conn) writeConnect() error {
|
||||
return err
|
||||
}
|
||||
|
||||
v, err := c.readResponse(1)
|
||||
v, err := c.readResponse(func(items []any) bool {
|
||||
return len(items) >= 3 && items[0] == "_result" && items[1] == float64(1)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -280,7 +282,9 @@ func (c *Conn) writeCreateStream() error {
|
||||
return err
|
||||
}
|
||||
|
||||
v, err := c.readResponse(4)
|
||||
v, err := c.readResponse(func(items []any) bool {
|
||||
return len(items) >= 3 && items[0] == "_result" && items[1] == float64(4)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -301,7 +305,10 @@ func (c *Conn) writePublish() error {
|
||||
return err
|
||||
}
|
||||
|
||||
v, err := c.readResponse(5)
|
||||
// YouTube can response with "onBWDone 0"
|
||||
v, err := c.readResponse(func(items []any) bool {
|
||||
return len(items) >= 3 && items[0] == "onStatus"
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -321,7 +328,9 @@ func (c *Conn) writePlay() error {
|
||||
}
|
||||
|
||||
// Reolink response with ID=0, other software respose with ID=5
|
||||
v, err := c.readResponse(5)
|
||||
v, err := c.readResponse(func(items []any) bool {
|
||||
return len(items) >= 3 && items[0] == "onStatus"
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
+45
-7
@@ -2,10 +2,13 @@ package rtmp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/flv/amf"
|
||||
@@ -34,23 +37,54 @@ func NewServer(conn net.Conn) (*Conn, error) {
|
||||
}
|
||||
|
||||
func (c *Conn) serverHandshake() error {
|
||||
b := make([]byte, 1+1536)
|
||||
// read C0+C1
|
||||
// based on https://rtmp.veriskope.com/docs/spec/
|
||||
_ = c.conn.SetDeadline(time.Now().Add(core.ConnDeadline))
|
||||
|
||||
// read C0
|
||||
b := make([]byte, 1)
|
||||
if _, err := io.ReadFull(c.rd, b); err != nil {
|
||||
return err
|
||||
}
|
||||
// write S0+S1, skip random
|
||||
|
||||
if b[0] != 3 {
|
||||
return errors.New("rtmp: wrong handshake")
|
||||
}
|
||||
|
||||
// write S0
|
||||
if _, err := c.conn.Write([]byte{3}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b = make([]byte, 1536)
|
||||
|
||||
// write S1
|
||||
tsS1 := nowMS()
|
||||
binary.BigEndian.PutUint32(b, tsS1)
|
||||
binary.BigEndian.PutUint32(b[4:], 0)
|
||||
_, _ = rand.Read(b[8:])
|
||||
if _, err := c.conn.Write(b); err != nil {
|
||||
return err
|
||||
}
|
||||
// read S1, skip check
|
||||
if _, err := io.ReadFull(c.rd, make([]byte, 1536)); err != nil {
|
||||
|
||||
// read C1
|
||||
if _, err := io.ReadFull(c.rd, b); err != nil {
|
||||
return err
|
||||
}
|
||||
// write C1
|
||||
if _, err := c.conn.Write(b[1:]); err != nil {
|
||||
|
||||
// write S2
|
||||
tsS2 := nowMS()
|
||||
binary.BigEndian.PutUint32(b, tsS1)
|
||||
binary.BigEndian.PutUint32(b[4:], tsS2)
|
||||
if _, err := c.conn.Write(b); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// read C2
|
||||
if _, err := io.ReadFull(c.rd, b); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = c.conn.SetDeadline(time.Time{})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -161,3 +195,7 @@ func (c *Conn) WriteStart() error {
|
||||
payload := amf.EncodeItems("onStatus", 0, nil, map[string]any{"code": code})
|
||||
return c.writeMessage(3, TypeCommand, 0, payload)
|
||||
}
|
||||
|
||||
func nowMS() uint32 {
|
||||
return uint32(time.Now().UnixNano() / int64(time.Millisecond))
|
||||
}
|
||||
|
||||
+2
-4
@@ -237,13 +237,11 @@ func (c *Conn) SetupMedia(media *core.Media) (byte, error) {
|
||||
rawURL := media.ID // control
|
||||
if !strings.Contains(rawURL, "://") {
|
||||
rawURL = c.URL.String()
|
||||
if !strings.HasSuffix(rawURL, "/") {
|
||||
// prefix check for https://github.com/AlexxIT/go2rtc/issues/1236
|
||||
if !strings.HasSuffix(rawURL, "/") && !strings.HasPrefix(media.ID, "/") {
|
||||
rawURL += "/"
|
||||
}
|
||||
rawURL += media.ID
|
||||
} else if strings.HasPrefix(rawURL, "rtsp://rtsp://") {
|
||||
// fix https://github.com/AlexxIT/go2rtc/issues/830
|
||||
rawURL = rawURL[7:]
|
||||
}
|
||||
trackURL, err := urlParse(rawURL)
|
||||
if err != nil {
|
||||
|
||||
@@ -75,6 +75,16 @@ func UnmarshalSDP(rawSDP []byte) ([]*core.Media, error) {
|
||||
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
|
||||
@@ -107,6 +117,7 @@ func findFmtpLine(payloadType uint8, descriptions []*sdp.MediaDescription) strin
|
||||
// 1. Content-Base: rtsp://::ffff:192.168.1.123/onvif/profile.1/
|
||||
// 2. Content-Base: rtsp://rtsp://turret2-cam.lan:554/stream1/
|
||||
func urlParse(rawURL string) (*url.URL, error) {
|
||||
// fix https://github.com/AlexxIT/go2rtc/issues/830
|
||||
if strings.HasPrefix(rawURL, "rtsp://rtsp://") {
|
||||
rawURL = rawURL[7:]
|
||||
}
|
||||
|
||||
+19
-8
@@ -19,19 +19,30 @@ 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)
|
||||
|
||||
+31
-12
@@ -13,6 +13,8 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
)
|
||||
|
||||
var FailedAuth = errors.New("failed authentication")
|
||||
|
||||
func NewServer(conn net.Conn) *Conn {
|
||||
return &Conn{
|
||||
Connection: core.Connection{
|
||||
@@ -45,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"`}},
|
||||
@@ -54,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
|
||||
@@ -129,6 +136,16 @@ 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
|
||||
@@ -141,29 +158,31 @@ func (c *Conn) Accept() error {
|
||||
}
|
||||
|
||||
case MethodSetup:
|
||||
tr := req.Header.Get("Transport")
|
||||
|
||||
res := &tcp.Response{
|
||||
Header: map[string][]string{},
|
||||
Request: req,
|
||||
}
|
||||
|
||||
const transport = "RTP/AVP/TCP;unicast;interleaved="
|
||||
if tr = core.Between(tr, "interleaved=", ";"); tr != "" {
|
||||
// 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
|
||||
tr = fmt.Sprintf("%d-%d", i*2, i*2+1)
|
||||
res.Header.Set("Transport", transport+tr)
|
||||
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", transport+tr)
|
||||
res.Header.Set("Transport", tr)
|
||||
}
|
||||
} else {
|
||||
res.Status = "461 Unsupported transport"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !linux
|
||||
|
||||
package shell
|
||||
|
||||
import "syscall"
|
||||
|
||||
var procAttr *syscall.SysProcAttr
|
||||
@@ -0,0 +1,6 @@
|
||||
package shell
|
||||
|
||||
import "syscall"
|
||||
|
||||
// will stop child if parent died (even with SIGKILL)
|
||||
var procAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGTERM}
|
||||
+9
-2
@@ -65,6 +65,13 @@ func (s *Server) DelSession(session *Session) {
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) GetSession(ssrc uint32) (session *Session) {
|
||||
s.mu.Lock()
|
||||
session = s.sessions[ssrc]
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) handle() error {
|
||||
b := make([]byte, 2048)
|
||||
for {
|
||||
@@ -80,14 +87,14 @@ func (s *Server) handle() error {
|
||||
case 99, 110, 0x80 | 99, 0x80 | 110:
|
||||
// this is default position for SSRC in RTP packet
|
||||
ssrc := binary.BigEndian.Uint32(b[8:])
|
||||
if session, ok := s.sessions[ssrc]; ok {
|
||||
if session := s.GetSession(ssrc); session != nil {
|
||||
session.ReadRTP(b[:n])
|
||||
}
|
||||
|
||||
case 200, 201, 202, 203, 204, 205, 206, 207:
|
||||
// this is default position for SSRC in RTCP packet
|
||||
ssrc := binary.BigEndian.Uint32(b[4:])
|
||||
if session, ok := s.sessions[ssrc]; ok {
|
||||
if session := s.GetSession(ssrc); session != nil {
|
||||
session.ReadRTCP(b[:n])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package stdin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/pion/rtp"
|
||||
@@ -42,10 +41,7 @@ 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) {
|
||||
|
||||
+3
-4
@@ -1,21 +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{
|
||||
|
||||
@@ -291,6 +291,7 @@ func dial(req *http.Request, brand, username, password string) (net.Conn, *http.
|
||||
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")
|
||||
|
||||
+4
-4
@@ -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 {
|
||||
|
||||
@@ -5,10 +5,11 @@ import (
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
)
|
||||
|
||||
func Dial(address string) (net.Conn, error) {
|
||||
|
||||
+48
-17
@@ -4,6 +4,8 @@ import (
|
||||
"net"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/xnet"
|
||||
"github.com/pion/ice/v2"
|
||||
"github.com/pion/interceptor"
|
||||
"github.com/pion/webrtc/v3"
|
||||
)
|
||||
@@ -18,6 +20,7 @@ func NewAPI() (*webrtc.API, error) {
|
||||
|
||||
type Filters struct {
|
||||
Candidates []string `yaml:"candidates"`
|
||||
Loopback bool `yaml:"loopback"`
|
||||
Interfaces []string `yaml:"interfaces"`
|
||||
IPs []string `yaml:"ips"`
|
||||
Networks []string `yaml:"networks"`
|
||||
@@ -44,39 +47,53 @@ func NewServerAPI(network, address string, filters *Filters) (*webrtc.API, error
|
||||
// fix https://github.com/pion/webrtc/pull/2407
|
||||
s.SetDTLSInsecureSkipHelloVerify(true)
|
||||
|
||||
if filters != nil && filters.Loopback {
|
||||
s.SetIncludeLoopbackCandidate(true)
|
||||
}
|
||||
|
||||
var interfaceFilter func(name string) bool
|
||||
if filters != nil && filters.Interfaces != nil {
|
||||
s.SetIncludeLoopbackCandidate(true)
|
||||
s.SetInterfaceFilter(func(name string) bool {
|
||||
interfaceFilter = func(name string) bool {
|
||||
return core.Contains(filters.Interfaces, name)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// disable listen on Hassio docker interfaces
|
||||
s.SetInterfaceFilter(func(name string) bool {
|
||||
return name != "hassio" && name != "docker0"
|
||||
})
|
||||
// default interfaces - all, except loopback
|
||||
}
|
||||
s.SetInterfaceFilter(interfaceFilter)
|
||||
|
||||
var ipFilter func(ip net.IP) bool
|
||||
if filters != nil && filters.IPs != nil {
|
||||
s.SetIncludeLoopbackCandidate(true)
|
||||
s.SetIPFilter(func(ip net.IP) bool {
|
||||
ipFilter = func(ip net.IP) bool {
|
||||
return core.Contains(filters.IPs, ip.String())
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// try filter all Docker-like interfaces
|
||||
ipFilter = func(ip net.IP) bool {
|
||||
return !xnet.Docker.Contains(ip)
|
||||
}
|
||||
// if there are no such interfaces - disable the filter
|
||||
// the user will need to enable port forwarding
|
||||
if nets, _ := xnet.IPNets(ipFilter); len(nets) == 0 {
|
||||
ipFilter = nil
|
||||
}
|
||||
}
|
||||
s.SetIPFilter(ipFilter)
|
||||
|
||||
var networkTypes []webrtc.NetworkType
|
||||
if filters != nil && filters.Networks != nil {
|
||||
var networkTypes []webrtc.NetworkType
|
||||
for _, s := range filters.Networks {
|
||||
if networkType, err := webrtc.NewNetworkType(s); err == nil {
|
||||
networkTypes = append(networkTypes, networkType)
|
||||
}
|
||||
}
|
||||
s.SetNetworkTypes(networkTypes)
|
||||
} else {
|
||||
s.SetNetworkTypes([]webrtc.NetworkType{
|
||||
// default network types - all
|
||||
networkTypes = []webrtc.NetworkType{
|
||||
webrtc.NetworkTypeUDP4, webrtc.NetworkTypeUDP6,
|
||||
webrtc.NetworkTypeTCP4, webrtc.NetworkTypeTCP6,
|
||||
})
|
||||
}
|
||||
}
|
||||
s.SetNetworkTypes(networkTypes)
|
||||
|
||||
if filters != nil && len(filters.UDPPorts) == 2 {
|
||||
_ = s.SetEphemeralUDPPortRange(filters.UDPPorts[0], filters.UDPPorts[1])
|
||||
@@ -100,10 +117,24 @@ func NewServerAPI(network, address string, filters *Filters) (*webrtc.API, error
|
||||
}
|
||||
|
||||
if network == "" || network == "udp" {
|
||||
if ln, err := net.ListenPacket("udp", address); err == nil {
|
||||
udpMux := webrtc.NewICEUDPMux(nil, ln)
|
||||
s.SetICEUDPMux(udpMux)
|
||||
// UDPMuxDefault should not listening on unspecified address, use NewMultiUDPMuxFromPort instead
|
||||
var udpMux ice.UDPMux
|
||||
if port := xnet.ParseUnspecifiedPort(address); port != 0 {
|
||||
var networks []ice.NetworkType
|
||||
for _, ntype := range networkTypes {
|
||||
networks = append(networks, ice.NetworkType(ntype))
|
||||
}
|
||||
|
||||
udpMux, _ = ice.NewMultiUDPMuxFromPort(
|
||||
port,
|
||||
ice.UDPMuxFromPortWithInterfaceFilter(interfaceFilter),
|
||||
ice.UDPMuxFromPortWithIPFilter(ipFilter),
|
||||
ice.UDPMuxFromPortWithNetworks(networks...),
|
||||
)
|
||||
} else if ln, err := net.ListenPacket("udp", address); err == nil {
|
||||
udpMux = ice.NewUDPMuxDefault(ice.UDPMuxParams{UDPConn: ln})
|
||||
}
|
||||
s.SetICEUDPMux(udpMux)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,10 @@ func NewConn(pc *webrtc.PeerConnection) *Conn {
|
||||
}
|
||||
pc.SCTP().Transport().ICETransport().OnSelectedCandidatePairChange(
|
||||
func(pair *webrtc.ICECandidatePair) {
|
||||
// fix situation when candidate pair changes multiple times
|
||||
if i := strings.IndexByte(c.Protocol, '+'); i > 0 {
|
||||
c.Protocol = c.Protocol[:i]
|
||||
}
|
||||
c.Protocol += "+" + pair.Remote.Protocol.String()
|
||||
c.RemoteAddr = fmt.Sprintf(
|
||||
"%s:%d %s", sanitizeIP6(pair.Remote.Address), pair.Remote.Port, pair.Remote.Typ,
|
||||
|
||||
@@ -3,10 +3,11 @@ package webtorrent
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/gorilla/websocket"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package xnet
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Docker has common docker addresses (class B):
|
||||
// https://en.wikipedia.org/wiki/Private_network
|
||||
// - docker0 172.17.0.1/16
|
||||
// - br-xxxx 172.18.0.1/16
|
||||
// - hassio 172.30.32.1/23
|
||||
var Docker = net.IPNet{
|
||||
IP: []byte{172, 16, 0, 0},
|
||||
Mask: []byte{255, 240, 0, 0},
|
||||
}
|
||||
|
||||
// ParseUnspecifiedPort will return port if address is unspecified
|
||||
// ex. ":8555" or "0.0.0.0:8555"
|
||||
func ParseUnspecifiedPort(address string) int {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
if host != "" && host != "0.0.0.0" && host != "[::]" {
|
||||
return 0
|
||||
}
|
||||
|
||||
i, _ := strconv.Atoi(port)
|
||||
return i
|
||||
}
|
||||
|
||||
func IPNets(ipFilter func(ip net.IP) bool) ([]*net.IPNet, error) {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var nets []*net.IPNet
|
||||
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
addrs, _ := iface.Addrs() // range on nil slice is OK
|
||||
for _, addr := range addrs {
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip := v.IP.To4()
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
if ipFilter != nil && !ipFilter(ip) {
|
||||
continue
|
||||
}
|
||||
nets = append(nets, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nets, nil
|
||||
}
|
||||
+131
-105
@@ -23,149 +23,157 @@ func Encode(v any, indent int) ([]byte, error) {
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
// Patch - change key/value pair in YAML file without break formatting
|
||||
func Patch(src []byte, key string, value any, path ...string) ([]byte, error) {
|
||||
nodeParent, err := FindParent(src, path...)
|
||||
func Patch(in []byte, path []string, value any) ([]byte, error) {
|
||||
out, err := patch(in, path, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var dst []byte
|
||||
|
||||
if nodeParent != nil {
|
||||
dst, err = AddOrReplace(src, key, value, nodeParent)
|
||||
} else {
|
||||
dst, err = AddToEnd(src, key, value, path...)
|
||||
}
|
||||
|
||||
if err = yaml.Unmarshal(dst, map[string]any{}); err != nil {
|
||||
// validate
|
||||
if err = yaml.Unmarshal(out, map[string]any{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dst, nil
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FindParent - return YAML Node from path of keys (tree)
|
||||
func FindParent(src []byte, path ...string) (*yaml.Node, error) {
|
||||
if len(src) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func patch(in []byte, path []string, value any) ([]byte, error) {
|
||||
var root yaml.Node
|
||||
if err := yaml.Unmarshal(src, &root); err != nil {
|
||||
if err := yaml.Unmarshal(in, &root); err != nil {
|
||||
// invalid yaml
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if root.Content == nil {
|
||||
return nil, nil
|
||||
// empty in
|
||||
if len(root.Content) != 1 {
|
||||
return addToEnd(in, path, value)
|
||||
}
|
||||
|
||||
parent := root.Content[0] // yaml.DocumentNode
|
||||
for _, name := range path {
|
||||
if parent == nil {
|
||||
break
|
||||
}
|
||||
_, parent = FindChild(parent, name)
|
||||
// yaml is not dict
|
||||
if root.Content[0].Kind != yaml.MappingNode {
|
||||
return nil, errors.New("yaml: can't patch")
|
||||
}
|
||||
return parent, nil
|
||||
|
||||
// dict items list
|
||||
nodes := root.Content[0].Content
|
||||
|
||||
n := len(path) - 1
|
||||
|
||||
// parent node key/value
|
||||
pKey, pVal := findNode(nodes, path[:n])
|
||||
if pKey == nil {
|
||||
// no parent node
|
||||
return addToEnd(in, path, value)
|
||||
}
|
||||
|
||||
var paste []byte
|
||||
|
||||
if value != nil {
|
||||
// nil value means delete key
|
||||
var err error
|
||||
v := map[string]any{path[n]: value}
|
||||
if paste, err = Encode(v, 2); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
iKey, _ := findNode(pVal.Content, path[n:])
|
||||
if iKey != nil {
|
||||
// key item not nil (replace value)
|
||||
paste = addIndent(paste, iKey.Column-1)
|
||||
|
||||
i0, i1 := nodeBounds(in, iKey)
|
||||
return join(in[:i0], paste, in[i1:]), nil
|
||||
}
|
||||
|
||||
if pVal.Content != nil {
|
||||
// parent value not nil (use first child indent)
|
||||
paste = addIndent(paste, pVal.Column-1)
|
||||
} else {
|
||||
// parent value is nil (use parent indent + 2)
|
||||
paste = addIndent(paste, pKey.Column+1)
|
||||
}
|
||||
|
||||
_, i1 := nodeBounds(in, pKey)
|
||||
return join(in[:i1], paste, in[i1:]), nil
|
||||
}
|
||||
|
||||
// FindChild - search and return YAML key/value pair for current Node
|
||||
func FindChild(node *yaml.Node, name string) (key, value *yaml.Node) {
|
||||
for i, child := range node.Content {
|
||||
if child.Value != name {
|
||||
continue
|
||||
func findNode(nodes []*yaml.Node, keys []string) (key, value *yaml.Node) {
|
||||
for i, name := range keys {
|
||||
for j := 0; j < len(nodes); j += 2 {
|
||||
if nodes[j].Value == name {
|
||||
if i < len(keys)-1 {
|
||||
nodes = nodes[j+1].Content
|
||||
break
|
||||
}
|
||||
return nodes[j], nodes[j+1]
|
||||
}
|
||||
}
|
||||
return child, node.Content[i+1]
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func FirstChild(node *yaml.Node) *yaml.Node {
|
||||
if node.Content == nil {
|
||||
return node
|
||||
}
|
||||
return node.Content[0]
|
||||
}
|
||||
func nodeBounds(in []byte, node *yaml.Node) (offset0, offset1 int) {
|
||||
// start from next line after node
|
||||
offset0 = lineOffset(in, node.Line)
|
||||
offset1 = lineOffset(in, node.Line+1)
|
||||
|
||||
func LastChild(node *yaml.Node) *yaml.Node {
|
||||
if node.Content == nil {
|
||||
return node
|
||||
}
|
||||
return LastChild(node.Content[len(node.Content)-1])
|
||||
}
|
||||
|
||||
func AddOrReplace(src []byte, key string, value any, nodeParent *yaml.Node) ([]byte, error) {
|
||||
v := map[string]any{key: value}
|
||||
put, err := Encode(v, 2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if offset1 < 0 {
|
||||
return offset0, len(in)
|
||||
}
|
||||
|
||||
if nodeKey, nodeValue := FindChild(nodeParent, key); nodeKey != nil {
|
||||
put = AddIndent(put, nodeKey.Column-1)
|
||||
|
||||
i0 := LineOffset(src, nodeKey.Line)
|
||||
i1 := LineOffset(src, LastChild(nodeValue).Line+1)
|
||||
|
||||
if i1 < 0 { // no new line on the end of file
|
||||
if value != nil {
|
||||
return append(src[:i0], put...), nil
|
||||
for i := offset1; i < len(in); {
|
||||
indent, length := parseLine(in[i:])
|
||||
if indent+1 != length {
|
||||
if node.Column < indent+1 {
|
||||
offset1 = i + length
|
||||
} else {
|
||||
break
|
||||
}
|
||||
return src[:i0], nil
|
||||
}
|
||||
|
||||
dst := make([]byte, 0, len(src)+len(put))
|
||||
dst = append(dst, src[:i0]...)
|
||||
if value != nil {
|
||||
dst = append(dst, put...)
|
||||
}
|
||||
return append(dst, src[i1:]...), nil
|
||||
i += length
|
||||
}
|
||||
|
||||
put = AddIndent(put, FirstChild(nodeParent).Column-1)
|
||||
|
||||
i := LineOffset(src, LastChild(nodeParent).Line+1)
|
||||
|
||||
if i < 0 { // no new line on the end of file
|
||||
src = append(src, '\n')
|
||||
if value != nil {
|
||||
src = append(src, put...)
|
||||
}
|
||||
return src, nil
|
||||
}
|
||||
|
||||
dst := make([]byte, 0, len(src)+len(put))
|
||||
dst = append(dst, src[:i]...)
|
||||
if value != nil {
|
||||
dst = append(dst, put...)
|
||||
}
|
||||
return append(dst, src[i:]...), nil
|
||||
return
|
||||
}
|
||||
|
||||
func AddToEnd(src []byte, key string, value any, path ...string) ([]byte, error) {
|
||||
if len(path) > 1 || value == nil {
|
||||
return nil, errors.New("config: path not exist")
|
||||
func addToEnd(in []byte, path []string, value any) ([]byte, error) {
|
||||
if len(path) != 2 || value == nil {
|
||||
return nil, errors.New("yaml: path not exist")
|
||||
}
|
||||
|
||||
v := map[string]map[string]any{
|
||||
path[0]: {key: value},
|
||||
path[0]: {path[1]: value},
|
||||
}
|
||||
put, err := Encode(v, 2)
|
||||
paste, err := Encode(v, 2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dst := make([]byte, 0, len(src)+len(put)+10)
|
||||
dst = append(dst, src...)
|
||||
if l := len(src); l > 0 && src[l-1] != '\n' {
|
||||
dst = append(dst, '\n')
|
||||
}
|
||||
return append(dst, put...), nil
|
||||
return join(in, paste), nil
|
||||
}
|
||||
|
||||
func AddPrefix(src, pre []byte) (dst []byte) {
|
||||
func join(items ...[]byte) []byte {
|
||||
n := len(items) - 1
|
||||
for _, b := range items {
|
||||
n += len(b)
|
||||
}
|
||||
|
||||
buf := make([]byte, 0, n)
|
||||
for _, b := range items {
|
||||
if len(b) == 0 {
|
||||
continue
|
||||
}
|
||||
if n = len(buf); n > 0 && buf[n-1] != '\n' {
|
||||
buf = append(buf, '\n')
|
||||
}
|
||||
buf = append(buf, b...)
|
||||
}
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
func addPrefix(src, pre []byte) (dst []byte) {
|
||||
for len(src) > 0 {
|
||||
dst = append(dst, pre...)
|
||||
i := bytes.IndexByte(src, '\n') + 1
|
||||
@@ -180,21 +188,21 @@ func AddPrefix(src, pre []byte) (dst []byte) {
|
||||
return
|
||||
}
|
||||
|
||||
func AddIndent(src []byte, indent int) (dst []byte) {
|
||||
func addIndent(in []byte, indent int) (dst []byte) {
|
||||
pre := make([]byte, indent)
|
||||
for i := 0; i < indent; i++ {
|
||||
pre[i] = ' '
|
||||
}
|
||||
return AddPrefix(src, pre)
|
||||
return addPrefix(in, pre)
|
||||
}
|
||||
|
||||
func LineOffset(b []byte, line int) (offset int) {
|
||||
func lineOffset(in []byte, line int) (offset int) {
|
||||
for l := 1; ; l++ {
|
||||
if l == line {
|
||||
return offset
|
||||
}
|
||||
|
||||
i := bytes.IndexByte(b[offset:], '\n') + 1
|
||||
i := bytes.IndexByte(in[offset:], '\n') + 1
|
||||
if i == 0 {
|
||||
break
|
||||
}
|
||||
@@ -202,3 +210,21 @@ func LineOffset(b []byte, line int) (offset int) {
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func parseLine(b []byte) (indent int, length int) {
|
||||
prefix := true
|
||||
for ; length < len(b); length++ {
|
||||
switch b[length] {
|
||||
case ' ':
|
||||
if prefix {
|
||||
indent++
|
||||
}
|
||||
case '\n':
|
||||
length++
|
||||
return
|
||||
default:
|
||||
prefix = false
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
+99
-136
@@ -7,140 +7,103 @@ import (
|
||||
)
|
||||
|
||||
func TestPatch(t *testing.T) {
|
||||
b := []byte(`# prefix`)
|
||||
|
||||
// 1. Add first
|
||||
b, err := Patch(b, "camera1", "url1", "streams")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, `# prefix
|
||||
streams:
|
||||
camera1: url1
|
||||
`, string(b))
|
||||
|
||||
// 2. Add second
|
||||
b, err = Patch(b, "camera2", []string{"url2", "url3"}, "streams")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, `# prefix
|
||||
streams:
|
||||
camera1: url1
|
||||
camera2:
|
||||
- url2
|
||||
- url3
|
||||
`, string(b))
|
||||
|
||||
// 3. Replace first
|
||||
b, err = Patch(b, "camera1", "url4", "streams")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, `# prefix
|
||||
streams:
|
||||
camera1: url4
|
||||
camera2:
|
||||
- url2
|
||||
- url3
|
||||
`, string(b))
|
||||
|
||||
// 4. Replace second
|
||||
b, err = Patch(b, "camera2", "url5", "streams")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, `# prefix
|
||||
streams:
|
||||
camera1: url4
|
||||
camera2: url5
|
||||
`, string(b))
|
||||
|
||||
// 5. Delete first
|
||||
b, err = Patch(b, "camera1", nil, "streams")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, `# prefix
|
||||
streams:
|
||||
camera2: url5
|
||||
`, string(b))
|
||||
}
|
||||
|
||||
func TestPatchParings(t *testing.T) {
|
||||
b := []byte(`homekit:
|
||||
camera1:
|
||||
pin: 123-45-678
|
||||
streams:
|
||||
camera1: url1
|
||||
`)
|
||||
|
||||
// 1. Add new key
|
||||
pairings := []string{"client1", "client2"}
|
||||
|
||||
b, err := Patch(b, "pairings", pairings, "homekit", "camera1")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, `homekit:
|
||||
camera1:
|
||||
pin: 123-45-678
|
||||
pairings:
|
||||
- client1
|
||||
- client2
|
||||
streams:
|
||||
camera1: url1
|
||||
`, string(b))
|
||||
}
|
||||
|
||||
func TestPatch2(t *testing.T) {
|
||||
b := []byte(`streams:
|
||||
camera1:
|
||||
- url1
|
||||
- url2
|
||||
`)
|
||||
|
||||
b, err := Patch(b, "camera2", "url3", "streams")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, `streams:
|
||||
camera1:
|
||||
- url1
|
||||
- url2
|
||||
camera2: url3
|
||||
`, string(b))
|
||||
}
|
||||
|
||||
func TestNoNewLineEnd1(t *testing.T) {
|
||||
b := []byte(`streams:
|
||||
camera1: url4
|
||||
camera2:
|
||||
- url2
|
||||
- url3`)
|
||||
|
||||
b, err := Patch(b, "camera2", "url5", "streams")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, `streams:
|
||||
camera1: url4
|
||||
camera2: url5
|
||||
`, string(b))
|
||||
}
|
||||
|
||||
func TestNoNewLineEnd2(t *testing.T) {
|
||||
b := []byte(`streams:
|
||||
camera1: url1
|
||||
homekit:
|
||||
camera1:
|
||||
pin: 123-45-678`)
|
||||
|
||||
// 1. Add new key
|
||||
pairings := []string{"client1", "client2"}
|
||||
|
||||
b, err := Patch(b, "pairings", pairings, "homekit", "camera1")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, `streams:
|
||||
camera1: url1
|
||||
homekit:
|
||||
camera1:
|
||||
pin: 123-45-678
|
||||
pairings:
|
||||
- client1
|
||||
- client2
|
||||
`, string(b))
|
||||
tests := []struct {
|
||||
name string
|
||||
src string
|
||||
path []string
|
||||
value any
|
||||
expect string
|
||||
}{
|
||||
{
|
||||
name: "empty config",
|
||||
src: "",
|
||||
path: []string{"streams", "camera1"},
|
||||
value: "val1",
|
||||
expect: "streams:\n camera1: val1\n",
|
||||
},
|
||||
{
|
||||
name: "empty main key",
|
||||
src: "#dummy",
|
||||
path: []string{"streams", "camera1"},
|
||||
value: "val1",
|
||||
expect: "#dummy\nstreams:\n camera1: val1\n",
|
||||
},
|
||||
{
|
||||
name: "single line value",
|
||||
src: "streams:\n camera1: url1\n camera2: url2",
|
||||
path: []string{"streams", "camera1"},
|
||||
value: "val1",
|
||||
expect: "streams:\n camera1: val1\n camera2: url2",
|
||||
},
|
||||
{
|
||||
name: "next line value",
|
||||
src: "streams:\n camera1:\n url1\n camera2: url2",
|
||||
path: []string{"streams", "camera1"},
|
||||
value: "val1",
|
||||
expect: "streams:\n camera1: val1\n camera2: url2",
|
||||
},
|
||||
{
|
||||
name: "two lines value",
|
||||
src: "streams:\n camera1: url1\n url2\n camera2: url2",
|
||||
path: []string{"streams", "camera1"},
|
||||
value: "val1",
|
||||
expect: "streams:\n camera1: val1\n camera2: url2",
|
||||
},
|
||||
{
|
||||
name: "next two lines value",
|
||||
src: "streams:\n camera1:\n url1\n url2\n camera2: url2",
|
||||
path: []string{"streams", "camera1"},
|
||||
value: "val1",
|
||||
expect: "streams:\n camera1: val1\n camera2: url2",
|
||||
},
|
||||
{
|
||||
name: "add array",
|
||||
src: "",
|
||||
path: []string{"streams", "camera1"},
|
||||
value: []string{"val1", "val2"},
|
||||
expect: "streams:\n camera1:\n - val1\n - val2\n",
|
||||
},
|
||||
{
|
||||
name: "remove value",
|
||||
src: "streams:\n camera1: url1\n camera2: url2",
|
||||
path: []string{"streams", "camera1"},
|
||||
value: nil,
|
||||
expect: "streams:\n camera2: url2",
|
||||
},
|
||||
{
|
||||
name: "add pairings",
|
||||
src: "homekit:\n camera1:\nstreams:\n camera1: url1",
|
||||
path: []string{"homekit", "camera1", "pairings"},
|
||||
value: []string{"val1"},
|
||||
expect: "homekit:\n camera1:\n pairings:\n - val1\nstreams:\n camera1: url1",
|
||||
},
|
||||
{
|
||||
name: "remove pairings",
|
||||
src: "homekit:\n camera1:\n pairings:\n - val1\nstreams:\n camera1: url1",
|
||||
path: []string{"homekit", "camera1", "pairings"},
|
||||
value: nil,
|
||||
expect: "homekit:\n camera1:\nstreams:\n camera1: url1",
|
||||
},
|
||||
{
|
||||
name: "no new line",
|
||||
src: "streams:\n camera1: url1",
|
||||
path: []string{"streams", "camera1"},
|
||||
value: "val1",
|
||||
expect: "streams:\n camera1: val1\n",
|
||||
},
|
||||
{
|
||||
name: "no new line",
|
||||
src: "streams:\n camera1: url1\nhomekit:\n camera1:\n name: dummy",
|
||||
path: []string{"homekit", "camera1", "pairings"},
|
||||
value: []string{"val1"},
|
||||
expect: "streams:\n camera1: url1\nhomekit:\n camera1:\n name: dummy\n pairings:\n - val1\n",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
b, err := Patch([]byte(tt.src), tt.path, tt.value)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.expect, string(b))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,12 @@ 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.
|
||||
|
||||
```
|
||||
golang.org/x/crypto v0.33.0
|
||||
golang.org/x/mod v0.20.0 // indirect
|
||||
golang.org/x/tools v0.24.0 // indirect
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
- UPX-3.96 pack broken bin for `linux_mipsel`
|
||||
|
||||
@@ -61,3 +61,13 @@ go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel %FILENAME% go2rtc
|
||||
@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
|
||||
|
||||
+13
-1
@@ -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
|
||||
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
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
r = await fetch('api/config', {method: 'POST', body: editor.getValue()});
|
||||
if (r.ok) {
|
||||
alert('OK');
|
||||
dump = editor.getValue();
|
||||
await fetch('api/restart', {method: 'POST'});
|
||||
} else {
|
||||
alert(await r.text());
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
const positions = network.getPositions();
|
||||
const viewPosition = network.getViewPosition();
|
||||
const scale = network.getScale();
|
||||
const selectedNodes = network.getSelectedNodes();
|
||||
|
||||
network.setData(data);
|
||||
|
||||
@@ -65,6 +66,8 @@
|
||||
}
|
||||
|
||||
network.moveTo({position: viewPosition, scale: scale});
|
||||
|
||||
network.selectNodes(selectedNodes);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching or updating network data:', error);
|
||||
|
||||
Reference in New Issue
Block a user