mirror of
https://github.com/AlexxIT/go2rtc.git
synced 2025-10-04 16:02:43 +08:00
Compare commits
141 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
a4885c2c3a | ||
![]() |
f5aaee006e | ||
![]() |
db6745e8ff | ||
![]() |
ba34855602 | ||
![]() |
e6fa97c738 | ||
![]() |
5b481a27c6 | ||
![]() |
bdc7ff1035 | ||
![]() |
da5f060741 | ||
![]() |
a56d335380 | ||
![]() |
d8aed552bc | ||
![]() |
d7286fa06e | ||
![]() |
906f554d74 | ||
![]() |
cb44d5431a | ||
![]() |
a69eb8a66e | ||
![]() |
1b411b1fed | ||
![]() |
5d57959608 | ||
![]() |
31e57c2ff8 | ||
![]() |
734393d638 | ||
![]() |
96504e2fb0 | ||
![]() |
ecfe802065 | ||
![]() |
1ac9d54dab | ||
![]() |
72d7e8aaaa | ||
![]() |
0395696866 | ||
![]() |
0667683e4d | ||
![]() |
aca0781c4b | ||
![]() |
b389d0eb9c | ||
![]() |
bf303ed471 | ||
![]() |
cd777ba2b4 | ||
![]() |
e3188a0a6d | ||
![]() |
2bab0a014d | ||
![]() |
a01da18018 | ||
![]() |
9d5a5c1e45 | ||
![]() |
8377ad1d05 | ||
![]() |
ec33796bd3 | ||
![]() |
31e4ba2722 | ||
![]() |
e0b1a50356 | ||
![]() |
9bb36ebb6c | ||
![]() |
756be9801e | ||
![]() |
bd73b07ed8 | ||
![]() |
df1d44d24e | ||
![]() |
79245eeff4 | ||
![]() |
aa86c1ec25 | ||
![]() |
2ab1d9d774 | ||
![]() |
a9e7a73cc8 | ||
![]() |
ea17b420d6 | ||
![]() |
660979dfda | ||
![]() |
a6b9b4993f | ||
![]() |
cc74504ed8 | ||
![]() |
791239be12 | ||
![]() |
a79061c7c2 | ||
![]() |
50ad3b20c4 | ||
![]() |
649de0131c | ||
![]() |
8cb513cb89 | ||
![]() |
3932dbaa84 | ||
![]() |
4534b4d8ca | ||
![]() |
8e571a66e3 | ||
![]() |
0ccfcb0ec0 | ||
![]() |
8bae4631d2 | ||
![]() |
268629f551 | ||
![]() |
0bd2fcde54 | ||
![]() |
6f34cf0c95 | ||
![]() |
f8bc25d0ae | ||
![]() |
8749562c96 | ||
![]() |
d9d2bdff44 | ||
![]() |
b3e9ed23ac | ||
![]() |
bf3f81ccac | ||
![]() |
ff39e2e496 | ||
![]() |
d2346a2aed | ||
![]() |
8f57b1acb6 | ||
![]() |
6fafd10482 | ||
![]() |
c726651b8b | ||
![]() |
02af2e2849 | ||
![]() |
6d9c7012b0 | ||
![]() |
8a7712a4c8 | ||
![]() |
82fa803a37 | ||
![]() |
78a74da8d6 | ||
![]() |
53242ea02f | ||
![]() |
af05083a1f | ||
![]() |
c41bddbbea | ||
![]() |
54c8ca0112 | ||
![]() |
a518488289 | ||
![]() |
99cc21aacb | ||
![]() |
bc8295baee | ||
![]() |
50f9913c41 | ||
![]() |
4c135b5a46 | ||
![]() |
686fb374e9 | ||
![]() |
2b3e6a2730 | ||
![]() |
9143729042 | ||
![]() |
3952f0ba0f | ||
![]() |
7a131822db | ||
![]() |
b2399f3bb3 | ||
![]() |
2a8a3f1cbf | ||
![]() |
b1ba5bab62 | ||
![]() |
6878f05e57 | ||
![]() |
d428a8964a | ||
![]() |
f432e72dd0 | ||
![]() |
2929db9cec | ||
![]() |
6d967bc1f9 | ||
![]() |
83c0053b2c | ||
![]() |
ecfd7404f5 | ||
![]() |
41badbfb8e | ||
![]() |
0cb013a7fd | ||
![]() |
75020d4df7 | ||
![]() |
69c288b154 | ||
![]() |
0ea651db62 | ||
![]() |
4823e60a92 | ||
![]() |
c4949eb81f | ||
![]() |
aa4c81c266 | ||
![]() |
063fef5813 | ||
![]() |
d9fb734c85 | ||
![]() |
a51156cf18 | ||
![]() |
32e0ee4a10 | ||
![]() |
e6bea97936 | ||
![]() |
9776e09ca7 | ||
![]() |
ad273d3a98 | ||
![]() |
69c301e79f | ||
![]() |
8f2bb3f34b | ||
![]() |
e4ff6d224f | ||
![]() |
00751459a2 | ||
![]() |
874c07b887 | ||
![]() |
152df3ef5d | ||
![]() |
c950bb0252 | ||
![]() |
dd7ea2657a | ||
![]() |
5889791847 | ||
![]() |
9160403b99 | ||
![]() |
5ccbd7c1c2 | ||
![]() |
778245dd1c | ||
![]() |
205018c96a | ||
![]() |
eaba451a47 | ||
![]() |
b7c11db604 | ||
![]() |
f7b98044e6 | ||
![]() |
1b1bdb37db | ||
![]() |
ab453d275e | ||
![]() |
ee387b79e1 | ||
![]() |
e71ed5e7eb | ||
![]() |
122a550599 | ||
![]() |
f3f08afac8 | ||
![]() |
a0030194cb | ||
![]() |
f158ffb33e | ||
![]() |
abe617a346 | ||
![]() |
e080eac204 |
30
.github/workflows/build.yml
vendored
30
.github/workflows/build.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with: { go-version: '1.21' }
|
||||
with: { go-version: '1.22' }
|
||||
|
||||
- name: Build go2rtc_win64
|
||||
env: { GOOS: windows, GOARCH: amd64 }
|
||||
@@ -123,7 +123,9 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ github.repository }}
|
||||
images: |
|
||||
${{ github.repository }}
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}},enable=false
|
||||
@@ -142,6 +144,14 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
@@ -168,10 +178,12 @@ jobs:
|
||||
id: meta-hw
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ github.repository }}
|
||||
images: |
|
||||
${{ github.repository }}
|
||||
ghcr.io/${{ github.repository }}
|
||||
flavor: |
|
||||
suffix=-hardware
|
||||
latest=false
|
||||
suffix=-hardware,onlatest=true
|
||||
latest=auto
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}},enable=false
|
||||
@@ -189,6 +201,14 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
|
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21'
|
||||
go-version: '1.22'
|
||||
|
||||
- name: Build Go binary
|
||||
run: go build -ldflags "-s -w" -trimpath -o ./go2rtc
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@@ -4,6 +4,10 @@
|
||||
go2rtc.yaml
|
||||
go2rtc.json
|
||||
|
||||
go2rtc_linux*
|
||||
go2rtc_mac*
|
||||
go2rtc_win*
|
||||
|
||||
0_test.go
|
||||
|
||||
.DS_Store
|
||||
|
@@ -2,7 +2,7 @@
|
||||
|
||||
# 0. Prepare images
|
||||
ARG PYTHON_VERSION="3.11"
|
||||
ARG GO_VERSION="1.21"
|
||||
ARG GO_VERSION="1.22"
|
||||
ARG NGROK_VERSION="3"
|
||||
|
||||
FROM python:${PYTHON_VERSION}-alpine AS base
|
||||
@@ -20,6 +20,8 @@ ENV GOARCH=${TARGETARCH}
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN apk add git
|
||||
|
||||
# Cache dependencies
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build go mod download
|
||||
|
35
README.md
35
README.md
@@ -1,6 +1,6 @@
|
||||
<h1 align="center">
|
||||
|
||||

|
||||

|
||||
<br>
|
||||
[](https://github.com/AlexxIT/go2rtc/stargazers)
|
||||
[](https://hub.docker.com/r/alexxit/go2rtc)
|
||||
@@ -131,7 +131,7 @@ Don't forget to fix the rights `chmod +x go2rtc_xxx_xxx` on Linux and Mac.
|
||||
|
||||
### go2rtc: Docker
|
||||
|
||||
Container [alexxit/go2rtc](https://hub.docker.com/r/alexxit/go2rtc) with support `amd64`, `386`, `arm64`, `arm`. This container is the same as [Home Assistant Add-on](#go2rtc-home-assistant-add-on), but can be used separately from Home Assistant. Container has preinstalled [FFmpeg](#source-ffmpeg), [ngrok](#module-ngrok) and [Python](#source-echo).
|
||||
The Docker container [`alexxit/go2rtc`](https://hub.docker.com/r/alexxit/go2rtc) supports multiple architectures including `amd64`, `386`, `arm64`, and `arm`. This container offers the same functionality as the [Home Assistant Add-on](#go2rtc-home-assistant-add-on) but is designed to operate independently of Home Assistant. It comes preinstalled with [FFmpeg](#source-ffmpeg), [ngrok](#module-ngrok), and [Python](#source-echo).
|
||||
|
||||
### go2rtc: Home Assistant Add-on
|
||||
|
||||
@@ -429,6 +429,7 @@ streams:
|
||||
stream: exec:ffmpeg -re -i /media/BigBuckBunny.mp4 -c copy -rtsp_transport tcp -f rtsp {output}
|
||||
picam_h264: exec:libcamera-vid -t 0 --inline -o -
|
||||
picam_mjpeg: exec:libcamera-vid -t 0 --codec mjpeg -o -
|
||||
pi5cam_h264: exec:libcamera-vid -t 0 --libav-format h264 -o -
|
||||
canon: exec:gphoto2 --capture-movie --stdout#killsignal=2#killtimeout=5
|
||||
play_pcma: exec:ffplay -fflags nobuffer -f alaw -ar 8000 -i -#backchannel=1
|
||||
play_pcm48k: exec:ffplay -fflags nobuffer -f s16be -ar 48000 -i -#backchannel=1
|
||||
@@ -552,11 +553,16 @@ echo -n "cloud password" | shasum -a 256 | awk '{print toupper($0)}'
|
||||
|
||||
[TP-Link Kasa](https://www.kasasmart.com/) non-standard protocol [more info](https://medium.com/@hu3vjeen/reverse-engineering-tp-link-kc100-bac4641bf1cd).
|
||||
|
||||
- `username` - urlsafe email, `alex@gmail.com` -> `alex%40gmail.com`
|
||||
- `password` - base64password, `secret1` -> `c2VjcmV0MQ==`
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
kasa: kasa://user:pass@192.168.1.123:19443/https/stream/mixed
|
||||
kc401: kasa://username:password@192.168.1.123:19443/https/stream/mixed
|
||||
```
|
||||
|
||||
Tested: KD110, KC200, KC401, KC420WS, EC71.
|
||||
|
||||
#### Source: GoPro
|
||||
|
||||
*[New in v1.8.3](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.3)*
|
||||
@@ -773,7 +779,7 @@ POST http://localhost:1984/api/streams?dst=camera1&src=ffmpeg:http://example.com
|
||||
You can publish any stream to streaming services (YouTube, Telegram, etc.) via RTMP/RTMPS. Important:
|
||||
|
||||
- Supported codecs: H264 for video and AAC for audio
|
||||
- Pixel format should be `yuv420p`, for cameras with `yuvj420p` format you SHOULD use [transcoding](#source-ffmpeg)
|
||||
- AAC audio is required for YouTube, videos without audio will not work
|
||||
- You don't need to enable [RTMP module](#module-rtmp) listening for this task
|
||||
|
||||
You can use API:
|
||||
@@ -786,16 +792,19 @@ Or config file:
|
||||
|
||||
```yaml
|
||||
publish:
|
||||
# publish stream "tplink_tapo" to Telegram
|
||||
tplink_tapo: rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx
|
||||
# publish stream "other_camera" to Telegram and YouTube
|
||||
other_camera:
|
||||
# publish stream "video_audio_transcode" to Telegram
|
||||
video_audio_transcode:
|
||||
- rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx
|
||||
- rtmps://xxx.rtmp.youtube.com/live2/xxxx-xxxx-xxxx-xxxx-xxxx
|
||||
# publish stream "audio_transcode" to Telegram and YouTube
|
||||
audio_transcode:
|
||||
- rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx
|
||||
- rtmp://xxx.rtmp.youtube.com/live2/xxxx-xxxx-xxxx-xxxx-xxxx
|
||||
|
||||
streams:
|
||||
# for TP-Link cameras it's important to use transcoding because of wrong pixel format
|
||||
tplink_tapo: ffmpeg:rtsp://user:pass@192.168.1.123/stream1#video=h264#hardware#audio=aac
|
||||
video_audio_transcode:
|
||||
- ffmpeg:rtsp://user:pass@192.168.1.123/stream1#video=h264#hardware#audio=aac
|
||||
audio_transcode:
|
||||
- ffmpeg:rtsp://user:pass@192.168.1.123/stream1#video=copy#audio=aac
|
||||
```
|
||||
|
||||
- **Telegram Desktop App** > Any public or private channel or group (where you admin) > Live stream > Start with... > Start streaming.
|
||||
@@ -1187,6 +1196,10 @@ API examples:
|
||||
- You can use `rotate` param with `90`, `180`, `270` or `-90` values
|
||||
- You can use `hardware`/`hw` param [read more](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration)
|
||||
|
||||
**PS.** This module also supports streaming to the server console (terminal) in the **animated ASCII art** format ([read more](https://github.com/AlexxIT/go2rtc/blob/master/internal/mjpeg/README.md)):
|
||||
|
||||
[](https://www.youtube.com/watch?v=sHj_3h_sX7M)
|
||||
|
||||
### Module: Log
|
||||
|
||||
You can set different log levels for different modules.
|
||||
|
BIN
assets/logo.gif
Normal file
BIN
assets/logo.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 154 KiB |
39
examples/rtsp_client/main.go
Normal file
39
examples/rtsp_client/main.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
)
|
||||
|
||||
func main() {
|
||||
client := rtsp.NewClient(os.Args[1])
|
||||
if err := client.Dial(); err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
|
||||
client.Medias = []*core.Media{
|
||||
{
|
||||
Kind: core.KindAudio,
|
||||
Direction: core.DirectionRecvonly,
|
||||
Codecs: []*core.Codec{
|
||||
{Name: core.CodecPCMU, ClockRate: 8000},
|
||||
},
|
||||
ID: "streamid=0",
|
||||
},
|
||||
}
|
||||
if err := client.Announce(); err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
if _, err := client.SetupMedia(client.Medias[0]); err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
if err := client.Record(); err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
|
||||
shell.RunUntilSignal()
|
||||
}
|
18
go.mod
18
go.mod
@@ -1,11 +1,12 @@
|
||||
module github.com/AlexxIT/go2rtc
|
||||
|
||||
go 1.21
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/asticode/go-astits v1.13.0
|
||||
github.com/expr-lang/expr v1.16.5
|
||||
github.com/expr-lang/expr v1.16.9
|
||||
github.com/gorilla/websocket v1.5.1
|
||||
github.com/mattn/go-isatty v0.0.20
|
||||
github.com/miekg/dns v1.1.59
|
||||
github.com/pion/ice/v2 v2.3.24
|
||||
github.com/pion/interceptor v0.1.29
|
||||
@@ -15,12 +16,12 @@ require (
|
||||
github.com/pion/srtp/v2 v2.0.18
|
||||
github.com/pion/stun v0.6.1
|
||||
github.com/pion/webrtc/v3 v3.2.40
|
||||
github.com/rs/zerolog v1.32.0
|
||||
github.com/rs/zerolog v1.33.0
|
||||
github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1
|
||||
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9
|
||||
golang.org/x/crypto v0.23.0
|
||||
golang.org/x/crypto v0.24.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -30,7 +31,6 @@ require (
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/kr/pretty v0.2.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/pion/datachannel v1.5.6 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.11 // indirect
|
||||
github.com/pion/logging v0.2.2 // indirect
|
||||
@@ -40,9 +40,9 @@ require (
|
||||
github.com/pion/transport/v2 v2.2.5 // indirect
|
||||
github.com/pion/turn/v2 v2.1.6 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
golang.org/x/mod v0.17.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/mod v0.18.0 // indirect
|
||||
golang.org/x/net v0.26.0 // indirect
|
||||
golang.org/x/sync v0.7.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/tools v0.20.0 // indirect
|
||||
golang.org/x/sys v0.21.0 // indirect
|
||||
golang.org/x/tools v0.22.0 // indirect
|
||||
)
|
||||
|
14
go.sum
14
go.sum
@@ -8,6 +8,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/expr-lang/expr v1.16.5 h1:m2hvtguFeVaVNTHj8L7BoAyt7O0PAIBaSVbjdHgRXMs=
|
||||
github.com/expr-lang/expr v1.16.5/go.mod h1:uCkhfG+x7fcZ5A5sXHKuQ07jGZRl6J0FCAaf2k4PtVQ=
|
||||
github.com/expr-lang/expr v1.16.9 h1:WUAzmR0JNI9JCiF0/ewwHB1gmcGw5wW7nWt8gc6PpCI=
|
||||
github.com/expr-lang/expr v1.16.9/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -85,6 +87,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0=
|
||||
github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
||||
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||
github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 h1:NVK+OqnavpyFmUiKfUMHrpvbCi2VFoWTrcpI7aDaJ2I=
|
||||
github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA=
|
||||
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f h1:1R9KdKjCNSd7F8iGTxIpoID9prlYH8nuNYKt0XvweHA=
|
||||
@@ -115,10 +119,14 @@ golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
|
||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
|
||||
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
@@ -134,6 +142,8 @@ golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
|
||||
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -160,6 +170,8 @@ golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -184,6 +196,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY=
|
||||
golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg=
|
||||
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
|
||||
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
|
@@ -4,7 +4,7 @@
|
||||
# only debian 13 (trixie) has latest ffmpeg
|
||||
# https://packages.debian.org/trixie/ffmpeg
|
||||
ARG DEBIAN_VERSION="trixie-slim"
|
||||
ARG GO_VERSION="1.21-bookworm"
|
||||
ARG GO_VERSION="1.22-bookworm"
|
||||
ARG NGROK_VERSION="3"
|
||||
|
||||
FROM debian:${DEBIAN_VERSION} AS base
|
||||
|
@@ -83,7 +83,7 @@ func initWS(origin string) {
|
||||
if o.Host == r.Host {
|
||||
return true
|
||||
}
|
||||
log.Trace().Msgf("[api.ws] origin=%s, host=%s", o.Host, r.Host)
|
||||
log.Trace().Msgf("[api] ws origin=%s, host=%s", o.Host, r.Host)
|
||||
// https://github.com/AlexxIT/go2rtc/issues/118
|
||||
if i := strings.IndexByte(o.Host, ':'); i > 0 {
|
||||
return o.Host[:i] == r.Host
|
||||
@@ -127,7 +127,7 @@ func apiWS(w http.ResponseWriter, r *http.Request) {
|
||||
break
|
||||
}
|
||||
|
||||
log.Trace().Str("type", msg.Type).Msg("[api.ws] msg")
|
||||
log.Trace().Str("type", msg.Type).Msg("[api] ws msg")
|
||||
|
||||
if handler := wsHandlers[msg.Type]; handler != nil {
|
||||
go func() {
|
||||
|
70
internal/app/README.md
Normal file
70
internal/app/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
- By default go2rtc will search config file `go2rtc.yaml` in current work directory
|
||||
- go2rtc support multiple config files:
|
||||
- `go2rtc -c config1.yaml -c config2.yaml -c config3.yaml`
|
||||
- go2rtc support inline config as multiple formats from command line:
|
||||
- **YAML**: `go2rtc -c '{log: {format: text}}'`
|
||||
- **JSON**: `go2rtc -c '{"log":{"format":"text"}}'`
|
||||
- **key=value**: `go2rtc -c log.format=text`
|
||||
- Every next config will overwrite previous (but only defined params)
|
||||
|
||||
```
|
||||
go2rtc -config "{log: {format: text}}" -config /config/go2rtc.yaml -config "{rtsp: {listen: ''}}" -config /usr/local/go2rtc/go2rtc.yaml
|
||||
```
|
||||
|
||||
or simple version
|
||||
|
||||
```
|
||||
go2rtc -c log.format=text -c /config/go2rtc.yaml -c rtsp.listen='' -c /usr/local/go2rtc/go2rtc.yaml
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
Also go2rtc support templates for using environment variables in any part of config:
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
camera1: rtsp://rtsp:${CAMERA_PASSWORD}@192.168.1.123/av_stream/ch0
|
||||
|
||||
rtsp:
|
||||
username: ${RTSP_USER:admin} # "admin" if env "RTSP_USER" not set
|
||||
password: ${RTSP_PASS:secret} # "secret" if env "RTSP_PASS" not set
|
||||
```
|
||||
|
||||
## JSON Schema
|
||||
|
||||
Editors like [GoLand](https://www.jetbrains.com/go/) and [VS Code](https://code.visualstudio.com/) supports autocomplete and syntax validation.
|
||||
|
||||
```yaml
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/AlexxIT/go2rtc/master/website/schema.json
|
||||
```
|
||||
|
||||
## Defaults
|
||||
|
||||
- Default values may change in updates
|
||||
- FFmpeg module has many presets, they are not listed here because they may also change in updates
|
||||
|
||||
```yaml
|
||||
api:
|
||||
listen: ":1984"
|
||||
|
||||
ffmpeg:
|
||||
bin: "ffmpeg"
|
||||
|
||||
log:
|
||||
format: "color"
|
||||
level: "info"
|
||||
output: "stdout"
|
||||
time: "UNIXMS"
|
||||
|
||||
rtsp:
|
||||
listen: ":8554"
|
||||
default_query: "video&audio"
|
||||
|
||||
srtp:
|
||||
listen: ":8443"
|
||||
|
||||
webrtc:
|
||||
listen: ":8555/tcp"
|
||||
ice_servers:
|
||||
- urls: [ "stun:stun.l.google.com:19302" ]
|
||||
```
|
@@ -1,144 +1,101 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
"github.com/AlexxIT/go2rtc/pkg/yaml"
|
||||
"github.com/rs/zerolog/log"
|
||||
"runtime/debug"
|
||||
)
|
||||
|
||||
var Version = "1.9.1"
|
||||
var UserAgent = "go2rtc/" + Version
|
||||
var (
|
||||
Version string
|
||||
UserAgent string
|
||||
ConfigPath string
|
||||
Info = make(map[string]any)
|
||||
)
|
||||
|
||||
var ConfigPath string
|
||||
var Info = map[string]any{
|
||||
"version": Version,
|
||||
}
|
||||
const usage = `Usage of go2rtc:
|
||||
|
||||
-c, --config Path to config file or config string as YAML or JSON, support multiple
|
||||
-d, --daemon Run in background
|
||||
-v, --version Print version and exit
|
||||
`
|
||||
|
||||
func Init() {
|
||||
var confs Config
|
||||
var config flagConfig
|
||||
var daemon bool
|
||||
var version bool
|
||||
|
||||
flag.Var(&confs, "config", "go2rtc config (path to file or raw text), support multiple")
|
||||
if runtime.GOOS != "windows" {
|
||||
flag.BoolVar(&daemon, "daemon", false, "Run program in background")
|
||||
}
|
||||
flag.BoolVar(&version, "version", false, "Print the version of the application and exit")
|
||||
flag.Var(&config, "config", "")
|
||||
flag.Var(&config, "c", "")
|
||||
flag.BoolVar(&daemon, "daemon", false, "")
|
||||
flag.BoolVar(&daemon, "d", false, "")
|
||||
flag.BoolVar(&version, "version", false, "")
|
||||
flag.BoolVar(&version, "v", false, "")
|
||||
|
||||
flag.Usage = func() { fmt.Print(usage) }
|
||||
flag.Parse()
|
||||
|
||||
revision, vcsTime := readRevisionTime()
|
||||
|
||||
if version {
|
||||
fmt.Println("Current version:", Version)
|
||||
fmt.Printf("go2rtc version %s (%s) %s/%s\n", Version, revision, runtime.GOOS, runtime.GOARCH)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if daemon {
|
||||
args := os.Args[1:]
|
||||
for i, arg := range args {
|
||||
if arg == "-daemon" {
|
||||
args[i] = ""
|
||||
}
|
||||
if daemon && os.Getppid() != 1 {
|
||||
if runtime.GOOS == "windows" {
|
||||
fmt.Println("Daemon mode is not supported on Windows")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Re-run the program in background and exit
|
||||
cmd := exec.Command(os.Args[0], args...)
|
||||
cmd := exec.Command(os.Args[0], os.Args[1:]...)
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Fatal().Err(err).Send()
|
||||
fmt.Println("Failed to start daemon:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Running in daemon mode with PID:", cmd.Process.Pid)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if confs == nil {
|
||||
confs = []string{"go2rtc.yaml"}
|
||||
}
|
||||
UserAgent = "go2rtc/" + Version
|
||||
|
||||
for _, conf := range confs {
|
||||
if conf[0] != '{' {
|
||||
// config as file
|
||||
if ConfigPath == "" {
|
||||
ConfigPath = conf
|
||||
}
|
||||
Info["version"] = Version
|
||||
Info["revision"] = revision
|
||||
|
||||
data, _ := os.ReadFile(conf)
|
||||
if data == nil {
|
||||
continue
|
||||
}
|
||||
initConfig(config)
|
||||
initLogger()
|
||||
|
||||
data = []byte(shell.ReplaceEnvVars(string(data)))
|
||||
configs = append(configs, data)
|
||||
} else {
|
||||
// config as raw YAML
|
||||
configs = append(configs, []byte(conf))
|
||||
}
|
||||
}
|
||||
platform := fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)
|
||||
Logger.Info().Str("version", Version).Str("platform", platform).Str("revision", revision).Msg("go2rtc")
|
||||
Logger.Debug().Str("version", runtime.Version()).Str("vcs.time", vcsTime).Msg("build")
|
||||
|
||||
if ConfigPath != "" {
|
||||
if !filepath.IsAbs(ConfigPath) {
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
ConfigPath = filepath.Join(cwd, ConfigPath)
|
||||
Logger.Info().Str("path", ConfigPath).Msg("config")
|
||||
}
|
||||
}
|
||||
|
||||
func readRevisionTime() (revision, vcsTime string) {
|
||||
if info, ok := debug.ReadBuildInfo(); ok {
|
||||
for _, setting := range info.Settings {
|
||||
switch setting.Key {
|
||||
case "vcs.revision":
|
||||
if len(setting.Value) > 7 {
|
||||
revision = setting.Value[:7]
|
||||
} else {
|
||||
revision = setting.Value
|
||||
}
|
||||
case "vcs.time":
|
||||
vcsTime = setting.Value
|
||||
case "vcs.modified":
|
||||
if setting.Value == "true" {
|
||||
revision = "mod." + revision
|
||||
}
|
||||
}
|
||||
}
|
||||
Info["config_path"] = ConfigPath
|
||||
}
|
||||
|
||||
var cfg struct {
|
||||
Mod map[string]string `yaml:"log"`
|
||||
}
|
||||
|
||||
LoadConfig(&cfg)
|
||||
|
||||
log.Logger = NewLogger(cfg.Mod["format"], cfg.Mod["level"])
|
||||
|
||||
modules = cfg.Mod
|
||||
|
||||
log.Info().Msgf("go2rtc version %s %s/%s", Version, runtime.GOOS, runtime.GOARCH)
|
||||
|
||||
migrateStore()
|
||||
return
|
||||
}
|
||||
|
||||
func LoadConfig(v any) {
|
||||
for _, data := range configs {
|
||||
if err := yaml.Unmarshal(data, v); err != nil {
|
||||
log.Warn().Err(err).Msg("[app] read config")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func PatchConfig(key string, value any, path ...string) error {
|
||||
if ConfigPath == "" {
|
||||
return errors.New("config file disabled")
|
||||
}
|
||||
|
||||
// empty config is OK
|
||||
b, _ := os.ReadFile(ConfigPath)
|
||||
|
||||
b, err := yaml.Patch(b, key, value, path...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(ConfigPath, b, 0644)
|
||||
}
|
||||
|
||||
// internal
|
||||
|
||||
type Config []string
|
||||
|
||||
func (c *Config) String() string {
|
||||
return strings.Join(*c, " ")
|
||||
}
|
||||
|
||||
func (c *Config) Set(value string) error {
|
||||
*c = append(*c, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
var configs [][]byte
|
||||
|
109
internal/app/config.go
Normal file
109
internal/app/config.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
"github.com/AlexxIT/go2rtc/pkg/yaml"
|
||||
)
|
||||
|
||||
func LoadConfig(v any) {
|
||||
for _, data := range configs {
|
||||
if err := yaml.Unmarshal(data, v); err != nil {
|
||||
Logger.Warn().Err(err).Send()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func PatchConfig(key string, value any, path ...string) error {
|
||||
if ConfigPath == "" {
|
||||
return errors.New("config file disabled")
|
||||
}
|
||||
|
||||
// empty config is OK
|
||||
b, _ := os.ReadFile(ConfigPath)
|
||||
|
||||
b, err := yaml.Patch(b, key, value, path...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(ConfigPath, b, 0644)
|
||||
}
|
||||
|
||||
type flagConfig []string
|
||||
|
||||
func (c *flagConfig) String() string {
|
||||
return strings.Join(*c, " ")
|
||||
}
|
||||
|
||||
func (c *flagConfig) Set(value string) error {
|
||||
*c = append(*c, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
var configs [][]byte
|
||||
|
||||
func initConfig(confs flagConfig) {
|
||||
if confs == nil {
|
||||
confs = []string{"go2rtc.yaml"}
|
||||
}
|
||||
|
||||
for _, conf := range confs {
|
||||
if len(conf) == 0 {
|
||||
continue
|
||||
}
|
||||
if conf[0] == '{' {
|
||||
// config as raw YAML or JSON
|
||||
configs = append(configs, []byte(conf))
|
||||
} else if data := parseConfString(conf); data != nil {
|
||||
configs = append(configs, data)
|
||||
} else {
|
||||
// config as file
|
||||
if ConfigPath == "" {
|
||||
ConfigPath = conf
|
||||
}
|
||||
|
||||
if data, _ = os.ReadFile(conf); data == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
data = []byte(shell.ReplaceEnvVars(string(data)))
|
||||
configs = append(configs, data)
|
||||
}
|
||||
}
|
||||
|
||||
if ConfigPath != "" {
|
||||
if !filepath.IsAbs(ConfigPath) {
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
ConfigPath = filepath.Join(cwd, ConfigPath)
|
||||
}
|
||||
}
|
||||
Info["config_path"] = ConfigPath
|
||||
}
|
||||
}
|
||||
|
||||
func parseConfString(s string) []byte {
|
||||
i := strings.IndexByte(s, '=')
|
||||
if i < 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
items := strings.Split(s[:i], ".")
|
||||
if len(items) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// `log.level=trace` => `{log: {level: trace}}`
|
||||
var pre string
|
||||
var suf = s[i+1:]
|
||||
for _, item := range items {
|
||||
pre += "{" + item + ": "
|
||||
suf += "}"
|
||||
}
|
||||
|
||||
return []byte(pre + suf)
|
||||
}
|
@@ -4,49 +4,100 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var MemoryLog *circularBuffer
|
||||
|
||||
func NewLogger(format string, level string) zerolog.Logger {
|
||||
var writer io.Writer = os.Stdout
|
||||
|
||||
if format != "json" {
|
||||
writer = zerolog.ConsoleWriter{
|
||||
Out: writer, TimeFormat: "15:04:05.000", NoColor: format == "text",
|
||||
}
|
||||
}
|
||||
|
||||
MemoryLog = newBuffer(16)
|
||||
|
||||
writer = zerolog.MultiLevelWriter(writer, MemoryLog)
|
||||
|
||||
zerolog.TimeFieldFormat = zerolog.TimeFormatUnixMs
|
||||
|
||||
lvl, err := zerolog.ParseLevel(level)
|
||||
if err != nil || lvl == zerolog.NoLevel {
|
||||
lvl = zerolog.InfoLevel
|
||||
}
|
||||
|
||||
return zerolog.New(writer).With().Timestamp().Logger().Level(lvl)
|
||||
}
|
||||
var MemoryLog = newBuffer(16)
|
||||
|
||||
func GetLogger(module string) zerolog.Logger {
|
||||
if s, ok := modules[module]; ok {
|
||||
lvl, err := zerolog.ParseLevel(s)
|
||||
if err == nil {
|
||||
return log.Level(lvl)
|
||||
return Logger.Level(lvl)
|
||||
}
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
Logger.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
|
||||
return log.Logger
|
||||
return Logger
|
||||
}
|
||||
|
||||
// initLogger support:
|
||||
// - output: empty (only to memory), stderr, stdout
|
||||
// - format: empty (autodetect color support), color, json, text
|
||||
// - time: empty (disable timestamp), UNIXMS, UNIXMICRO, UNIXNANO
|
||||
// - level: disabled, trace, debug, info, warn, error...
|
||||
func initLogger() {
|
||||
var cfg struct {
|
||||
Mod map[string]string `yaml:"log"`
|
||||
}
|
||||
|
||||
cfg.Mod = modules // defaults
|
||||
|
||||
LoadConfig(&cfg)
|
||||
|
||||
var writer io.Writer
|
||||
|
||||
switch modules["output"] {
|
||||
case "stderr":
|
||||
writer = os.Stderr
|
||||
case "stdout":
|
||||
writer = os.Stdout
|
||||
}
|
||||
|
||||
timeFormat := modules["time"]
|
||||
|
||||
if writer != nil {
|
||||
if format := modules["format"]; format != "json" {
|
||||
console := &zerolog.ConsoleWriter{Out: writer}
|
||||
|
||||
switch format {
|
||||
case "text":
|
||||
console.NoColor = true
|
||||
case "color":
|
||||
console.NoColor = false // useless, but anyway
|
||||
default:
|
||||
// autodetection if output support color
|
||||
// go-isatty - dependency for go-colorable - dependency for ConsoleWriter
|
||||
console.NoColor = !isatty.IsTerminal(writer.(*os.File).Fd())
|
||||
}
|
||||
|
||||
if timeFormat != "" {
|
||||
console.TimeFormat = "15:04:05.000"
|
||||
} else {
|
||||
console.PartsOrder = []string{
|
||||
zerolog.LevelFieldName,
|
||||
zerolog.CallerFieldName,
|
||||
zerolog.MessageFieldName,
|
||||
}
|
||||
}
|
||||
|
||||
writer = console
|
||||
}
|
||||
|
||||
writer = zerolog.MultiLevelWriter(writer, MemoryLog)
|
||||
} else {
|
||||
writer = MemoryLog
|
||||
}
|
||||
|
||||
lvl, _ := zerolog.ParseLevel(modules["level"])
|
||||
Logger = zerolog.New(writer).Level(lvl)
|
||||
|
||||
if timeFormat != "" {
|
||||
zerolog.TimeFieldFormat = timeFormat
|
||||
Logger = Logger.With().Timestamp().Logger()
|
||||
}
|
||||
}
|
||||
|
||||
var Logger zerolog.Logger
|
||||
|
||||
// modules log levels
|
||||
var modules map[string]string
|
||||
var modules = map[string]string{
|
||||
"format": "", // useless, but anyway
|
||||
"level": "info",
|
||||
"output": "stdout", // TODO: change to stderr someday
|
||||
"time": zerolog.TimeFormatUnixMs,
|
||||
}
|
||||
|
||||
const chunkSize = 1 << 16
|
||||
|
||||
|
@@ -1,35 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func migrateStore() {
|
||||
const name = "go2rtc.json"
|
||||
|
||||
data, _ := os.ReadFile(name)
|
||||
if data == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var store struct {
|
||||
Streams map[string]string `json:"streams"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &store); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
for id, url := range store.Streams {
|
||||
if err := PatchConfig(id, url, "streams"); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_ = os.Remove(name)
|
||||
}
|
@@ -7,13 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("bubble", handle)
|
||||
}
|
||||
|
||||
func handle(url string) (core.Producer, error) {
|
||||
conn := bubble.NewClient(url)
|
||||
if err := conn.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
streams.HandleFunc("bubble", func(source string) (core.Producer, error) {
|
||||
return bubble.Dial(source)
|
||||
})
|
||||
}
|
||||
|
@@ -2,16 +2,8 @@ package debug
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
api.HandleFunc("api/stack", stackHandler)
|
||||
|
||||
streams.HandleFunc("null", nullHandler)
|
||||
}
|
||||
|
||||
func nullHandler(string) (core.Producer, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
@@ -10,26 +10,16 @@ import (
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/dvrip"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("dvrip", handle)
|
||||
streams.HandleFunc("dvrip", dvrip.Dial)
|
||||
|
||||
// DVRIP client autodiscovery
|
||||
api.HandleFunc("api/dvrip", apiDvrip)
|
||||
}
|
||||
|
||||
func handle(url string) (core.Producer, error) {
|
||||
client, err := dvrip.Dial(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
const Port = 34569 // UDP port number for dvrip discovery
|
||||
|
||||
func apiDvrip(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -92,10 +82,7 @@ func sendBroadcasts(conn *net.UDPConn) {
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
if _, err = conn.WriteToUDP(data, addr); err != nil {
|
||||
log.Err(err).Caller().Send()
|
||||
}
|
||||
_, _ = conn.WriteToUDP(data, addr)
|
||||
}
|
||||
}
|
||||
|
||||
|
39
internal/exec/closer.go
Normal file
39
internal/exec/closer.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
// closer support custom killsignal with custom killtimeout
|
||||
type closer struct {
|
||||
cmd *exec.Cmd
|
||||
query url.Values
|
||||
}
|
||||
|
||||
func (c *closer) Close() (err error) {
|
||||
sig := os.Kill
|
||||
if s := c.query.Get("killsignal"); s != "" {
|
||||
sig = syscall.Signal(core.Atoi(s))
|
||||
}
|
||||
|
||||
log.Trace().Msgf("[exec] kill with signal=%d", sig)
|
||||
err = c.cmd.Process.Signal(sig)
|
||||
|
||||
if s := c.query.Get("killtimeout"); s != "" {
|
||||
timeout := time.Duration(core.Atoi(s)) * time.Second
|
||||
timer := time.AfterFunc(timeout, func() {
|
||||
log.Trace().Msgf("[exec] kill after timeout=%s", s)
|
||||
_ = c.cmd.Process.Kill()
|
||||
})
|
||||
defer timer.Stop() // stop timer if Wait ends before timeout
|
||||
}
|
||||
|
||||
return errors.Join(err, c.cmd.Wait())
|
||||
}
|
@@ -1,6 +1,7 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -49,8 +51,10 @@ func Init() {
|
||||
}
|
||||
|
||||
func execHandle(rawURL string) (core.Producer, error) {
|
||||
rawURL, rawQuery, _ := strings.Cut(rawURL, "#")
|
||||
query := streams.ParseQuery(rawQuery)
|
||||
|
||||
var path string
|
||||
var query url.Values
|
||||
|
||||
// RTSP flow should have `{output}` inside URL
|
||||
// pipe flow may have `#{params}` inside URL
|
||||
@@ -62,60 +66,73 @@ func execHandle(rawURL string) (core.Producer, error) {
|
||||
sum := md5.Sum([]byte(rawURL))
|
||||
path = "/" + hex.EncodeToString(sum[:])
|
||||
rawURL = rawURL[:i] + "rtsp://127.0.0.1:" + rtsp.Port + path + rawURL[i+8:]
|
||||
} else if i = strings.IndexByte(rawURL, '#'); i > 0 {
|
||||
query = streams.ParseQuery(rawURL[i+1:])
|
||||
rawURL = rawURL[:i]
|
||||
}
|
||||
|
||||
args := shell.QuoteSplit(rawURL[5:]) // remove `exec:`
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
if log.Debug().Enabled() {
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stderr = &logWriter{
|
||||
buf: make([]byte, 512),
|
||||
debug: log.Debug().Enabled(),
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
return handlePipe(rawURL, cmd, query)
|
||||
}
|
||||
|
||||
return handleRTSP(rawURL, cmd, path)
|
||||
}
|
||||
|
||||
func handlePipe(_ string, cmd *exec.Cmd, query url.Values) (core.Producer, error) {
|
||||
if query.Get("backchannel") == "1" {
|
||||
return stdin.NewClient(cmd)
|
||||
}
|
||||
|
||||
r, err := PipeCloser(cmd, query)
|
||||
cl := &closer{cmd: cmd, query: query}
|
||||
|
||||
if path == "" {
|
||||
return handlePipe(rawURL, cmd, cl)
|
||||
}
|
||||
|
||||
return handleRTSP(rawURL, cmd, cl, path)
|
||||
}
|
||||
|
||||
func handlePipe(source string, cmd *exec.Cmd, cl io.Closer) (core.Producer, error) {
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rc := struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}{
|
||||
// add buffer for pipe reader to reduce syscall
|
||||
bufio.NewReaderSize(stdout, core.BufferSize),
|
||||
cl,
|
||||
}
|
||||
|
||||
log.Debug().Strs("args", cmd.Args).Msg("[exec] run pipe")
|
||||
|
||||
ts := time.Now()
|
||||
|
||||
if err = cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prod, err := magic.Open(r)
|
||||
prod, err := magic.Open(rc)
|
||||
if err != nil {
|
||||
_ = r.Close()
|
||||
_ = rc.Close()
|
||||
return nil, fmt.Errorf("exec/pipe: %w\n%s", err, cmd.Stderr)
|
||||
}
|
||||
|
||||
return prod, err
|
||||
if info, ok := prod.(core.Info); ok {
|
||||
info.SetProtocol("pipe")
|
||||
setRemoteInfo(info, source, cmd.Args)
|
||||
}
|
||||
|
||||
log.Debug().Stringer("launch", time.Since(ts)).Msg("[exec] run pipe")
|
||||
|
||||
return prod, nil
|
||||
}
|
||||
|
||||
func handleRTSP(url string, cmd *exec.Cmd, path string) (core.Producer, error) {
|
||||
stderr := limitBuffer{buf: make([]byte, 512)}
|
||||
|
||||
if cmd.Stderr != nil {
|
||||
cmd.Stderr = io.MultiWriter(cmd.Stderr, &stderr)
|
||||
} else {
|
||||
cmd.Stderr = &stderr
|
||||
}
|
||||
|
||||
func handleRTSP(source string, cmd *exec.Cmd, cl io.Closer, path string) (core.Producer, error) {
|
||||
if log.Trace().Enabled() {
|
||||
cmd.Stdout = os.Stdout
|
||||
}
|
||||
|
||||
waiter := make(chan core.Producer)
|
||||
waiter := make(chan *pkg.Conn, 1)
|
||||
|
||||
waitersMu.Lock()
|
||||
waiters[path] = waiter
|
||||
@@ -127,12 +144,12 @@ func handleRTSP(url string, cmd *exec.Cmd, path string) (core.Producer, error) {
|
||||
waitersMu.Unlock()
|
||||
}()
|
||||
|
||||
log.Debug().Str("url", url).Str("cmd", fmt.Sprintf("%s", strings.Join(cmd.Args, " "))).Msg("[exec] run")
|
||||
log.Debug().Strs("args", cmd.Args).Msg("[exec] run rtsp")
|
||||
|
||||
ts := time.Now()
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Error().Err(err).Str("url", url).Msg("[exec]")
|
||||
log.Error().Err(err).Str("source", source).Msg("[exec]")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -142,15 +159,17 @@ func handleRTSP(url string, cmd *exec.Cmd, path string) (core.Producer, error) {
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-time.After(time.Second * 60):
|
||||
_ = cmd.Process.Kill()
|
||||
log.Error().Str("url", url).Msg("[exec] timeout")
|
||||
return nil, errors.New("timeout")
|
||||
case <-time.After(time.Minute):
|
||||
log.Error().Str("source", source).Msg("[exec] timeout")
|
||||
_ = cl.Close()
|
||||
return nil, errors.New("exec: timeout")
|
||||
case <-done:
|
||||
// limit message size
|
||||
return nil, errors.New("exec: " + stderr.String())
|
||||
return nil, fmt.Errorf("exec/rtsp\n%s", cmd.Stderr)
|
||||
case prod := <-waiter:
|
||||
log.Debug().Stringer("launch", time.Since(ts)).Msg("[exec] run")
|
||||
log.Debug().Stringer("launch", time.Since(ts)).Msg("[exec] run rtsp")
|
||||
setRemoteInfo(prod, source, cmd.Args)
|
||||
prod.OnClose = cl.Close
|
||||
return prod, nil
|
||||
}
|
||||
}
|
||||
@@ -159,25 +178,63 @@ func handleRTSP(url string, cmd *exec.Cmd, path string) (core.Producer, error) {
|
||||
|
||||
var (
|
||||
log zerolog.Logger
|
||||
waiters = map[string]chan core.Producer{}
|
||||
waiters = make(map[string]chan *pkg.Conn)
|
||||
waitersMu sync.Mutex
|
||||
)
|
||||
|
||||
type limitBuffer struct {
|
||||
buf []byte
|
||||
n int
|
||||
type logWriter struct {
|
||||
buf []byte
|
||||
debug bool
|
||||
n int
|
||||
}
|
||||
|
||||
func (l *limitBuffer) String() string {
|
||||
func (l *logWriter) String() string {
|
||||
if l.n == len(l.buf) {
|
||||
return string(l.buf) + "..."
|
||||
}
|
||||
return string(l.buf[:l.n])
|
||||
}
|
||||
|
||||
func (l *limitBuffer) Write(p []byte) (int, error) {
|
||||
func (l *logWriter) Write(p []byte) (n int, err error) {
|
||||
if l.n < cap(l.buf) {
|
||||
l.n += copy(l.buf[l.n:], p)
|
||||
}
|
||||
return len(p), nil
|
||||
n = len(p)
|
||||
if l.debug {
|
||||
if p = trimSpace(p); p != nil {
|
||||
log.Debug().Msgf("[exec] %s", p)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func trimSpace(b []byte) []byte {
|
||||
start := 0
|
||||
stop := len(b)
|
||||
for ; start < stop; start++ {
|
||||
if b[start] >= ' ' {
|
||||
break // trim all ASCII before 0x20
|
||||
}
|
||||
}
|
||||
for ; ; stop-- {
|
||||
if stop == start {
|
||||
return nil // skip empty output
|
||||
}
|
||||
if b[stop-1] > ' ' {
|
||||
break // trim all ASCII before 0x21
|
||||
}
|
||||
}
|
||||
return b[start:stop]
|
||||
}
|
||||
|
||||
func setRemoteInfo(info core.Info, source string, args []string) {
|
||||
info.SetSource(source)
|
||||
|
||||
if i := slices.Index(args, "-i"); i > 0 && i < len(args)-1 {
|
||||
rawURL := args[i+1]
|
||||
if u, err := url.Parse(rawURL); err == nil && u.Host != "" {
|
||||
info.SetRemoteAddr(u.Host)
|
||||
info.SetURL(rawURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,56 +0,0 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"io"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
// PipeCloser - return StdoutPipe that Kill cmd on Close call
|
||||
func PipeCloser(cmd *exec.Cmd, query url.Values) (io.ReadCloser, error) {
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// add buffer for pipe reader to reduce syscall
|
||||
return &pipeCloser{bufio.NewReaderSize(stdout, core.BufferSize), stdout, cmd, query}, nil
|
||||
}
|
||||
|
||||
type pipeCloser struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
cmd *exec.Cmd
|
||||
query url.Values
|
||||
}
|
||||
|
||||
func (p *pipeCloser) Close() error {
|
||||
return errors.Join(p.Closer.Close(), p.Kill(), p.Wait())
|
||||
}
|
||||
|
||||
func (p *pipeCloser) Kill() error {
|
||||
if s := p.query.Get("killsignal"); s != "" {
|
||||
log.Trace().Msgf("[exec] kill with custom sig=%s", s)
|
||||
sig := syscall.Signal(core.Atoi(s))
|
||||
return p.cmd.Process.Signal(sig)
|
||||
}
|
||||
return p.cmd.Process.Kill()
|
||||
}
|
||||
|
||||
func (p *pipeCloser) Wait() error {
|
||||
if s := p.query.Get("killtimeout"); s != "" {
|
||||
timeout := time.Duration(core.Atoi(s)) * time.Second
|
||||
timer := time.AfterFunc(timeout, func() {
|
||||
log.Trace().Msgf("[exec] kill after timeout=%s", s)
|
||||
_ = p.cmd.Process.Kill()
|
||||
})
|
||||
defer timer.Stop() // stop timer if Wait ends before timeout
|
||||
}
|
||||
return p.cmd.Wait()
|
||||
}
|
@@ -45,6 +45,13 @@
|
||||
[video4linux2,v4l2 @ 0x7f7de7c58bc0] Compressed: mjpeg : Motion-JPEG : 640x480 160x120 176x144 320x176 320x240 352x288 432x240 544x288 640x360 752x416 800x448 800x600 864x480 960x544 960x720 1024x576 1184x656 1280x720 1280x960
|
||||
```
|
||||
|
||||
## TTS
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
tts: ffmpeg:#input=-readrate 1 -readrate_initial_burst 0.001 -f lavfi -i "flite=text='1 2 3 4 5 6 7 8 9 0'"#audio=pcma
|
||||
```
|
||||
|
||||
## Useful links
|
||||
|
||||
- https://superuser.com/questions/564402/explanation-of-x264-tune
|
||||
|
51
internal/ffmpeg/api.go
Normal file
51
internal/ffmpeg/api.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
)
|
||||
|
||||
func apiFFmpeg(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
dst := query.Get("dst")
|
||||
stream := streams.Get(dst)
|
||||
if stream == nil {
|
||||
http.Error(w, "", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var src string
|
||||
if s := query.Get("file"); s != "" {
|
||||
if streams.Validate(s) == nil {
|
||||
src = "ffmpeg:" + s + "#audio=auto#input=file"
|
||||
}
|
||||
} else if s = query.Get("live"); s != "" {
|
||||
if streams.Validate(s) == nil {
|
||||
src = "ffmpeg:" + s + "#audio=auto"
|
||||
}
|
||||
} else if s = query.Get("text"); s != "" {
|
||||
if strings.IndexAny(s, `'"&%$`) < 0 {
|
||||
src = "ffmpeg:tts?text=" + s
|
||||
if s = query.Get("voice"); s != "" {
|
||||
src += "&voice=" + s
|
||||
}
|
||||
src += "#audio=auto"
|
||||
}
|
||||
}
|
||||
|
||||
if src == "" {
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := stream.Play(src); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
@@ -1,3 +1,5 @@
|
||||
//go:build freebsd || netbsd || openbsd || dragonfly
|
||||
|
||||
package device
|
||||
|
||||
import (
|
@@ -1,3 +1,5 @@
|
||||
//go:build darwin || ios
|
||||
|
||||
package device
|
||||
|
||||
import (
|
||||
|
@@ -1,3 +1,5 @@
|
||||
//go:build unix && !darwin && !freebsd && !netbsd && !openbsd && !dragonfly
|
||||
|
||||
package device
|
||||
|
||||
import (
|
@@ -1,3 +1,5 @@
|
||||
//go:build windows
|
||||
|
||||
package device
|
||||
|
||||
import (
|
||||
|
@@ -1,11 +1,9 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
@@ -17,24 +15,15 @@ func Init(bin string) {
|
||||
api.HandleFunc("api/ffmpeg/devices", apiDevices)
|
||||
}
|
||||
|
||||
func GetInput(src string) (string, error) {
|
||||
i := strings.IndexByte(src, '?')
|
||||
if i < 0 {
|
||||
return "", errors.New("empty query: " + src)
|
||||
}
|
||||
|
||||
query, err := url.ParseQuery(src[i+1:])
|
||||
func GetInput(src string) string {
|
||||
query, err := url.ParseQuery(src)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return ""
|
||||
}
|
||||
|
||||
runonce.Do(initDevices)
|
||||
|
||||
if input := queryToInput(query); input != "" {
|
||||
return input, nil
|
||||
}
|
||||
|
||||
return "", errors.New("wrong query: " + src)
|
||||
return queryToInput(query)
|
||||
}
|
||||
|
||||
var Bin string
|
||||
|
@@ -2,35 +2,58 @@ package ffmpeg
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/ffmpeg/device"
|
||||
"github.com/AlexxIT/go2rtc/internal/ffmpeg/hardware"
|
||||
"github.com/AlexxIT/go2rtc/internal/ffmpeg/virtual"
|
||||
"github.com/AlexxIT/go2rtc/internal/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/ffmpeg"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var cfg struct {
|
||||
Mod map[string]string `yaml:"ffmpeg"`
|
||||
Log struct {
|
||||
Level string `yaml:"ffmpeg"`
|
||||
} `yaml:"log"`
|
||||
}
|
||||
|
||||
cfg.Mod = defaults // will be overriden from yaml
|
||||
cfg.Log.Level = "error"
|
||||
|
||||
app.LoadConfig(&cfg)
|
||||
|
||||
if app.GetLogger("exec").GetLevel() >= 0 {
|
||||
defaults["global"] += " -v error"
|
||||
log = app.GetLogger("ffmpeg")
|
||||
|
||||
// zerolog levels: trace debug info warn error fatal panic disabled
|
||||
// FFmpeg levels: trace debug verbose info warning error fatal panic quiet
|
||||
if cfg.Log.Level == "warn" {
|
||||
cfg.Log.Level = "warning"
|
||||
}
|
||||
defaults["global"] += " -v " + cfg.Log.Level
|
||||
|
||||
streams.RedirectFunc("ffmpeg", func(url string) (string, error) {
|
||||
if _, err := Version(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
args := parseArgs(url[7:])
|
||||
if slices.Contains(args.Codecs, "auto") {
|
||||
return "", nil // force call streams.HandleFunc("ffmpeg")
|
||||
}
|
||||
return "exec:" + args.String(), nil
|
||||
})
|
||||
|
||||
streams.HandleFunc("ffmpeg", NewProducer)
|
||||
|
||||
api.HandleFunc("api/ffmpeg", apiFFmpeg)
|
||||
|
||||
device.Init(defaults["bin"])
|
||||
hardware.Init(defaults["bin"])
|
||||
}
|
||||
@@ -49,16 +72,25 @@ var defaults = map[string]string{
|
||||
// output
|
||||
"output": "-user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}",
|
||||
"output/mjpeg": "-f mjpeg -",
|
||||
"output/raw": "-f yuv4mpegpipe -",
|
||||
"output/aac": "-f adts -",
|
||||
"output/wav": "-f wav -",
|
||||
|
||||
// `-preset superfast` - we can't use ultrafast because it doesn't support `-profile main -level 4.1`
|
||||
// `-tune zerolatency` - for minimal latency
|
||||
// `-profile high -level 4.1` - most used streaming profile
|
||||
// `-pix_fmt:v yuv420p` - important for Telegram
|
||||
"h264": "-c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p",
|
||||
"h265": "-c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency",
|
||||
"h265": "-c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p",
|
||||
"mjpeg": "-c:v mjpeg",
|
||||
//"mjpeg": "-c:v mjpeg -force_duplicated_matrix:v 1 -huffman:v 0 -pix_fmt:v yuvj420p",
|
||||
|
||||
"raw": "-c:v rawvideo",
|
||||
"raw/gray8": "-c:v rawvideo -pix_fmt:v gray8",
|
||||
"raw/yuv420p": "-c:v rawvideo -pix_fmt:v yuv420p",
|
||||
"raw/yuv422p": "-c:v rawvideo -pix_fmt:v yuv422p",
|
||||
"raw/yuv444p": "-c:v rawvideo -pix_fmt:v yuv444p",
|
||||
|
||||
// https://ffmpeg.org/ffmpeg-codecs.html#libopus-1
|
||||
// https://github.com/pion/webrtc/issues/1514
|
||||
// https://ffmpeg.org/ffmpeg-resampler.html
|
||||
@@ -116,6 +148,8 @@ var defaults = map[string]string{
|
||||
"h265/videotoolbox": "-c:v hevc_videotoolbox -g 50 -bf 0 -profile:v main -level:v 5.1",
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
|
||||
// configTemplate - return template from config (defaults) if exist or return raw template
|
||||
func configTemplate(template string) string {
|
||||
if s := defaults[template]; s != "" {
|
||||
@@ -140,9 +174,10 @@ func inputTemplate(name, s string, query url.Values) string {
|
||||
func parseArgs(s string) *ffmpeg.Args {
|
||||
// init FFmpeg arguments
|
||||
args := &ffmpeg.Args{
|
||||
Bin: defaults["bin"],
|
||||
Global: defaults["global"],
|
||||
Output: defaults["output"],
|
||||
Bin: defaults["bin"],
|
||||
Global: defaults["global"],
|
||||
Output: defaults["output"],
|
||||
Version: verAV,
|
||||
}
|
||||
|
||||
var query url.Values
|
||||
@@ -188,16 +223,14 @@ func parseArgs(s string) *ffmpeg.Args {
|
||||
s += "?video&audio"
|
||||
}
|
||||
args.Input = inputTemplate("rtsp", s, query)
|
||||
} else if strings.HasPrefix(s, "device?") {
|
||||
var err error
|
||||
args.Input, err = device.GetInput(s)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
} else if strings.HasPrefix(s, "virtual?") {
|
||||
var err error
|
||||
if args.Input, err = virtual.GetInput(s[8:]); err != nil {
|
||||
return nil
|
||||
} else if i = strings.Index(s, "?"); i > 0 {
|
||||
switch s[:i] {
|
||||
case "device":
|
||||
args.Input = device.GetInput(s[i+1:])
|
||||
case "virtual":
|
||||
args.Input = virtual.GetInput(s[i+1:])
|
||||
case "tts":
|
||||
args.Input = virtual.GetInputTTS(s[i+1:])
|
||||
}
|
||||
} else {
|
||||
args.Input = inputTemplate("file", s, query)
|
||||
@@ -280,6 +313,12 @@ func parseArgs(s string) *ffmpeg.Args {
|
||||
}
|
||||
}
|
||||
|
||||
if query["bitrate"] != nil {
|
||||
// https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
|
||||
b := query["bitrate"][0]
|
||||
args.AddCodec("-b:v " + b + " -maxrate " + b + " -bufsize " + b)
|
||||
}
|
||||
|
||||
// 4. Process audio codecs
|
||||
if args.Audio > 0 {
|
||||
for _, audio := range query["audio"] {
|
||||
@@ -309,11 +348,27 @@ func parseArgs(s string) *ffmpeg.Args {
|
||||
args.AddCodec("-an")
|
||||
}
|
||||
|
||||
// transcoding to only mjpeg
|
||||
if (args.Video == 1 && args.Audio == 0 && query.Get("video") == "mjpeg") ||
|
||||
// no transcoding from mjpeg input
|
||||
(args.Video == 0 && args.Audio == 0 && strings.Contains(args.Input, " mjpeg ")) {
|
||||
args.Output = defaults["output/mjpeg"]
|
||||
// change otput from RTSP to some other pipe format
|
||||
switch {
|
||||
case args.Video == 0 && args.Audio == 0:
|
||||
// no transcoding from mjpeg input (ffmpeg device with support output as raw MJPEG)
|
||||
if strings.Contains(args.Input, " mjpeg ") {
|
||||
args.Output = defaults["output/mjpeg"]
|
||||
}
|
||||
case args.Video == 1 && args.Audio == 0:
|
||||
switch core.Before(query.Get("video"), "/") {
|
||||
case "mjpeg":
|
||||
args.Output = defaults["output/mjpeg"]
|
||||
case "raw":
|
||||
args.Output = defaults["output/raw"]
|
||||
}
|
||||
case args.Video == 0 && args.Audio == 1:
|
||||
switch core.Before(query.Get("audio"), "/") {
|
||||
case "aac":
|
||||
args.Output = defaults["output/aac"]
|
||||
case "pcma", "pcmu", "pcml":
|
||||
args.Output = defaults["output/wav"]
|
||||
}
|
||||
}
|
||||
|
||||
return args
|
||||
|
@@ -3,6 +3,7 @@ package ffmpeg
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/ffmpeg"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -292,3 +293,23 @@ func TestDrawText(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersion(t *testing.T) {
|
||||
verAV = ffmpeg.Version61
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
expect string
|
||||
}{
|
||||
{
|
||||
source: "/media/bbb.mp4",
|
||||
expect: `ffmpeg -hide_banner -readrate_initial_burst 0.001 -re -i /media/bbb.mp4 -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
args := parseArgs(test.source)
|
||||
require.Equal(t, test.expect, args.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@@ -7,8 +7,6 @@ import (
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/pkg/ffmpeg"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -152,7 +150,6 @@ var cache = map[string]string{}
|
||||
|
||||
func run(bin string, args string) bool {
|
||||
err := exec.Command(bin, strings.Split(args, " ")...).Run()
|
||||
log.Printf("%v %v", args, err)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
|
@@ -1,3 +1,5 @@
|
||||
//go:build freebsd || netbsd || openbsd || dragonfly
|
||||
|
||||
package hardware
|
||||
|
||||
import (
|
@@ -1,3 +1,5 @@
|
||||
//go:build darwin || ios
|
||||
|
||||
package hardware
|
||||
|
||||
import (
|
||||
|
@@ -1,3 +1,5 @@
|
||||
//go:build unix && !darwin && !freebsd && !netbsd && !openbsd && !dragonfly
|
||||
|
||||
package hardware
|
||||
|
||||
import (
|
@@ -1,3 +1,5 @@
|
||||
//go:build windows
|
||||
|
||||
package hardware
|
||||
|
||||
import "github.com/AlexxIT/go2rtc/internal/api"
|
||||
|
118
internal/ffmpeg/producer.go
Normal file
118
internal/ffmpeg/producer.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/aac"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
type Producer struct {
|
||||
core.Connection
|
||||
url string
|
||||
query url.Values
|
||||
ffmpeg core.Producer
|
||||
}
|
||||
|
||||
// NewProducer - FFmpeg producer with auto selection video/audio codec based on client capabilities
|
||||
func NewProducer(url string) (core.Producer, error) {
|
||||
p := &Producer{}
|
||||
|
||||
i := strings.IndexByte(url, '#')
|
||||
p.url, p.query = url[:i], streams.ParseQuery(url[i+1:])
|
||||
|
||||
// ffmpeg.NewProducer support only one audio
|
||||
if len(p.query["video"]) != 0 || len(p.query["audio"]) != 1 {
|
||||
return nil, errors.New("ffmpeg: unsupported params: " + url[i:])
|
||||
}
|
||||
|
||||
p.ID = core.NewID()
|
||||
p.FormatName = "ffmpeg"
|
||||
p.Medias = []*core.Media{
|
||||
{
|
||||
// we can support only audio, because don't know FmtpLine for H264 and PayloadType for MJPEG
|
||||
Kind: core.KindAudio,
|
||||
Direction: core.DirectionRecvonly,
|
||||
// codecs in order from best to worst
|
||||
Codecs: []*core.Codec{
|
||||
// OPUS will always marked as OPUS/48000/2
|
||||
{Name: core.CodecOpus, ClockRate: 48000, Channels: 2},
|
||||
{Name: core.CodecPCM, ClockRate: 16000},
|
||||
{Name: core.CodecPCMA, ClockRate: 16000},
|
||||
{Name: core.CodecPCMU, ClockRate: 16000},
|
||||
{Name: core.CodecPCM, ClockRate: 8000},
|
||||
{Name: core.CodecPCMA, ClockRate: 8000},
|
||||
{Name: core.CodecPCMU, ClockRate: 8000},
|
||||
// AAC has unknown problems on Dahua two way
|
||||
{Name: core.CodecAAC, ClockRate: 16000, FmtpLine: aac.FMTP + "1408"},
|
||||
},
|
||||
},
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (p *Producer) Start() error {
|
||||
var err error
|
||||
if p.ffmpeg, err = streams.GetProducer(p.newURL()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i, media := range p.ffmpeg.GetMedias() {
|
||||
track, err := p.ffmpeg.GetTrack(media, media.Codecs[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Receivers[i].Replace(track)
|
||||
}
|
||||
|
||||
return p.ffmpeg.Start()
|
||||
}
|
||||
|
||||
func (p *Producer) Stop() error {
|
||||
if p.ffmpeg == nil {
|
||||
return nil
|
||||
}
|
||||
return p.ffmpeg.Stop()
|
||||
}
|
||||
|
||||
func (p *Producer) MarshalJSON() ([]byte, error) {
|
||||
if p.ffmpeg == nil {
|
||||
return json.Marshal(p.Connection)
|
||||
}
|
||||
return json.Marshal(p.ffmpeg)
|
||||
}
|
||||
|
||||
func (p *Producer) newURL() string {
|
||||
s := p.url
|
||||
// rewrite codecs in url from auto to known presets from defaults
|
||||
for _, receiver := range p.Receivers {
|
||||
codec := receiver.Codec
|
||||
switch codec.Name {
|
||||
case core.CodecOpus:
|
||||
s += "#audio=opus"
|
||||
case core.CodecAAC:
|
||||
s += "#audio=aac/16000"
|
||||
case core.CodecPCM:
|
||||
s += "#audio=pcm/" + strconv.Itoa(int(codec.ClockRate))
|
||||
case core.CodecPCMA:
|
||||
s += "#audio=pcma/" + strconv.Itoa(int(codec.ClockRate))
|
||||
case core.CodecPCMU:
|
||||
s += "#audio=pcmu/" + strconv.Itoa(int(codec.ClockRate))
|
||||
}
|
||||
}
|
||||
// add other params
|
||||
for key, values := range p.query {
|
||||
if key != "audio" {
|
||||
for _, value := range values {
|
||||
s += "#" + key + "=" + value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
46
internal/ffmpeg/version.go
Normal file
46
internal/ffmpeg/version.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"sync"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/ffmpeg"
|
||||
)
|
||||
|
||||
var verMu sync.Mutex
|
||||
var verErr error
|
||||
var verFF string
|
||||
var verAV string
|
||||
|
||||
func Version() (string, error) {
|
||||
verMu.Lock()
|
||||
defer verMu.Unlock()
|
||||
|
||||
if verFF != "" {
|
||||
return verFF, verErr
|
||||
}
|
||||
|
||||
cmd := exec.Command(defaults["bin"], "-version")
|
||||
b, err := cmd.Output()
|
||||
if err != nil {
|
||||
verFF = "-"
|
||||
verErr = err
|
||||
return verFF, verErr
|
||||
}
|
||||
|
||||
verFF, verAV = ffmpeg.ParseVersion(b)
|
||||
|
||||
if verFF == "" {
|
||||
verFF = "?"
|
||||
}
|
||||
|
||||
// better to compare libavformat, because nightly/master builds
|
||||
if verAV != "" && verAV < ffmpeg.Version50 {
|
||||
verErr = errors.New("ffmpeg: unsupported version: " + verFF)
|
||||
}
|
||||
|
||||
log.Debug().Str("version", verFF).Str("libavformat", verAV).Msgf("[ffmpeg] bin")
|
||||
|
||||
return verFF, verErr
|
||||
}
|
@@ -4,56 +4,76 @@ import (
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func GetInput(src string) (string, error) {
|
||||
func GetInput(src string) string {
|
||||
query, err := url.ParseQuery(src)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return ""
|
||||
}
|
||||
|
||||
// set defaults (using Add instead of Set)
|
||||
query.Add("source", "testsrc")
|
||||
query.Add("size", "1920x1080")
|
||||
query.Add("decimals", "2")
|
||||
input := "-re"
|
||||
|
||||
// https://ffmpeg.org/ffmpeg-filters.html
|
||||
source := query.Get("source")
|
||||
input := "-re -f lavfi -i " + source
|
||||
for _, video := range query["video"] {
|
||||
// https://ffmpeg.org/ffmpeg-filters.html
|
||||
sep := "=" // first separator
|
||||
|
||||
sep := "=" // first separator
|
||||
for key, values := range query {
|
||||
value := values[0]
|
||||
|
||||
// https://ffmpeg.org/ffmpeg-utils.html#video-size-syntax
|
||||
switch key {
|
||||
case "color", "rate", "duration", "sar":
|
||||
case "size":
|
||||
switch value {
|
||||
case "720":
|
||||
value = "1280x720"
|
||||
case "1080":
|
||||
value = "1920x1080"
|
||||
case "2K":
|
||||
value = "2560x1440"
|
||||
case "4K":
|
||||
value = "3840x2160"
|
||||
case "8K":
|
||||
value = "7680x4230" // https://reolink.com/blog/8k-resolution/
|
||||
}
|
||||
case "decimals":
|
||||
if source != "testsrc" {
|
||||
continue
|
||||
}
|
||||
default:
|
||||
continue
|
||||
if video == "" {
|
||||
video = "testsrc=decimals=2" // default video
|
||||
sep = ":"
|
||||
}
|
||||
|
||||
input += sep + key + "=" + value
|
||||
sep = ":" // next separator
|
||||
input += " -f lavfi -i " + video
|
||||
|
||||
// set defaults (using Add instead of Set)
|
||||
query.Add("size", "1920x1080")
|
||||
|
||||
for key, values := range query {
|
||||
value := values[0]
|
||||
|
||||
// https://ffmpeg.org/ffmpeg-utils.html#video-size-syntax
|
||||
switch key {
|
||||
case "color", "rate", "duration", "sar", "decimals":
|
||||
case "size":
|
||||
switch value {
|
||||
case "720":
|
||||
value = "1280x720" // crf=1 -> 12 Mbps
|
||||
case "1080":
|
||||
value = "1920x1080" // crf=1 -> 25 Mbps
|
||||
case "2K":
|
||||
value = "2560x1440" // crf=1 -> 43 Mbps
|
||||
case "4K":
|
||||
value = "3840x2160" // crf=1 -> 103 Mbps
|
||||
case "8K":
|
||||
value = "7680x4230" // https://reolink.com/blog/8k-resolution/
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
input += sep + key + "=" + value
|
||||
sep = ":" // next separator
|
||||
}
|
||||
|
||||
if s := query.Get("format"); s != "" {
|
||||
input += ",format=" + s
|
||||
}
|
||||
}
|
||||
|
||||
if s := query.Get("format"); s != "" {
|
||||
input += ",format=" + s
|
||||
}
|
||||
|
||||
return input, nil
|
||||
return input
|
||||
}
|
||||
|
||||
func GetInputTTS(src string) string {
|
||||
query, err := url.ParseQuery(src)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
input := `-re -f lavfi -i "flite=text='` + query.Get("text") + `'`
|
||||
|
||||
// ffmpeg -f lavfi -i flite=list_voices=1
|
||||
// awb, kal, kal16, rms, slt
|
||||
if voice := query.Get("voice"); voice != "" {
|
||||
input += ":voice" + voice
|
||||
}
|
||||
|
||||
return input + `"`
|
||||
}
|
||||
|
20
internal/ffmpeg/virtual/virtual_test.go
Normal file
20
internal/ffmpeg/virtual/virtual_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package virtual
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetInput(t *testing.T) {
|
||||
s := GetInput("video")
|
||||
require.Equal(t, "-re -f lavfi -i testsrc=decimals=2:size=1920x1080", s)
|
||||
|
||||
s = GetInput("video=testsrc2&size=4K")
|
||||
require.Equal(t, "-re -f lavfi -i testsrc2=size=3840x2160", s)
|
||||
}
|
||||
|
||||
func TestGetInputTTS(t *testing.T) {
|
||||
s := GetInputTTS("text=hello world&voice=slt")
|
||||
require.Equal(t, `-re -f lavfi -i "flite=text='hello world':voiceslt"`, s)
|
||||
}
|
@@ -10,15 +10,13 @@ import (
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("gopro", handleGoPro)
|
||||
streams.HandleFunc("gopro", func(source string) (core.Producer, error) {
|
||||
return gopro.Dial(source)
|
||||
})
|
||||
|
||||
api.HandleFunc("api/gopro", apiGoPro)
|
||||
}
|
||||
|
||||
func handleGoPro(rawURL string) (core.Producer, error) {
|
||||
return gopro.Dial(rawURL)
|
||||
}
|
||||
|
||||
func apiGoPro(w http.ResponseWriter, r *http.Request) {
|
||||
var items []*api.Source
|
||||
|
||||
|
@@ -63,7 +63,7 @@ func apiStream(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
s, err = webrtc.ExchangeSDP(stream, string(offer), "WebRTC/Hass sync", r.UserAgent())
|
||||
s, err = webrtc.ExchangeSDP(stream, string(offer), "hass/webrtc", r.UserAgent())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
@@ -21,7 +21,7 @@ import (
|
||||
func Init() {
|
||||
var conf struct {
|
||||
API struct {
|
||||
Listen string `json:"listen"`
|
||||
Listen string `yaml:"listen"`
|
||||
} `yaml:"api"`
|
||||
Mod struct {
|
||||
Config string `yaml:"config"`
|
||||
@@ -45,19 +45,14 @@ func Init() {
|
||||
return "", nil
|
||||
})
|
||||
|
||||
streams.HandleFunc("hass", func(url string) (core.Producer, error) {
|
||||
streams.HandleFunc("hass", func(source string) (core.Producer, error) {
|
||||
// support hass://supervisor?entity_id=camera.driveway_doorbell
|
||||
client, err := hass.NewClient(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client, nil
|
||||
return hass.NewClient(source)
|
||||
})
|
||||
|
||||
// load static entries from Hass config
|
||||
if err := importConfig(conf.Mod.Config); err != nil {
|
||||
log.Debug().Msgf("[hass] can't import config: %s", err)
|
||||
log.Trace().Msgf("[hass] can't import config: %s", err)
|
||||
|
||||
api.HandleFunc("api/hass", func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "no hass config", http.StatusNotFound)
|
||||
|
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mp4"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mpegts"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
@@ -63,15 +62,13 @@ func handlerStream(w http.ResponseWriter, r *http.Request) {
|
||||
medias := mp4.ParseQuery(r.URL.Query())
|
||||
if medias != nil {
|
||||
c := mp4.NewConsumer(medias)
|
||||
c.Type = "HLS/fMP4 consumer"
|
||||
c.RemoteAddr = tcp.RemoteAddr(r)
|
||||
c.UserAgent = r.UserAgent()
|
||||
c.FormatName = "hls/fmp4"
|
||||
c.WithRequest(r)
|
||||
cons = c
|
||||
} else {
|
||||
c := mpegts.NewConsumer()
|
||||
c.Type = "HLS/TS consumer"
|
||||
c.RemoteAddr = tcp.RemoteAddr(r)
|
||||
c.UserAgent = r.UserAgent()
|
||||
c.FormatName = "hls/mpegts"
|
||||
c.WithRequest(r)
|
||||
cons = c
|
||||
}
|
||||
|
||||
|
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mp4"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
)
|
||||
|
||||
func handlerWSHLS(tr *ws.Transport, msg *ws.Message) error {
|
||||
@@ -20,9 +19,8 @@ func handlerWSHLS(tr *ws.Transport, msg *ws.Message) error {
|
||||
codecs := msg.String()
|
||||
medias := mp4.ParseCodecs(codecs, true)
|
||||
cons := mp4.NewConsumer(medias)
|
||||
cons.Type = "HLS/fMP4 consumer"
|
||||
cons.RemoteAddr = tcp.RemoteAddr(tr.Request)
|
||||
cons.UserAgent = tr.Request.UserAgent()
|
||||
cons.FormatName = "hls/fmp4"
|
||||
cons.WithRequest(tr.Request)
|
||||
|
||||
log.Trace().Msgf("[hls] new ws consumer codecs=%s", codecs)
|
||||
|
||||
|
@@ -22,12 +22,11 @@ import (
|
||||
func Init() {
|
||||
var cfg struct {
|
||||
Mod map[string]struct {
|
||||
Pin string `json:"pin"`
|
||||
Name string `json:"name"`
|
||||
DeviceID string `json:"device_id"`
|
||||
DevicePrivate string `json:"device_private"`
|
||||
Pairings []string `json:"pairings"`
|
||||
//Listen string `json:"listen"`
|
||||
Pin string `yaml:"pin"`
|
||||
Name string `yaml:"name"`
|
||||
DeviceID string `yaml:"device_id"`
|
||||
DevicePrivate string `yaml:"device_private"`
|
||||
Pairings []string `yaml:"pairings"`
|
||||
} `yaml:"homekit"`
|
||||
}
|
||||
app.LoadConfig(&cfg)
|
||||
@@ -134,12 +133,19 @@ func Init() {
|
||||
var log zerolog.Logger
|
||||
var servers map[string]*server
|
||||
|
||||
func streamHandler(url string) (core.Producer, error) {
|
||||
func streamHandler(rawURL string) (core.Producer, error) {
|
||||
if srtp.Server == nil {
|
||||
return nil, errors.New("homekit: can't work without SRTP server")
|
||||
}
|
||||
|
||||
return homekit.Dial(url, srtp.Server)
|
||||
rawURL, rawQuery, _ := strings.Cut(rawURL, "#")
|
||||
client, err := homekit.Dial(rawURL, srtp.Server)
|
||||
if client != nil && rawQuery != "" {
|
||||
query := streams.ParseQuery(rawQuery)
|
||||
client.Bitrate = parseBitrate(query.Get("bitrate"))
|
||||
}
|
||||
|
||||
return client, err
|
||||
}
|
||||
|
||||
func hapPairSetup(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -200,3 +206,24 @@ func findHomeKitURL(stream *streams.Stream) string {
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseBitrate(s string) int {
|
||||
n := len(s)
|
||||
if n == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
var k int
|
||||
switch n--; s[n] {
|
||||
case 'K':
|
||||
k = 1024
|
||||
s = s[:n]
|
||||
case 'M':
|
||||
k = 1024 * 1024
|
||||
s = s[:n]
|
||||
default:
|
||||
k = 1
|
||||
}
|
||||
|
||||
return k * core.Atoi(s)
|
||||
}
|
||||
|
@@ -11,9 +11,9 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hls"
|
||||
"github.com/AlexxIT/go2rtc/pkg/image"
|
||||
"github.com/AlexxIT/go2rtc/pkg/magic"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mjpeg"
|
||||
"github.com/AlexxIT/go2rtc/pkg/multipart"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mpjpeg"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
)
|
||||
|
||||
@@ -45,6 +45,21 @@ func handleHTTP(rawURL string) (core.Producer, error) {
|
||||
}
|
||||
}
|
||||
|
||||
prod, err := do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if info, ok := prod.(core.Info); ok {
|
||||
info.SetProtocol("http")
|
||||
info.SetRemoteAddr(req.URL.Host) // TODO: rewrite to net.Conn
|
||||
info.SetURL(rawURL)
|
||||
}
|
||||
|
||||
return prod, nil
|
||||
}
|
||||
|
||||
func do(req *http.Request) (core.Producer, error) {
|
||||
res, err := tcp.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -66,14 +81,12 @@ func handleHTTP(rawURL string) (core.Producer, error) {
|
||||
}
|
||||
|
||||
switch {
|
||||
case ct == "image/jpeg":
|
||||
return mjpeg.NewClient(res), nil
|
||||
|
||||
case ct == "multipart/x-mixed-replace":
|
||||
return multipart.Open(res.Body)
|
||||
|
||||
case ct == "application/vnd.apple.mpegurl" || ext == "m3u8":
|
||||
return hls.OpenURL(req.URL, res.Body)
|
||||
case ct == "image/jpeg":
|
||||
return image.Open(res)
|
||||
case ct == "multipart/x-mixed-replace":
|
||||
return mpjpeg.Open(res.Body)
|
||||
}
|
||||
|
||||
return magic.Open(res.Body)
|
||||
|
@@ -7,16 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("isapi", handle)
|
||||
}
|
||||
|
||||
func handle(url string) (core.Producer, error) {
|
||||
conn, err := isapi.NewClient(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = conn.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
streams.HandleFunc("isapi", func(source string) (core.Producer, error) {
|
||||
return isapi.Dial(source)
|
||||
})
|
||||
}
|
||||
|
@@ -4,16 +4,10 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/ivideon"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("ivideon", func(url string) (core.Producer, error) {
|
||||
id := strings.Replace(url[8:], "/", ":", 1)
|
||||
prod := ivideon.NewClient(id)
|
||||
if err := prod.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return prod, nil
|
||||
streams.HandleFunc("ivideon", func(source string) (core.Producer, error) {
|
||||
return ivideon.Dial(source)
|
||||
})
|
||||
}
|
||||
|
38
internal/mjpeg/README.md
Normal file
38
internal/mjpeg/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
## Stream as ASCII to Terminal
|
||||
|
||||
[](https://www.youtube.com/watch?v=sHj_3h_sX7M)
|
||||
|
||||
**Tips**
|
||||
|
||||
- this feature works only with MJPEG codec (use transcoding)
|
||||
- choose a low frame rate (FPS)
|
||||
- choose the width and height to fit in your terminal
|
||||
- different terminals support different numbers of colours (8, 256, rgb)
|
||||
- escape text param with urlencode
|
||||
- you can stream any camera or file from a disc
|
||||
|
||||
**go2rtc.yaml** - transcoding to MJPEG, terminal size - 210x59 (16/9), fps - 10
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
gamazda: ffmpeg:gamazda.mp4#video=mjpeg#hardware#width=210#height=59#raw=-r 10
|
||||
```
|
||||
|
||||
**API params**
|
||||
|
||||
- `color` - foreground color, values: empty, `8`, `256`, `rgb`, [SGR](https://en.wikipedia.org/wiki/ANSI_escape_code)
|
||||
- example: `30` (black), `37` (white), `38;5;226` (yellow)
|
||||
- `back` - background color, values: empty, `8`, `256`, `rgb`, [SGR](https://en.wikipedia.org/wiki/ANSI_escape_code)
|
||||
- example: `40` (black), `47` (white), `48;5;226` (yellow)
|
||||
- `text` - character set, values: empty, one char, `block`, list of chars (in order of brightness)
|
||||
- example: `%20` (space), `block` (keyword for block elements), `ox` (two chars)
|
||||
|
||||
**Examples**
|
||||
|
||||
```bash
|
||||
% curl "http://192.168.1.123:1984/api/stream.ascii?src=gamazda"
|
||||
% curl "http://192.168.1.123:1984/api/stream.ascii?src=gamazda&color=256"
|
||||
% curl "http://192.168.1.123:1984/api/stream.ascii?src=gamazda&back=256&text=%20"
|
||||
% curl "http://192.168.1.123:1984/api/stream.ascii?src=gamazda&back=8&text=%20%20"
|
||||
% curl "http://192.168.1.123:1984/api/stream.ascii?src=gamazda&text=helloworld"
|
||||
```
|
@@ -5,26 +5,36 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/ffmpeg"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/ascii"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/magic"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mjpeg"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mpjpeg"
|
||||
"github.com/AlexxIT/go2rtc/pkg/y4m"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
api.HandleFunc("api/frame.jpeg", handlerKeyframe)
|
||||
api.HandleFunc("api/stream.mjpeg", handlerStream)
|
||||
api.HandleFunc("api/stream.ascii", handlerStream)
|
||||
api.HandleFunc("api/stream.y4m", apiStreamY4M)
|
||||
|
||||
ws.HandleFunc("mjpeg", handlerWS)
|
||||
|
||||
log = app.GetLogger("mjpeg")
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
|
||||
func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
|
||||
src := r.URL.Query().Get("src")
|
||||
stream := streams.Get(src)
|
||||
@@ -34,8 +44,7 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
cons := magic.NewKeyframe()
|
||||
cons.RemoteAddr = tcp.RemoteAddr(r)
|
||||
cons.UserAgent = r.UserAgent()
|
||||
cons.WithRequest(r)
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
@@ -90,8 +99,7 @@ func outputMjpeg(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
cons := mjpeg.NewConsumer()
|
||||
cons.RemoteAddr = tcp.RemoteAddr(r)
|
||||
cons.UserAgent = r.UserAgent()
|
||||
cons.WithRequest(r)
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Msg("[api.mjpeg] add consumer")
|
||||
@@ -99,38 +107,22 @@ func outputMjpeg(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
||||
h.Set("Cache-Control", "no-cache")
|
||||
h.Set("Connection", "close")
|
||||
h.Set("Pragma", "no-cache")
|
||||
|
||||
wr := &writer{wr: w, buf: []byte(header)}
|
||||
_, _ = cons.WriteTo(wr)
|
||||
if strings.HasSuffix(r.URL.Path, "mjpeg") {
|
||||
wr := mjpeg.NewWriter(w)
|
||||
_, _ = cons.WriteTo(wr)
|
||||
} else {
|
||||
cons.FormatName = "ascii"
|
||||
|
||||
stream.RemoveConsumer(cons)
|
||||
}
|
||||
|
||||
const header = "--frame\r\nContent-Type: image/jpeg\r\nContent-Length: "
|
||||
|
||||
type writer struct {
|
||||
wr io.Writer
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func (w *writer) Write(p []byte) (n int, err error) {
|
||||
w.buf = w.buf[:len(header)]
|
||||
w.buf = append(w.buf, strconv.Itoa(len(p))...)
|
||||
w.buf = append(w.buf, "\r\n\r\n"...)
|
||||
w.buf = append(w.buf, p...)
|
||||
w.buf = append(w.buf, "\r\n"...)
|
||||
|
||||
// Chrome bug: mjpeg image always shows the second to last image
|
||||
// https://bugs.chromium.org/p/chromium/issues/detail?id=527446
|
||||
if n, err = w.wr.Write(w.buf); err == nil {
|
||||
w.wr.(http.Flusher).Flush()
|
||||
query := r.URL.Query()
|
||||
wr := ascii.NewWriter(w, query.Get("color"), query.Get("back"), query.Get("text"))
|
||||
_, _ = cons.WriteTo(wr)
|
||||
}
|
||||
|
||||
return
|
||||
stream.RemoveConsumer(cons)
|
||||
}
|
||||
|
||||
func inputMjpeg(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -141,17 +133,16 @@ func inputMjpeg(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
res := &http.Response{Body: r.Body, Header: r.Header, Request: r}
|
||||
res.Header.Set("Content-Type", "multipart/mixed;boundary=")
|
||||
prod, _ := mpjpeg.Open(r.Body)
|
||||
prod.WithRequest(r)
|
||||
|
||||
client := mjpeg.NewClient(res)
|
||||
stream.AddProducer(client)
|
||||
stream.AddProducer(prod)
|
||||
|
||||
if err := client.Start(); err != nil && err != io.EOF {
|
||||
if err := prod.Start(); err != nil && err != io.EOF {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
|
||||
stream.RemoveProducer(client)
|
||||
stream.RemoveProducer(prod)
|
||||
}
|
||||
|
||||
func handlerWS(tr *ws.Transport, _ *ws.Message) error {
|
||||
@@ -161,8 +152,7 @@ func handlerWS(tr *ws.Transport, _ *ws.Message) error {
|
||||
}
|
||||
|
||||
cons := mjpeg.NewConsumer()
|
||||
cons.RemoteAddr = tcp.RemoteAddr(tr.Request)
|
||||
cons.UserAgent = tr.Request.UserAgent()
|
||||
cons.WithRequest(tr.Request)
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Debug().Err(err).Msg("[mjpeg] add consumer")
|
||||
@@ -179,3 +169,24 @@ func handlerWS(tr *ws.Transport, _ *ws.Message) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiStreamY4M(w http.ResponseWriter, r *http.Request) {
|
||||
src := r.URL.Query().Get("src")
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
cons := y4m.NewConsumer()
|
||||
cons.WithRequest(r)
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = cons.WriteTo(w)
|
||||
|
||||
stream.RemoveConsumer(cons)
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package mp4
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -12,7 +13,6 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mp4"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
@@ -99,9 +99,9 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
medias := mp4.ParseQuery(r.URL.Query())
|
||||
cons := mp4.NewConsumer(medias)
|
||||
cons.Type = "MP4/HTTP active consumer"
|
||||
cons.RemoteAddr = tcp.RemoteAddr(r)
|
||||
cons.UserAgent = r.UserAgent()
|
||||
cons.FormatName = "mp4"
|
||||
cons.Protocol = "http"
|
||||
cons.WithRequest(r)
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
@@ -127,20 +127,20 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) {
|
||||
header.Set("Content-Disposition", `attachment; filename="`+filename+`"`)
|
||||
}
|
||||
|
||||
var duration *time.Timer
|
||||
if s := query.Get("duration"); s != "" {
|
||||
if i, _ := strconv.Atoi(s); i > 0 {
|
||||
duration = time.AfterFunc(time.Second*time.Duration(i), func() {
|
||||
_ = cons.Stop()
|
||||
})
|
||||
}
|
||||
ctx := r.Context() // handle when the client drops the connection
|
||||
|
||||
if i := core.Atoi(query.Get("duration")); i > 0 {
|
||||
timeout := time.Second * time.Duration(i)
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = cons.Stop()
|
||||
stream.RemoveConsumer(cons)
|
||||
}()
|
||||
|
||||
_, _ = cons.WriteTo(w)
|
||||
|
||||
stream.RemoveConsumer(cons)
|
||||
|
||||
if duration != nil {
|
||||
duration.Stop()
|
||||
}
|
||||
}
|
||||
|
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mp4"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
)
|
||||
|
||||
func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error {
|
||||
@@ -24,9 +23,8 @@ func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error {
|
||||
}
|
||||
|
||||
cons := mp4.NewConsumer(medias)
|
||||
cons.Type = "MSE/WebSocket active consumer"
|
||||
cons.RemoteAddr = tcp.RemoteAddr(tr.Request)
|
||||
cons.UserAgent = tr.Request.UserAgent()
|
||||
cons.FormatName = "mse/fmp4"
|
||||
cons.WithRequest(tr.Request)
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Debug().Err(err).Msg("[mp4] add consumer")
|
||||
@@ -57,9 +55,7 @@ func handlerWSMP4(tr *ws.Transport, msg *ws.Message) error {
|
||||
}
|
||||
|
||||
cons := mp4.NewKeyframe(medias)
|
||||
cons.Type = "MP4/WebSocket active consumer"
|
||||
cons.RemoteAddr = tcp.RemoteAddr(tr.Request)
|
||||
cons.UserAgent = tr.Request.UserAgent()
|
||||
cons.WithRequest(tr.Request)
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
|
@@ -6,8 +6,6 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/aac"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func apiStreamAAC(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -19,11 +17,9 @@ func apiStreamAAC(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
cons := aac.NewConsumer()
|
||||
cons.RemoteAddr = tcp.RemoteAddr(r)
|
||||
cons.UserAgent = r.UserAgent()
|
||||
cons.WithRequest(r)
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
@@ -6,8 +6,6 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mpegts"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
@@ -32,11 +30,9 @@ func outputMpegTS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
cons := mpegts.NewConsumer()
|
||||
cons.RemoteAddr = tcp.RemoteAddr(r)
|
||||
cons.UserAgent = r.UserAgent()
|
||||
cons.WithRequest(r)
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
@@ -10,19 +10,13 @@ import (
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("nest", streamNest)
|
||||
streams.HandleFunc("nest", func(source string) (core.Producer, error) {
|
||||
return nest.Dial(source)
|
||||
})
|
||||
|
||||
api.HandleFunc("api/nest", apiNest)
|
||||
}
|
||||
|
||||
func streamNest(url string) (core.Producer, error) {
|
||||
client, err := nest.NewClient(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func apiNest(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
cliendID := query.Get("client_id")
|
||||
|
@@ -50,7 +50,7 @@ func Init() {
|
||||
|
||||
log.Info().Str("addr", address).Msg("[ngrok] add external candidate for WebRTC")
|
||||
|
||||
webrtc.AddCandidate(address, "tcp")
|
||||
webrtc.AddCandidate("tcp", address)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@@ -11,22 +11,13 @@ import (
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("roborock", handle)
|
||||
streams.HandleFunc("roborock", func(source string) (core.Producer, error) {
|
||||
return roborock.Dial(source)
|
||||
})
|
||||
|
||||
api.HandleFunc("api/roborock", apiHandle)
|
||||
}
|
||||
|
||||
func handle(url string) (core.Producer, error) {
|
||||
conn := roborock.NewClient(url)
|
||||
if err := conn.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := conn.Connect(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
var Auth struct {
|
||||
UserData *roborock.UserInfo `json:"user_data"`
|
||||
BaseURL string `json:"base_url"`
|
||||
|
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/flv"
|
||||
"github.com/AlexxIT/go2rtc/pkg/rtmp"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
@@ -128,11 +127,7 @@ func tcpHandle(netConn net.Conn) error {
|
||||
var log zerolog.Logger
|
||||
|
||||
func streamsHandle(url string) (core.Producer, error) {
|
||||
client, err := rtmp.DialPlay(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
return rtmp.DialPlay(url)
|
||||
}
|
||||
|
||||
func streamsConsumerHandle(url string) (core.Consumer, func(), error) {
|
||||
@@ -165,9 +160,7 @@ func outputFLV(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
cons := flv.NewConsumer()
|
||||
cons.Type = "HTTP-FLV consumer"
|
||||
cons.RemoteAddr = tcp.RemoteAddr(r)
|
||||
cons.UserAgent = r.UserAgent()
|
||||
cons.WithRequest(r)
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
|
@@ -21,7 +21,7 @@ func Init() {
|
||||
Username string `yaml:"username" json:"-"`
|
||||
Password string `yaml:"password" json:"-"`
|
||||
DefaultQuery string `yaml:"default_query" json:"default_query"`
|
||||
PacketSize uint16 `yaml:"pkt_size"`
|
||||
PacketSize uint16 `yaml:"pkt_size" json:"pkt_size,omitempty"`
|
||||
} `yaml:"rtsp"`
|
||||
}
|
||||
|
||||
@@ -210,6 +210,11 @@ func tcpHandler(conn *rtsp.Conn) {
|
||||
return
|
||||
}
|
||||
|
||||
query := conn.URL.Query()
|
||||
if s := query.Get("timeout"); s != "" {
|
||||
conn.Timeout = core.Atoi(s)
|
||||
}
|
||||
|
||||
log.Debug().Str("stream", name).Msg("[rtsp] new producer")
|
||||
|
||||
stream.AddProducer(conn)
|
||||
|
8
internal/streams/README.md
Normal file
8
internal/streams/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
## Testing notes
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
test1-basic: ffmpeg:virtual?video#video=h264
|
||||
test2-reconnect: ffmpeg:virtual?video&duration=10#video=h264
|
||||
test3-execkill: exec:./examples/rtsp_client/rtsp_client/rtsp_client {output}
|
||||
```
|
124
internal/streams/api.go
Normal file
124
internal/streams/api.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/pkg/probe"
|
||||
)
|
||||
|
||||
func apiStreams(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
src := query.Get("src")
|
||||
|
||||
// without source - return all streams list
|
||||
if src == "" && r.Method != "POST" {
|
||||
api.ResponseJSON(w, streams)
|
||||
return
|
||||
}
|
||||
|
||||
// Not sure about all this API. Should be rewrited...
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
stream := Get(src)
|
||||
if stream == nil {
|
||||
http.Error(w, "", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
cons := probe.NewProbe(query)
|
||||
if len(cons.Medias) != 0 {
|
||||
cons.WithRequest(r)
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
api.ResponsePrettyJSON(w, stream)
|
||||
|
||||
stream.RemoveConsumer(cons)
|
||||
} else {
|
||||
api.ResponsePrettyJSON(w, streams[src])
|
||||
}
|
||||
|
||||
case "PUT":
|
||||
name := query.Get("name")
|
||||
if name == "" {
|
||||
name = src
|
||||
}
|
||||
|
||||
if New(name, src) == nil {
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.PatchConfig(name, src, "streams"); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
|
||||
case "PATCH":
|
||||
name := query.Get("name")
|
||||
if name == "" {
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// support {input} templates: https://github.com/AlexxIT/go2rtc#module-hass
|
||||
if Patch(name, src) == nil {
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
case "POST":
|
||||
// with dst - redirect source to dst
|
||||
if dst := query.Get("dst"); dst != "" {
|
||||
if stream := Get(dst); stream != nil {
|
||||
if err := Validate(src); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
} else if err = stream.Play(src); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
} else {
|
||||
api.ResponseJSON(w, stream)
|
||||
}
|
||||
} else if stream = Get(src); stream != nil {
|
||||
if err := Validate(dst); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
} else if err = stream.Publish(dst); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
} else {
|
||||
http.Error(w, "", http.StatusNotFound)
|
||||
}
|
||||
} else {
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
case "DELETE":
|
||||
delete(streams, src)
|
||||
|
||||
if err := app.PatchConfig(src, nil, "streams"); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func apiStreamsDOT(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
|
||||
dot := make([]byte, 0, 1024)
|
||||
dot = append(dot, "digraph {\n"...)
|
||||
if query.Has("src") {
|
||||
for _, name := range query["src"] {
|
||||
if stream := streams[name]; stream != nil {
|
||||
dot = AppendDOT(dot, stream)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, stream := range streams {
|
||||
dot = AppendDOT(dot, stream)
|
||||
}
|
||||
}
|
||||
dot = append(dot, '}')
|
||||
|
||||
api.Response(w, dot, "text/vnd.graphviz")
|
||||
}
|
175
internal/streams/dot.go
Normal file
175
internal/streams/dot.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func AppendDOT(dot []byte, stream *Stream) []byte {
|
||||
for _, prod := range stream.producers {
|
||||
if prod.conn == nil {
|
||||
continue
|
||||
}
|
||||
c, err := marshalConn(prod.conn)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dot = c.appendDOT(dot, "producer")
|
||||
}
|
||||
for _, cons := range stream.consumers {
|
||||
c, err := marshalConn(cons)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dot = c.appendDOT(dot, "consumer")
|
||||
}
|
||||
return dot
|
||||
}
|
||||
|
||||
func marshalConn(v any) (*conn, error) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var c conn
|
||||
if err = json.Unmarshal(b, &c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
const bytesK = "KMGTP"
|
||||
|
||||
func humanBytes(i int) string {
|
||||
if i < 1000 {
|
||||
return fmt.Sprintf("%d B", i)
|
||||
}
|
||||
|
||||
f := float64(i) / 1000
|
||||
var n uint8
|
||||
for f >= 1000 && n < 5 {
|
||||
f /= 1000
|
||||
n++
|
||||
}
|
||||
return fmt.Sprintf("%.2f %cB", f, bytesK[n])
|
||||
}
|
||||
|
||||
type node struct {
|
||||
ID uint32 `json:"id"`
|
||||
Codec map[string]any `json:"codec"`
|
||||
Parent uint32 `json:"parent"`
|
||||
Childs []uint32 `json:"childs"`
|
||||
Bytes int `json:"bytes"`
|
||||
//Packets uint32 `json:"packets"`
|
||||
//Drops uint32 `json:"drops"`
|
||||
}
|
||||
|
||||
var codecKeys = []string{"codec_name", "sample_rate", "channels", "profile", "level"}
|
||||
|
||||
func (n *node) name() string {
|
||||
if name, ok := n.Codec["codec_name"].(string); ok {
|
||||
return name
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func (n *node) codec() []byte {
|
||||
b := make([]byte, 0, 128)
|
||||
for _, k := range codecKeys {
|
||||
if v := n.Codec[k]; v != nil {
|
||||
b = fmt.Appendf(b, "%s=%v\n", k, v)
|
||||
}
|
||||
}
|
||||
if l := len(b); l > 0 {
|
||||
return b[:l-1]
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (n *node) appendDOT(dot []byte, group string) []byte {
|
||||
dot = fmt.Appendf(dot, "%d [group=%s, label=%q, title=%q];\n", n.ID, group, n.name(), n.codec())
|
||||
//for _, sink := range n.Childs {
|
||||
// dot = fmt.Appendf(dot, "%d -> %d;\n", n.ID, sink)
|
||||
//}
|
||||
return dot
|
||||
}
|
||||
|
||||
type conn struct {
|
||||
ID uint32 `json:"id"`
|
||||
FormatName string `json:"format_name"`
|
||||
Protocol string `json:"protocol"`
|
||||
RemoteAddr string `json:"remote_addr"`
|
||||
Source string `json:"source"`
|
||||
URL string `json:"url"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Receivers []node `json:"receivers"`
|
||||
Senders []node `json:"senders"`
|
||||
BytesRecv int `json:"bytes_recv"`
|
||||
BytesSend int `json:"bytes_send"`
|
||||
}
|
||||
|
||||
func (c *conn) appendDOT(dot []byte, group string) []byte {
|
||||
host := c.host()
|
||||
dot = fmt.Appendf(dot, "%s [group=host];\n", host)
|
||||
dot = fmt.Appendf(dot, "%d [group=%s, label=%q, title=%q];\n", c.ID, group, c.FormatName, c.label())
|
||||
if group == "producer" {
|
||||
dot = fmt.Appendf(dot, "%s -> %d [label=%q];\n", host, c.ID, humanBytes(c.BytesRecv))
|
||||
} else {
|
||||
dot = fmt.Appendf(dot, "%d -> %s [label=%q];\n", c.ID, host, humanBytes(c.BytesSend))
|
||||
}
|
||||
|
||||
for _, recv := range c.Receivers {
|
||||
dot = fmt.Appendf(dot, "%d -> %d [label=%q];\n", c.ID, recv.ID, humanBytes(recv.Bytes))
|
||||
dot = recv.appendDOT(dot, "node")
|
||||
}
|
||||
for _, send := range c.Senders {
|
||||
dot = fmt.Appendf(dot, "%d -> %d [label=%q];\n", send.Parent, c.ID, humanBytes(send.Bytes))
|
||||
//dot = fmt.Appendf(dot, "%d -> %d [label=%q];\n", send.ID, c.ID, humanBytes(send.Bytes))
|
||||
//dot = send.appendDOT(dot, "node")
|
||||
}
|
||||
return dot
|
||||
}
|
||||
|
||||
func (c *conn) host() (s string) {
|
||||
if c.Protocol == "pipe" {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
|
||||
if s = c.RemoteAddr; s == "" {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
if i := strings.Index(s, "forwarded"); i > 0 {
|
||||
s = s[i+10:]
|
||||
}
|
||||
|
||||
if s[0] == '[' {
|
||||
if i := strings.Index(s, "]"); i > 0 {
|
||||
return s[1:i]
|
||||
}
|
||||
}
|
||||
|
||||
if i := strings.IndexAny(s, " ,:"); i > 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c *conn) label() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("format_name=" + c.FormatName)
|
||||
if c.Protocol != "" {
|
||||
sb.WriteString("\nprotocol=" + c.Protocol)
|
||||
}
|
||||
if c.Source != "" {
|
||||
sb.WriteString("\nsource=" + c.Source)
|
||||
}
|
||||
if c.URL != "" {
|
||||
sb.WriteString("\nurl=" + c.URL)
|
||||
}
|
||||
if c.UserAgent != "" {
|
||||
sb.WriteString("\nuser_agent=" + c.UserAgent)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
@@ -7,7 +7,7 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
type Handler func(url string) (core.Producer, error)
|
||||
type Handler func(source string) (core.Producer, error)
|
||||
|
||||
var handlers = map[string]Handler{}
|
||||
|
||||
|
@@ -2,6 +2,8 @@ package streams
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
@@ -80,18 +82,20 @@ func (s *Stream) Play(source string) error {
|
||||
s.AddInternalProducer(src)
|
||||
s.AddInternalConsumer(cons)
|
||||
|
||||
go func() {
|
||||
_ = src.Start()
|
||||
_ = dst.Stop()
|
||||
s.RemoveProducer(src)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
_ = dst.Start()
|
||||
_ = src.Stop()
|
||||
s.RemoveInternalConsumer(cons)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
_ = src.Start()
|
||||
// little timeout before stop dst, so the buffer can be transferred
|
||||
time.Sleep(time.Second)
|
||||
_ = dst.Stop()
|
||||
s.RemoveProducer(src)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@@ -132,11 +132,10 @@ func (p *Producer) AddTrack(media *core.Media, codec *core.Codec, track *core.Re
|
||||
}
|
||||
|
||||
func (p *Producer) MarshalJSON() ([]byte, error) {
|
||||
if p.conn != nil {
|
||||
return json.Marshal(p.conn)
|
||||
if conn := p.conn; conn != nil {
|
||||
return json.Marshal(conn)
|
||||
}
|
||||
|
||||
info := core.Info{URL: p.url}
|
||||
info := map[string]string{"url": p.url}
|
||||
return json.Marshal(info)
|
||||
}
|
||||
|
||||
@@ -207,7 +206,7 @@ func (p *Producer) reconnect(workerID, retry int) {
|
||||
for _, media := range conn.GetMedias() {
|
||||
switch media.Direction {
|
||||
case core.DirectionRecvonly:
|
||||
for _, receiver := range p.receivers {
|
||||
for i, receiver := range p.receivers {
|
||||
codec := media.MatchCodec(receiver.Codec)
|
||||
if codec == nil {
|
||||
continue
|
||||
@@ -219,6 +218,7 @@ func (p *Producer) reconnect(workerID, retry int) {
|
||||
}
|
||||
|
||||
receiver.Replace(track)
|
||||
p.receivers[i] = track
|
||||
break
|
||||
}
|
||||
|
||||
@@ -234,6 +234,9 @@ func (p *Producer) reconnect(workerID, retry int) {
|
||||
}
|
||||
}
|
||||
|
||||
// stop previous connection after moving tracks (fix ghost exec/ffmpeg)
|
||||
_ = p.conn.Stop()
|
||||
// swap connections
|
||||
p.conn = conn
|
||||
|
||||
go p.worker(conn, workerID)
|
||||
|
@@ -88,6 +88,11 @@ func (s *Stream) RemoveProducer(prod core.Producer) {
|
||||
}
|
||||
|
||||
func (s *Stream) stopProducers() {
|
||||
if s.pending.Load() > 0 {
|
||||
log.Trace().Msg("[streams] skip stop pending producer")
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
producers:
|
||||
for _, producer := range s.producers {
|
||||
@@ -107,19 +112,12 @@ producers:
|
||||
}
|
||||
|
||||
func (s *Stream) MarshalJSON() ([]byte, error) {
|
||||
if !s.mu.TryLock() {
|
||||
log.Warn().Msgf("[streams] json locked")
|
||||
return json.Marshal(nil)
|
||||
}
|
||||
|
||||
var info struct {
|
||||
var info = struct {
|
||||
Producers []*Producer `json:"producers"`
|
||||
Consumers []core.Consumer `json:"consumers"`
|
||||
}{
|
||||
Producers: s.producers,
|
||||
Consumers: s.consumers,
|
||||
}
|
||||
info.Producers = s.producers
|
||||
info.Consumers = s.consumers
|
||||
|
||||
s.mu.Unlock()
|
||||
|
||||
return json.Marshal(info)
|
||||
}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"errors"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sync"
|
||||
@@ -26,7 +26,8 @@ func Init() {
|
||||
streams[name] = NewStream(item)
|
||||
}
|
||||
|
||||
api.HandleFunc("api/streams", streamsHandler)
|
||||
api.HandleFunc("api/streams", apiStreams)
|
||||
api.HandleFunc("api/streams.dot", apiStreamsDOT)
|
||||
|
||||
if cfg.Publish == nil {
|
||||
return
|
||||
@@ -47,9 +48,16 @@ func Get(name string) *Stream {
|
||||
|
||||
var sanitize = regexp.MustCompile(`\s`)
|
||||
|
||||
func New(name string, source string) *Stream {
|
||||
// not allow creating dynamic streams with spaces in the source
|
||||
// Validate - not allow creating dynamic streams with spaces in the source
|
||||
func Validate(source string) error {
|
||||
if sanitize.MatchString(source) {
|
||||
return errors.New("streams: invalid dynamic source")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func New(name string, source string) *Stream {
|
||||
if Validate(source) != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -135,77 +143,6 @@ func Delete(id string) {
|
||||
delete(streams, id)
|
||||
}
|
||||
|
||||
func streamsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
src := query.Get("src")
|
||||
|
||||
// without source - return all streams list
|
||||
if src == "" && r.Method != "POST" {
|
||||
api.ResponseJSON(w, streams)
|
||||
return
|
||||
}
|
||||
|
||||
// Not sure about all this API. Should be rewrited...
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
api.ResponsePrettyJSON(w, streams[src])
|
||||
|
||||
case "PUT":
|
||||
name := query.Get("name")
|
||||
if name == "" {
|
||||
name = src
|
||||
}
|
||||
|
||||
if New(name, src) == nil {
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.PatchConfig(name, src, "streams"); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
|
||||
case "PATCH":
|
||||
name := query.Get("name")
|
||||
if name == "" {
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// support {input} templates: https://github.com/AlexxIT/go2rtc#module-hass
|
||||
if Patch(name, src) == nil {
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
case "POST":
|
||||
// with dst - redirect source to dst
|
||||
if dst := query.Get("dst"); dst != "" {
|
||||
if stream := Get(dst); stream != nil {
|
||||
if err := stream.Play(src); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
} else {
|
||||
api.ResponseJSON(w, stream)
|
||||
}
|
||||
} else if stream = Get(src); stream != nil {
|
||||
if err := stream.Publish(dst); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
} else {
|
||||
http.Error(w, "", http.StatusNotFound)
|
||||
}
|
||||
} else {
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
case "DELETE":
|
||||
delete(streams, src)
|
||||
|
||||
if err := app.PatchConfig(src, nil, "streams"); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
var streams = map[string]*Stream{}
|
||||
var streamsMu sync.Mutex
|
||||
|
@@ -8,11 +8,11 @@ import (
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("kasa", func(url string) (core.Producer, error) {
|
||||
return kasa.Dial(url)
|
||||
streams.HandleFunc("kasa", func(source string) (core.Producer, error) {
|
||||
return kasa.Dial(source)
|
||||
})
|
||||
|
||||
streams.HandleFunc("tapo", func(url string) (core.Producer, error) {
|
||||
return tapo.Dial(url)
|
||||
streams.HandleFunc("tapo", func(source string) (core.Producer, error) {
|
||||
return tapo.Dial(source)
|
||||
})
|
||||
}
|
||||
|
@@ -1,13 +1,105 @@
|
||||
What you should to know about WebRTC:
|
||||
|
||||
- It's almost always a **direct [peer-to-peer](https://en.wikipedia.org/wiki/Peer-to-peer) connection** from your browser to go2rtc app
|
||||
- When you use Home Assistant, Frigate, Nginx, Nabu Casa, Cloudflare and other software - they are only **involved in establishing** the connection, but they are **not involved in transferring** media data
|
||||
- WebRTC media cannot be transferred inside an HTTP connection
|
||||
- Usually, WebRTC uses random UDP ports on client and server side to establish a connection
|
||||
- Usually, WebRTC uses public [STUN](https://en.wikipedia.org/wiki/STUN) servers to establish a connection outside LAN, such servers are only needed to establish a connection and are not involved in data transfer
|
||||
- Usually, WebRTC will automatically discover all of your local addresses and all of your public addresses and try to establish a connection
|
||||
|
||||
If an external connection via STUN is used:
|
||||
|
||||
- Uses [UDP hole punching](https://en.wikipedia.org/wiki/UDP_hole_punching) technology to bypass NAT even if you not open your server to the World
|
||||
- For about 20% of users, the techology will not work because of the [Symmetric NAT](https://tomchen.github.io/symmetric-nat-test/)
|
||||
- UDP is not suitable for transmitting 2K and 4K high bitrate video over open networks because of the high loss rate
|
||||
|
||||
## Default config
|
||||
|
||||
```yaml
|
||||
webrtc:
|
||||
listen: ":8555/tcp"
|
||||
ice_servers:
|
||||
- urls: [ "stun:stun.l.google.com:19302" ]
|
||||
```
|
||||
|
||||
## Config
|
||||
|
||||
- supported TCP: fixed port (default), disabled
|
||||
- supported UDP: random port (default), fixed port
|
||||
**Important!** This example is not for copypasting!
|
||||
|
||||
| Config examples | TCP | UDP |
|
||||
|-----------------------|-------|--------|
|
||||
| `listen: ":8555/tcp"` | fixed | random |
|
||||
| `listen: ":8555"` | fixed | fixed |
|
||||
| `listen: ""` | no | random |
|
||||
```yaml
|
||||
webrtc:
|
||||
# fix local TCP or UDP or both ports for WebRTC media
|
||||
listen: ":8555/tcp" # address of your local server
|
||||
|
||||
# add additional host candidates manually
|
||||
# order is important, the first will have a higher priority
|
||||
candidates:
|
||||
- 216.58.210.174:8555 # if you have static public IP-address
|
||||
- stun:8555 # if you have dynamic public IP-address
|
||||
- home.duckdns.org:8555 # if you have domain
|
||||
|
||||
# add custom STUN and TURN servers
|
||||
# use `ice_servers: []` for remove defaults and leave empty
|
||||
ice_servers:
|
||||
- urls: [ stun:stun1.l.google.com:19302 ]
|
||||
- urls: [ turn:123.123.123.123:3478 ]
|
||||
username: your_user
|
||||
credential: your_pass
|
||||
|
||||
# optional filter list for auto discovery logic
|
||||
# some settings only make sense if you don't specify a fixed UDP port
|
||||
filters:
|
||||
# list of host candidates from auto discovery to be sent
|
||||
# including candidates from the `listen` option
|
||||
# use `candidates: []` to remove all auto discovery candidates
|
||||
candidates: [ 192.168.1.123 ]
|
||||
|
||||
# list of network types to be used for connection
|
||||
# including candidates from the `listen` option
|
||||
networks: [ udp4, udp6, tcp4, tcp6 ]
|
||||
|
||||
# list of interfaces to be used for connection
|
||||
# not related to the `listen` option
|
||||
interfaces: [ eno1 ]
|
||||
|
||||
# list of host IP-addresses to be used for connection
|
||||
# not related to the `listen` option
|
||||
ips: [ 192.168.1.123 ]
|
||||
|
||||
# range for random UDP ports [min, max] to be used for connection
|
||||
# not related to the `listen` option
|
||||
udp_ports: [ 50000, 50100 ]
|
||||
```
|
||||
|
||||
By default go2rtc uses **fixed TCP** port and multiple **random UDP** ports for each WebRTC connection - `listen: ":8555/tcp"`.
|
||||
|
||||
You can set **fixed TCP** and **fixed UDP** port for all connections - `listen: ":8555"`. This may has lower performance, but it's your choice.
|
||||
|
||||
Don't know why, but you can disable TCP port and leave only random UDP ports - `listen: ""`.
|
||||
|
||||
## Config filters
|
||||
|
||||
Filters allow you to exclude unnecessary candidates. Extra candidates don't make your connection worse or better. But the wrong filter settings can break everything. Skip this setting if you don't understand it.
|
||||
|
||||
For example, go2rtc is installed on the host system. And there are unnecessary interfaces. You can keep only the relevant via `interfaces` or `ips` options. You can also exclude IPv6 candidates if your server supports them but your home network does not.
|
||||
|
||||
```yaml
|
||||
webrtc:
|
||||
listen: ":8555/tcp" # use fixed TCP port and random UDP ports
|
||||
filters:
|
||||
ips: [ 192.168.1.2 ] # IP-address of your server
|
||||
networks: [ udp4, tcp4 ] # skip IPv6, if it's not supported for you
|
||||
```
|
||||
|
||||
For example, go2rtc inside closed docker container (ex. [Frigate](https://frigate.video/)). You shouldn't filter docker interfaces, otherwise go2rtc will not be able to connect anywhere. But you can filter the docker candidates because no one can connect to them.
|
||||
|
||||
```yaml
|
||||
webrtc:
|
||||
listen: ":8555" # use fixed TCP and UDP ports
|
||||
candidates: [ 192.168.1.2:8555 ] # add manual host candidate (use docker port forwarding)
|
||||
filters:
|
||||
candidates: [] # skip all internal docker candidates
|
||||
```
|
||||
|
||||
## Userful links
|
||||
|
||||
|
@@ -2,57 +2,60 @@ package webrtc
|
||||
|
||||
import (
|
||||
"net"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
"github.com/pion/sdp/v3"
|
||||
pion "github.com/pion/webrtc/v3"
|
||||
)
|
||||
|
||||
type Address struct {
|
||||
Host string
|
||||
Port string
|
||||
Network string
|
||||
Offset int
|
||||
host string
|
||||
Port string
|
||||
Network string
|
||||
Priority uint32
|
||||
}
|
||||
|
||||
func (a *Address) Marshal() string {
|
||||
host := a.Host
|
||||
if host == "stun" {
|
||||
func (a *Address) Host() string {
|
||||
if a.host == "stun" {
|
||||
ip, err := webrtc.GetCachedPublicIP()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
host = ip.String()
|
||||
return ip.String()
|
||||
}
|
||||
return a.host
|
||||
}
|
||||
|
||||
switch a.Network {
|
||||
case "udp":
|
||||
return webrtc.CandidateManualHostUDP(host, a.Port, a.Offset)
|
||||
case "tcp":
|
||||
return webrtc.CandidateManualHostTCPPassive(host, a.Port, a.Offset)
|
||||
func (a *Address) Marshal() string {
|
||||
if host := a.Host(); host != "" {
|
||||
return webrtc.CandidateICE(a.Network, host, a.Port, a.Priority)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
var addresses []*Address
|
||||
var filters webrtc.Filters
|
||||
|
||||
func AddCandidate(network, address string) {
|
||||
if network == "" {
|
||||
AddCandidate("tcp", address)
|
||||
AddCandidate("udp", address)
|
||||
return
|
||||
}
|
||||
|
||||
func AddCandidate(address, network string) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
offset := -1 - len(addresses) // every next candidate will have a lower priority
|
||||
// start from 1, so manual candidates will be lower than built-in
|
||||
// and every next candidate will have a lower priority
|
||||
candidateIndex := 1 + len(addresses)
|
||||
|
||||
switch network {
|
||||
case "tcp", "udp":
|
||||
addresses = append(addresses, &Address{host, port, network, offset})
|
||||
default:
|
||||
addresses = append(
|
||||
addresses, &Address{host, port, "udp", offset}, &Address{host, port, "tcp", offset},
|
||||
)
|
||||
}
|
||||
priority := webrtc.CandidateHostPriority(network, candidateIndex)
|
||||
addresses = append(addresses, &Address{host, port, network, priority})
|
||||
}
|
||||
|
||||
func GetCandidates() (candidates []string) {
|
||||
@@ -64,6 +67,38 @@ func GetCandidates() (candidates []string) {
|
||||
return
|
||||
}
|
||||
|
||||
// FilterCandidate return true if candidate passed the check
|
||||
func FilterCandidate(candidate *pion.ICECandidate) bool {
|
||||
if candidate == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// host candidate should be in the hosts list
|
||||
if candidate.Typ == pion.ICECandidateTypeHost && filters.Candidates != nil {
|
||||
if !slices.Contains(filters.Candidates, candidate.Address) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if filters.Networks != nil {
|
||||
networkType := NetworkType(candidate.Protocol.String(), candidate.Address)
|
||||
if !slices.Contains(filters.Networks, networkType) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// NetworkType convert tcp/udp network to tcp4/tcp6/udp4/udp6
|
||||
func NetworkType(network, host string) string {
|
||||
if strings.IndexByte(host, ':') >= 0 {
|
||||
return network + "6"
|
||||
} else {
|
||||
return network + "4"
|
||||
}
|
||||
}
|
||||
|
||||
func asyncCandidates(tr *ws.Transport, cons *webrtc.Conn) {
|
||||
tr.WithContext(func(ctx map[any]any) {
|
||||
if candidates, ok := ctx["candidate"].([]string); ok {
|
||||
@@ -86,30 +121,6 @@ func asyncCandidates(tr *ws.Transport, cons *webrtc.Conn) {
|
||||
}
|
||||
}
|
||||
|
||||
func syncCanditates(answer string) (string, error) {
|
||||
if len(addresses) == 0 {
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
sd := &sdp.SessionDescription{}
|
||||
if err := sd.Unmarshal([]byte(answer)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
md := sd.MediaDescriptions[0]
|
||||
|
||||
for _, candidate := range GetCandidates() {
|
||||
md.WithPropertyAttribute(candidate)
|
||||
}
|
||||
|
||||
data, err := sd.Marshal()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func candidateHandler(tr *ws.Transport, msg *ws.Message) error {
|
||||
// process incoming candidate in sync function
|
||||
tr.WithContext(func(ctx map[any]any) {
|
||||
|
@@ -41,7 +41,7 @@ func streamsHandler(rawURL string) (core.Producer, error) {
|
||||
// https://aws.amazon.com/kinesis/video-streams/
|
||||
// https://docs.aws.amazon.com/kinesisvideostreams-webrtc-dg/latest/devguide/what-is-kvswebrtc.html
|
||||
// https://github.com/orgs/awslabs/repositories?q=kinesis+webrtc
|
||||
return kinesisClient(rawURL, query, "WebRTC/Kinesis")
|
||||
return kinesisClient(rawURL, query, "webrtc/kinesis")
|
||||
} else if format == "openipc" {
|
||||
return openIPCClient(rawURL, query)
|
||||
} else {
|
||||
@@ -77,17 +77,23 @@ func go2rtcClient(url string) (core.Producer, error) {
|
||||
// 2. Create PeerConnection
|
||||
pc, err := PeerConnection(true)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = pc.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// waiter will wait PC error or WS error or nil (connection OK)
|
||||
var connState core.Waiter
|
||||
var connMu sync.Mutex
|
||||
|
||||
prod := webrtc.NewConn(pc)
|
||||
prod.Desc = "WebRTC/WebSocket async"
|
||||
prod.Mode = core.ModeActiveProducer
|
||||
prod.Protocol = "ws"
|
||||
prod.URL = url
|
||||
prod.Listen(func(msg any) {
|
||||
switch msg := msg.(type) {
|
||||
case *pion.ICECandidate:
|
||||
@@ -132,7 +138,8 @@ func go2rtcClient(url string) (core.Producer, error) {
|
||||
}
|
||||
|
||||
if msg.Type != "webrtc/answer" {
|
||||
return nil, errors.New("wrong answer: " + msg.Type)
|
||||
err = errors.New("wrong answer: " + msg.String())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
answer := msg.String()
|
||||
@@ -180,8 +187,9 @@ func whepClient(url string) (core.Producer, error) {
|
||||
}
|
||||
|
||||
prod := webrtc.NewConn(pc)
|
||||
prod.Desc = "WebRTC/WHEP sync"
|
||||
prod.Mode = core.ModeActiveProducer
|
||||
prod.Protocol = "http"
|
||||
prod.URL = url
|
||||
|
||||
medias := []*core.Media{
|
||||
{Kind: core.KindVideo, Direction: core.DirectionRecvonly},
|
||||
|
@@ -34,7 +34,7 @@ func (k kinesisResponse) String() string {
|
||||
return fmt.Sprintf("type=%s, payload=%s", k.Type, k.Payload)
|
||||
}
|
||||
|
||||
func kinesisClient(rawURL string, query url.Values, desc string) (core.Producer, error) {
|
||||
func kinesisClient(rawURL string, query url.Values, format string) (core.Producer, error) {
|
||||
// 1. Connect to signalign server
|
||||
conn, _, err := websocket.DefaultDialer.Dial(rawURL, nil)
|
||||
if err != nil {
|
||||
@@ -79,8 +79,10 @@ func kinesisClient(rawURL string, query url.Values, desc string) (core.Producer,
|
||||
}
|
||||
|
||||
prod := webrtc.NewConn(pc)
|
||||
prod.Desc = desc
|
||||
prod.FormatName = format
|
||||
prod.Mode = core.ModeActiveProducer
|
||||
prod.Protocol = "ws"
|
||||
prod.URL = rawURL
|
||||
prod.Listen(func(msg any) {
|
||||
switch msg := msg.(type) {
|
||||
case *pion.ICECandidate:
|
||||
@@ -216,5 +218,5 @@ func wyzeClient(rawURL string) (core.Producer, error) {
|
||||
"ice_servers": []string{string(kvs.Servers)},
|
||||
}
|
||||
|
||||
return kinesisClient(kvs.URL, query, "WebRTC/Wyze")
|
||||
return kinesisClient(kvs.URL, query, "webrtc/wyze")
|
||||
}
|
||||
|
@@ -193,8 +193,10 @@ func milestoneClient(rawURL string, query url.Values) (core.Producer, error) {
|
||||
}
|
||||
|
||||
prod := webrtc.NewConn(pc)
|
||||
prod.Desc = "WebRTC/Milestone"
|
||||
prod.FormatName = "webrtc/milestone"
|
||||
prod.Mode = core.ModeActiveProducer
|
||||
prod.Protocol = "http"
|
||||
prod.URL = rawURL
|
||||
|
||||
offer, err := mc.GetOffer()
|
||||
if err != nil {
|
||||
|
@@ -53,8 +53,10 @@ func openIPCClient(rawURL string, query url.Values) (core.Producer, error) {
|
||||
var connState core.Waiter
|
||||
|
||||
prod := webrtc.NewConn(pc)
|
||||
prod.Desc = "WebRTC/OpenIPC"
|
||||
prod.FormatName = "webrtc/openipc"
|
||||
prod.Mode = core.ModeActiveProducer
|
||||
prod.Protocol = "ws"
|
||||
prod.URL = rawURL
|
||||
prod.Listen(func(msg any) {
|
||||
switch msg := msg.(type) {
|
||||
case *pion.ICECandidate:
|
||||
|
@@ -65,6 +65,7 @@ func outputWebRTC(w http.ResponseWriter, r *http.Request) {
|
||||
url := r.URL.Query().Get("src")
|
||||
stream := streams.Get(url)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -100,11 +101,11 @@ func outputWebRTC(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
switch mediaType {
|
||||
case "application/json":
|
||||
desc = "WebRTC/JSON sync"
|
||||
desc = "webrtc/json"
|
||||
case MimeSDP:
|
||||
desc = "WebRTC/WHEP sync"
|
||||
desc = "webrtc/whep"
|
||||
default:
|
||||
desc = "WebRTC/HTTP sync"
|
||||
desc = "webrtc/post"
|
||||
}
|
||||
|
||||
answer, err := ExchangeSDP(stream, offer, desc, r.UserAgent())
|
||||
@@ -168,8 +169,8 @@ func inputWebRTC(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// create new webrtc instance
|
||||
prod := webrtc.NewConn(pc)
|
||||
prod.Desc = "WebRTC/WHIP sync"
|
||||
prod.Mode = core.ModePassiveProducer
|
||||
prod.Protocol = "http"
|
||||
prod.UserAgent = r.UserAgent()
|
||||
|
||||
if err = prod.SetOffer(string(offer)); err != nil {
|
||||
@@ -178,10 +179,7 @@ func inputWebRTC(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
answer, err := prod.GetCompleteAnswer()
|
||||
if err == nil {
|
||||
answer, err = syncCanditates(answer)
|
||||
}
|
||||
answer, err := prod.GetCompleteAnswer(GetCandidates(), FilterCandidate)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
@@ -20,6 +20,7 @@ func Init() {
|
||||
Listen string `yaml:"listen"`
|
||||
Candidates []string `yaml:"candidates"`
|
||||
IceServers []pion.ICEServer `yaml:"ice_servers"`
|
||||
Filters webrtc.Filters `yaml:"filters"`
|
||||
} `yaml:"webrtc"`
|
||||
}
|
||||
|
||||
@@ -32,20 +33,15 @@ func Init() {
|
||||
|
||||
log = app.GetLogger("webrtc")
|
||||
|
||||
filters = cfg.Mod.Filters
|
||||
|
||||
address, network, _ := strings.Cut(cfg.Mod.Listen, "/")
|
||||
|
||||
var candidateHost []string
|
||||
for _, candidate := range cfg.Mod.Candidates {
|
||||
if strings.HasPrefix(candidate, "host:") {
|
||||
candidateHost = append(candidateHost, candidate[5:])
|
||||
continue
|
||||
}
|
||||
|
||||
AddCandidate(candidate, network)
|
||||
AddCandidate(network, candidate)
|
||||
}
|
||||
|
||||
// create pionAPI with custom codecs list and custom network settings
|
||||
serverAPI, err := webrtc.NewServerAPI(address, network, candidateHost)
|
||||
serverAPI, err := webrtc.NewServerAPI(network, address, &filters)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
@@ -55,8 +51,7 @@ func Init() {
|
||||
clientAPI := serverAPI
|
||||
|
||||
if address != "" {
|
||||
log.Info().Str("addr", address).Msg("[webrtc] listen")
|
||||
|
||||
log.Info().Str("addr", cfg.Mod.Listen).Msg("[webrtc] listen")
|
||||
clientAPI, _ = webrtc.NewAPI()
|
||||
}
|
||||
|
||||
@@ -122,8 +117,8 @@ func asyncHandler(tr *ws.Transport, msg *ws.Message) error {
|
||||
defer sendAnswer.Done(nil)
|
||||
|
||||
conn := webrtc.NewConn(pc)
|
||||
conn.Desc = "WebRTC/WebSocket async"
|
||||
conn.Mode = mode
|
||||
conn.Protocol = "ws"
|
||||
conn.UserAgent = tr.Request.UserAgent()
|
||||
conn.Listen(func(msg any) {
|
||||
switch msg := msg.(type) {
|
||||
@@ -139,6 +134,9 @@ func asyncHandler(tr *ws.Transport, msg *ws.Message) error {
|
||||
}
|
||||
|
||||
case *pion.ICECandidate:
|
||||
if !FilterCandidate(msg) {
|
||||
return
|
||||
}
|
||||
_ = sendAnswer.Wait()
|
||||
|
||||
s := msg.ToJSON().Candidate
|
||||
@@ -209,8 +207,9 @@ func ExchangeSDP(stream *streams.Stream, offer, desc, userAgent string) (answer
|
||||
|
||||
// create new webrtc instance
|
||||
conn := webrtc.NewConn(pc)
|
||||
conn.Desc = desc
|
||||
conn.FormatName = desc
|
||||
conn.UserAgent = userAgent
|
||||
conn.Protocol = "http"
|
||||
conn.Listen(func(msg any) {
|
||||
switch msg := msg.(type) {
|
||||
case pion.PeerConnectionState:
|
||||
@@ -248,10 +247,7 @@ func ExchangeSDP(stream *streams.Stream, offer, desc, userAgent string) (answer
|
||||
stream.AddProducer(conn)
|
||||
}
|
||||
|
||||
answer, err = conn.GetCompleteAnswer()
|
||||
if err == nil {
|
||||
answer, err = syncCanditates(answer)
|
||||
}
|
||||
answer, err = conn.GetCompleteAnswer(GetCandidates(), FilterCandidate)
|
||||
log.Trace().Msgf("[webrtc] answer\n%s", answer)
|
||||
|
||||
if err != nil {
|
||||
|
@@ -47,7 +47,7 @@ func Init() {
|
||||
if stream == nil {
|
||||
return "", errors.New(api.StreamNotFound)
|
||||
}
|
||||
return webrtc.ExchangeSDP(stream, offer, "WebRTC/WebTorrent sync", "")
|
||||
return webrtc.ExchangeSDP(stream, offer, "webtorrent", "")
|
||||
},
|
||||
}
|
||||
|
||||
|
2
main.go
2
main.go
@@ -36,6 +36,8 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
app.Version = "1.9.4"
|
||||
|
||||
// 1. Core modules: app, api/ws, streams
|
||||
|
||||
app.Init() // init config and logs
|
||||
|
@@ -1,3 +1,85 @@
|
||||
# Notes
|
||||
|
||||
go2rtc tries to name formats, protocols and codecs the same way they are named in FFmpeg.
|
||||
Some formats and protocols go2rtc supports exclusively. They have no equivalent in FFmpeg.
|
||||
|
||||
## Producers (input)
|
||||
|
||||
- The initiator of the connection can be go2rtc - **Source protocols**
|
||||
- The initiator of the connection can be an external program - **Ingress protocols**
|
||||
- Codecs can be incoming - **Recevers codecs**
|
||||
- Codecs can be outgoing (two way audio) - **Senders codecs**
|
||||
|
||||
| Format | Source protocols | Ingress protocols | Recevers codecs | Senders codecs | Example |
|
||||
|--------------|------------------|-------------------|------------------------------|--------------------|---------------|
|
||||
| adts | http,tcp,pipe | http | aac | | `http:` |
|
||||
| bubble | http | | h264,hevc,pcm_alaw | | `bubble:` |
|
||||
| dvrip | tcp | | h264,hevc,pcm_alaw,pcm_mulaw | pcm_alaw | `dvrip:` |
|
||||
| flv | http,tcp,pipe | http | h264,aac | | `http:` |
|
||||
| gopro | http+udp | | TODO | | `gopro:` |
|
||||
| hass/webrtc | ws+udp,tcp | | TODO | | `hass:` |
|
||||
| hls/mpegts | http | | h264,h265,aac,opus | | `http:` |
|
||||
| homekit | homekit+udp | | h264,eld* | | `homekit:` |
|
||||
| isapi | http | | | pcm_alaw,pcm_mulaw | `isapi:` |
|
||||
| ivideon | ws | | h264 | | `ivideon:` |
|
||||
| kasa | http | | h264,pcm_mulaw | | `kasa:` |
|
||||
| h264 | http,tcp,pipe | http | h264 | | `http:` |
|
||||
| hevc | http,tcp,pipe | http | hevc | | `http:` |
|
||||
| mjpeg | http,tcp,pipe | http | mjpeg | | `http:` |
|
||||
| mpjpeg | http,tcp,pipe | http | mjpeg | | `http:` |
|
||||
| mpegts | http,tcp,pipe | http | h264,hevc,aac,opus | | `http:` |
|
||||
| nest/webrtc | http+udp | | TODO | | `nest:` |
|
||||
| roborock | mqtt+udp | | h264,opus | opus | `roborock:` |
|
||||
| rtmp | rtmp | rtmp | h264,aac | | `rtmp:` |
|
||||
| rtsp | rtsp+tcp,ws | rtsp+tcp | h264,hevc,aac,pcm*,opus | pcm*,opus | `rtsp:` |
|
||||
| stdin | pipe | | | pcm_alaw,pcm_mulaw | `stdin:` |
|
||||
| tapo | http | | h264,pcma | pcm_alaw | `tapo:` |
|
||||
| wav | http,tcp,pipe | http | pcm_alaw,pcm_mulaw | | `http:` |
|
||||
| webrtc* | TODO | TODO | h264,pcm_alaw,pcm_mulaw,opus | pcm_alaw,pcm_mulaw | `webrtc:` |
|
||||
| webtorrent | TODO | TODO | TODO | TODO | `webtorrent:` |
|
||||
| yuv4mpegpipe | http,tcp,pipe | http | rawvideo | | `http:` |
|
||||
|
||||
- **eld** - rare variant of aac codec
|
||||
- **pcm** - pcm_alaw pcm_mulaw pcm_s16be pcm_s16le
|
||||
- **webrtc** - webrtc/kinesis, webrtc/openipc, webrtc/milestone, webrtc/wyze, webrtc/whep
|
||||
|
||||
## Consumers (output)
|
||||
|
||||
| Format | Protocol | Send codecs | Recv codecs | Example |
|
||||
|--------------|-------------|------------------------------|-------------------------|---------------------------------------|
|
||||
| adts | http | aac | | `GET /api/stream.adts` |
|
||||
| ascii | http | mjpeg | | `GET /api/stream.ascii` |
|
||||
| flv | http | h264,aac | | `GET /api/stream.flv` |
|
||||
| hls/mpegts | http | h264,hevc,aac | | `GET /api/stream.m3u8` |
|
||||
| hls/fmp4 | http | h264,hevc,aac,pcm*,opus | | `GET /api/stream.m3u8?mp4` |
|
||||
| homekit | homekit+udp | h264,opus | | Apple HomeKit app |
|
||||
| mjpeg | ws | mjpeg | | `{"type":"mjpeg"}` -> `/api/ws` |
|
||||
| mpjpeg | http | mjpeg | | `GET /api/stream.mjpeg` |
|
||||
| mp4 | http | h264,hevc,aac,pcm*,opus | | `GET /api/stream.mp4` |
|
||||
| mse/fmp4 | ws | h264,hevc,aac,pcm*,opus | | `{"type":"mse"}` -> `/api/ws` |
|
||||
| mpegts | http | h264,hevc,aac | | `GET /api/stream.ts` |
|
||||
| rtmp | rtmp | h264,aac | | `rtmp://localhost:1935/{stream_name}` |
|
||||
| rtsp | rtsp+tcp | h264,hevc,aac,pcm*,opus | | `rtsp://localhost:8554/{stream_name}` |
|
||||
| webrtc | TODO | h264,pcm_alaw,pcm_mulaw,opus | pcm_alaw,pcm_mulaw,opus | `{"type":"webrtc"}` -> `/api/ws` |
|
||||
| yuv4mpegpipe | http | rawvideo | | `GET /api/stream.y4m` |
|
||||
|
||||
- **pcm** - pcm_alaw pcm_mulaw pcm_s16be pcm_s16le
|
||||
|
||||
## Snapshots
|
||||
|
||||
| Format | Protocol | Send codecs | Example |
|
||||
|--------|----------|-------------|-----------------------|
|
||||
| jpeg | http | mjpeg | `GET /api/frame.jpeg` |
|
||||
| mp4 | http | h264,hevc | `GET /api/frame.mp4` |
|
||||
|
||||
## Developers
|
||||
|
||||
File naming:
|
||||
|
||||
- `pkg/{format}/producer.go` - producer for this format (also if support backchannel)
|
||||
- `pkg/{format}/consumer.go` - consumer for this format
|
||||
- `pkg/{format}/backchanel.go` - producer with only backchannel func
|
||||
|
||||
## Useful links
|
||||
|
||||
- https://www.wowza.com/blog/streaming-protocols
|
||||
|
@@ -69,6 +69,8 @@ func DecodeConfig(b []byte) (objType, sampleFreqIdx, channels byte, sampleRate u
|
||||
sampleFreqIdx = rd.ReadBits8(4)
|
||||
if sampleFreqIdx == 0b1111 {
|
||||
sampleRate = rd.ReadBits(24)
|
||||
} else {
|
||||
sampleRate = sampleRates[sampleFreqIdx]
|
||||
}
|
||||
|
||||
channels = rd.ReadBits8(4)
|
||||
|
@@ -41,3 +41,12 @@ func TestADTS(t *testing.T) {
|
||||
|
||||
require.Equal(t, src[:len(dst)], dst)
|
||||
}
|
||||
|
||||
func TestEncodeConfig(t *testing.T) {
|
||||
conf := EncodeConfig(TypeAACLC, 48000, 1, false)
|
||||
require.Equal(t, "1188", hex.EncodeToString(conf))
|
||||
conf = EncodeConfig(TypeAACLC, 16000, 1, false)
|
||||
require.Equal(t, "1408", hex.EncodeToString(conf))
|
||||
conf = EncodeConfig(TypeAACLC, 8000, 1, false)
|
||||
require.Equal(t, "1588", hex.EncodeToString(conf))
|
||||
}
|
||||
|
@@ -8,15 +8,12 @@ import (
|
||||
)
|
||||
|
||||
type Consumer struct {
|
||||
core.SuperConsumer
|
||||
core.Connection
|
||||
wr *core.WriteBuffer
|
||||
}
|
||||
|
||||
func NewConsumer() *Consumer {
|
||||
cons := &Consumer{
|
||||
wr: core.NewWriteBuffer(nil),
|
||||
}
|
||||
cons.Medias = []*core.Media{
|
||||
medias := []*core.Media{
|
||||
{
|
||||
Kind: core.KindAudio,
|
||||
Direction: core.DirectionSendonly,
|
||||
@@ -25,7 +22,16 @@ func NewConsumer() *Consumer {
|
||||
},
|
||||
},
|
||||
}
|
||||
return cons
|
||||
wr := core.NewWriteBuffer(nil)
|
||||
return &Consumer{
|
||||
Connection: core.Connection{
|
||||
ID: core.NewID(),
|
||||
FormatName: "adts",
|
||||
Medias: medias,
|
||||
Transport: wr,
|
||||
},
|
||||
wr: wr,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Consumer) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error {
|
||||
@@ -51,8 +57,3 @@ func (c *Consumer) AddTrack(media *core.Media, codec *core.Codec, track *core.Re
|
||||
func (c *Consumer) WriteTo(wr io.Writer) (int64, error) {
|
||||
return c.wr.WriteTo(wr)
|
||||
}
|
||||
|
||||
func (c *Consumer) Stop() error {
|
||||
_ = c.SuperConsumer.Close()
|
||||
return c.wr.Close()
|
||||
}
|
||||
|
@@ -10,9 +10,8 @@ import (
|
||||
)
|
||||
|
||||
type Producer struct {
|
||||
core.SuperProducer
|
||||
core.Connection
|
||||
rd *bufio.Reader
|
||||
cl io.Closer
|
||||
}
|
||||
|
||||
func Open(r io.Reader) (*Producer, error) {
|
||||
@@ -23,18 +22,22 @@ func Open(r io.Reader) (*Producer, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
codec := ADTSToCodec(b)
|
||||
|
||||
prod := &Producer{rd: rd, cl: r.(io.Closer)}
|
||||
prod.Type = "ADTS producer"
|
||||
prod.Medias = []*core.Media{
|
||||
medias := []*core.Media{
|
||||
{
|
||||
Kind: core.KindAudio,
|
||||
Direction: core.DirectionRecvonly,
|
||||
Codecs: []*core.Codec{codec},
|
||||
Codecs: []*core.Codec{ADTSToCodec(b)},
|
||||
},
|
||||
}
|
||||
return prod, nil
|
||||
return &Producer{
|
||||
Connection: core.Connection{
|
||||
ID: core.NewID(),
|
||||
FormatName: "adts",
|
||||
Medias: medias,
|
||||
Transport: r,
|
||||
},
|
||||
rd: rd,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Producer) Start() error {
|
||||
@@ -66,8 +69,3 @@ func (c *Producer) Start() error {
|
||||
c.Receivers[0].WriteRTP(pkt)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Producer) Stop() error {
|
||||
_ = c.SuperProducer.Close()
|
||||
return c.cl.Close()
|
||||
}
|
||||
|
6
pkg/ascii/README.md
Normal file
6
pkg/ascii/README.md
Normal file
@@ -0,0 +1,6 @@
|
||||
## Useful links
|
||||
|
||||
- https://en.wikipedia.org/wiki/ANSI_escape_code
|
||||
- https://paulbourke.net/dataformats/asciiart/
|
||||
- https://github.com/kutuluk/xterm-color-chart
|
||||
- https://github.com/hugomd/parrot.live
|
173
pkg/ascii/ascii.go
Normal file
173
pkg/ascii/ascii.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package ascii
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"net/http"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func NewWriter(w io.Writer, foreground, background, text string) io.Writer {
|
||||
// once clear screen
|
||||
_, _ = w.Write([]byte(csiClear))
|
||||
|
||||
// every frame - move to home
|
||||
a := &writer{wr: w, buf: []byte(csiHome)}
|
||||
|
||||
// https://en.wikipedia.org/wiki/ANSI_escape_code
|
||||
switch foreground {
|
||||
case "":
|
||||
case "8":
|
||||
a.color = func(r, g, b uint8) {
|
||||
idx := xterm256color(r, g, b, 8)
|
||||
a.appendEsc(fmt.Sprintf("\033[%dm", 30+idx))
|
||||
|
||||
}
|
||||
case "256":
|
||||
a.color = func(r, g, b uint8) {
|
||||
idx := xterm256color(r, g, b, 255)
|
||||
a.appendEsc(fmt.Sprintf("\033[38;5;%dm", idx))
|
||||
}
|
||||
case "rgb":
|
||||
a.color = func(r, g, b uint8) {
|
||||
a.appendEsc(fmt.Sprintf("\033[38;2;%d;%d;%dm", r, g, b))
|
||||
}
|
||||
default:
|
||||
a.buf = append(a.buf, "\033["+foreground+"m"...)
|
||||
}
|
||||
|
||||
switch background {
|
||||
case "":
|
||||
case "8":
|
||||
a.color = func(r, g, b uint8) {
|
||||
idx := xterm256color(r, g, b, 8)
|
||||
a.appendEsc(fmt.Sprintf("\033[%dm", 40+idx))
|
||||
}
|
||||
case "256":
|
||||
a.color = func(r, g, b uint8) {
|
||||
idx := xterm256color(r, g, b, 255)
|
||||
a.appendEsc(fmt.Sprintf("\033[48;5;%dm", idx))
|
||||
}
|
||||
case "rgb":
|
||||
a.color = func(r, g, b uint8) {
|
||||
a.appendEsc(fmt.Sprintf("\033[48;2;%d;%d;%dm", r, g, b))
|
||||
}
|
||||
default:
|
||||
a.buf = append(a.buf, "\033["+background+"m"...)
|
||||
}
|
||||
|
||||
a.pre = len(a.buf) // save prefix size
|
||||
|
||||
if len(text) == 1 {
|
||||
// fast 1 symbol version
|
||||
a.text = func(_, _, _ uint32) {
|
||||
a.buf = append(a.buf, text[0])
|
||||
}
|
||||
} else {
|
||||
switch text {
|
||||
case "":
|
||||
text = ` .::--~~==++**##%%$@` // default for empty text
|
||||
case "block":
|
||||
text = " ░░▒▒▓▓█" // https://en.wikipedia.org/wiki/Block_Elements
|
||||
}
|
||||
|
||||
if runes := []rune(text); len(runes) != len(text) {
|
||||
k := float32(len(runes)-1) / 255
|
||||
a.text = func(r, g, b uint32) {
|
||||
i := gray(r, g, b, k)
|
||||
a.buf = utf8.AppendRune(a.buf, runes[i])
|
||||
}
|
||||
} else {
|
||||
k := float32(len(text)-1) / 255
|
||||
a.text = func(r, g, b uint32) {
|
||||
i := gray(r, g, b, k)
|
||||
a.buf = append(a.buf, text[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
type writer struct {
|
||||
wr io.Writer
|
||||
buf []byte
|
||||
pre int
|
||||
esc string
|
||||
color func(r, g, b uint8)
|
||||
text func(r, g, b uint32)
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/questions/37774983/clearing-the-screen-by-printing-a-character
|
||||
const csiClear = "\033[2J"
|
||||
const csiHome = "\033[H"
|
||||
|
||||
func (a *writer) Write(p []byte) (n int, err error) {
|
||||
img, err := jpeg.Decode(bytes.NewReader(p))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
a.buf = a.buf[:a.pre] // restore prefix
|
||||
|
||||
w := img.Bounds().Dx()
|
||||
h := img.Bounds().Dy()
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
r, g, b, _ := img.At(x, y).RGBA()
|
||||
if a.color != nil {
|
||||
a.color(uint8(r>>8), uint8(g>>8), uint8(b>>8))
|
||||
}
|
||||
a.text(r, g, b)
|
||||
}
|
||||
a.buf = append(a.buf, '\n')
|
||||
}
|
||||
|
||||
a.appendEsc("\033[0m")
|
||||
|
||||
if _, err = a.wr.Write(a.buf); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
a.wr.(http.Flusher).Flush()
|
||||
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// appendEsc - append ESC code to buffer, and skip duplicates
|
||||
func (a *writer) appendEsc(s string) {
|
||||
if a.esc != s {
|
||||
a.esc = s
|
||||
a.buf = append(a.buf, s...)
|
||||
}
|
||||
}
|
||||
|
||||
func gray(r, g, b uint32, k float32) uint8 {
|
||||
gr := (19595*r + 38470*g + 7471*b + 1<<15) >> 24 // uint8
|
||||
return uint8(float32(gr) * k)
|
||||
}
|
||||
|
||||
const x256r = "\x00\x80\x00\x80\x00\x80\x00\xc0\x80\xff\x00\xff\x00\xff\x00\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x08\x12\x1c\x26\x30\x3a\x44\x4e\x58\x60\x66\x76\x80\x8a\x94\x9e\xa8\xb2\xbc\xc6\xd0\xda\xe4\xee"
|
||||
const x256g = "\x00\x00\x80\x80\x00\x00\x80\xc0\x80\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x08\x12\x1c\x26\x30\x3a\x44\x4e\x58\x60\x66\x76\x80\x8a\x94\x9e\xa8\xb2\xbc\xc6\xd0\xda\xe4\xee"
|
||||
const x256b = "\x00\x00\x00\x00\x80\x80\x80\xc0\x80\x00\x00\x00\xff\xff\xff\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x08\x12\x1c\x26\x30\x3a\x44\x4e\x58\x60\x66\x76\x80\x8a\x94\x9e\xa8\xb2\xbc\xc6\xd0\xda\xe4\xee"
|
||||
|
||||
func xterm256color(r, g, b uint8, n int) (index uint8) {
|
||||
best := uint16(0xFFFF)
|
||||
for i := 0; i < n; i++ {
|
||||
diff := sqDiff(r, x256r[i]) + sqDiff(g, x256g[i]) + sqDiff(b, x256b[i])
|
||||
if diff < best {
|
||||
best = diff
|
||||
index = uint8(i)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// sqDiff - just like from image/color/color.go
|
||||
func sqDiff(x, y uint8) uint16 {
|
||||
d := uint16(x - y)
|
||||
//return d
|
||||
return (d * d) >> 2
|
||||
}
|
@@ -131,3 +131,7 @@ func (r *Reader) ReadSEGolomb() int32 {
|
||||
func (r *Reader) Left() []byte {
|
||||
return r.buf[r.pos:]
|
||||
}
|
||||
|
||||
func (r *Reader) Pos() (int, byte) {
|
||||
return r.pos - 1, r.bits
|
||||
}
|
||||
|
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
// Deprecated: should be rewritten to core.Connection
|
||||
type Client struct {
|
||||
core.Listener
|
||||
|
||||
@@ -43,8 +44,12 @@ type Client struct {
|
||||
recv int
|
||||
}
|
||||
|
||||
func NewClient(url string) *Client {
|
||||
return &Client{url: url}
|
||||
func Dial(rawURL string) (*Client, error) {
|
||||
client := &Client{url: rawURL}
|
||||
if err := client.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
const (
|
||||
|
@@ -65,11 +65,16 @@ func (c *Client) Stop() error {
|
||||
}
|
||||
|
||||
func (c *Client) MarshalJSON() ([]byte, error) {
|
||||
info := &core.Info{
|
||||
Type: "Bubble active producer",
|
||||
Medias: c.medias,
|
||||
Recv: c.recv,
|
||||
Receivers: c.receivers,
|
||||
info := &core.Connection{
|
||||
ID: core.ID(c),
|
||||
FormatName: "bubble",
|
||||
Protocol: "http",
|
||||
Medias: c.medias,
|
||||
Recv: c.recv,
|
||||
Receivers: c.receivers,
|
||||
}
|
||||
if c.conn != nil {
|
||||
info.RemoteAddr = c.conn.RemoteAddr().String()
|
||||
}
|
||||
return json.Marshal(info)
|
||||
}
|
||||
|
@@ -2,8 +2,8 @@ package core
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
@@ -18,34 +18,76 @@ type Codec struct {
|
||||
PayloadType uint8
|
||||
}
|
||||
|
||||
func (c *Codec) String() string {
|
||||
s := fmt.Sprintf("%d %s", c.PayloadType, c.Name)
|
||||
// MarshalJSON - return FFprobe compatible output
|
||||
func (c *Codec) MarshalJSON() ([]byte, error) {
|
||||
info := map[string]any{}
|
||||
if name := FFmpegCodecName(c.Name); name != "" {
|
||||
info["codec_name"] = name
|
||||
info["codec_type"] = c.Kind()
|
||||
}
|
||||
if c.Name == CodecH264 {
|
||||
profile, level := DecodeH264(c.FmtpLine)
|
||||
if profile != "" {
|
||||
info["profile"] = profile
|
||||
info["level"] = level
|
||||
}
|
||||
}
|
||||
if c.ClockRate != 0 && c.ClockRate != 90000 {
|
||||
s = fmt.Sprintf("%s/%d", s, c.ClockRate)
|
||||
info["sample_rate"] = c.ClockRate
|
||||
}
|
||||
if c.Channels > 0 {
|
||||
s = fmt.Sprintf("%s/%d", s, c.Channels)
|
||||
info["channels"] = c.Channels
|
||||
}
|
||||
return s
|
||||
return json.Marshal(info)
|
||||
}
|
||||
|
||||
func (c *Codec) Text() string {
|
||||
switch c.Name {
|
||||
func FFmpegCodecName(name string) string {
|
||||
switch name {
|
||||
case CodecH264:
|
||||
if profile := DecodeH264(c.FmtpLine); profile != "" {
|
||||
return "H.264 " + profile
|
||||
}
|
||||
return c.Name
|
||||
return "h264"
|
||||
case CodecH265:
|
||||
return "hevc"
|
||||
case CodecJPEG:
|
||||
return "mjpeg"
|
||||
case CodecRAW:
|
||||
return "rawvideo"
|
||||
case CodecPCMA:
|
||||
return "pcm_alaw"
|
||||
case CodecPCMU:
|
||||
return "pcm_mulaw"
|
||||
case CodecPCM:
|
||||
return "pcm_s16be"
|
||||
case CodecPCML:
|
||||
return "pcm_s16le"
|
||||
case CodecAAC:
|
||||
return "aac"
|
||||
case CodecOpus:
|
||||
return "opus"
|
||||
case CodecVP8:
|
||||
return "vp8"
|
||||
case CodecVP9:
|
||||
return "vp9"
|
||||
case CodecAV1:
|
||||
return "av1"
|
||||
case CodecELD:
|
||||
return "aac/eld"
|
||||
case CodecFLAC:
|
||||
return "flac"
|
||||
case CodecMP3:
|
||||
return "mp3"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
s := c.Name
|
||||
func (c *Codec) String() (s string) {
|
||||
s = c.Name
|
||||
if c.ClockRate != 0 && c.ClockRate != 90000 {
|
||||
s += "/" + strconv.Itoa(int(c.ClockRate))
|
||||
s += fmt.Sprintf("/%d", c.ClockRate)
|
||||
}
|
||||
if c.Channels > 0 {
|
||||
s += "/" + strconv.Itoa(int(c.Channels))
|
||||
s += fmt.Sprintf("/%d", c.Channels)
|
||||
}
|
||||
return s
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Codec) IsRTP() bool {
|
||||
@@ -181,10 +223,9 @@ func UnmarshalCodec(md *sdp.MediaDescription, payloadType string) *Codec {
|
||||
return c
|
||||
}
|
||||
|
||||
func DecodeH264(fmtp string) string {
|
||||
func DecodeH264(fmtp string) (profile string, level byte) {
|
||||
if ps := Between(fmtp, "sprop-parameter-sets=", ","); ps != "" {
|
||||
if sps, _ := base64.StdEncoding.DecodeString(ps); len(sps) >= 4 {
|
||||
var profile string
|
||||
switch sps[1] {
|
||||
case 0x42:
|
||||
profile = "Baseline"
|
||||
@@ -198,8 +239,8 @@ func DecodeH264(fmtp string) string {
|
||||
profile = fmt.Sprintf("0x%02X", sps[1])
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s %d.%d", profile, sps[3]/10, sps[3]%10)
|
||||
level = sps[3]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return
|
||||
}
|
||||
|
139
pkg/core/connection.go
Normal file
139
pkg/core/connection.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
func NewID() uint32 {
|
||||
return id.Add(1)
|
||||
}
|
||||
|
||||
// Deprecated: use NewID instead
|
||||
func ID(v any) uint32 {
|
||||
p := uintptr(reflect.ValueOf(v).UnsafePointer())
|
||||
return 0x8000_0000 | uint32(p)
|
||||
}
|
||||
|
||||
var id atomic.Uint32
|
||||
|
||||
type Info interface {
|
||||
SetProtocol(string)
|
||||
SetRemoteAddr(string)
|
||||
SetSource(string)
|
||||
SetURL(string)
|
||||
WithRequest(*http.Request)
|
||||
}
|
||||
|
||||
// Connection just like webrtc.PeerConnection
|
||||
// - ID and RemoteAddr used for building Connection(s) graph
|
||||
// - FormatName, Protocol, RemoteAddr, Source, URL, SDP, UserAgent used for info about Connection
|
||||
// - FormatName and Protocol has FFmpeg compatible names
|
||||
// - Transport used for auto closing on Stop
|
||||
type Connection struct {
|
||||
ID uint32 `json:"id,omitempty"`
|
||||
FormatName string `json:"format_name,omitempty"` // rtsp, webrtc, mp4, mjpeg, mpjpeg...
|
||||
Protocol string `json:"protocol,omitempty"` // tcp, udp, http, ws, pipe...
|
||||
RemoteAddr string `json:"remote_addr,omitempty"` // host:port other info
|
||||
Source string `json:"source,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
SDP string `json:"sdp,omitempty"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
|
||||
Medias []*Media `json:"medias,omitempty"`
|
||||
Receivers []*Receiver `json:"receivers,omitempty"`
|
||||
Senders []*Sender `json:"senders,omitempty"`
|
||||
Recv int `json:"bytes_recv,omitempty"`
|
||||
Send int `json:"bytes_send,omitempty"`
|
||||
|
||||
Transport any `json:"-"`
|
||||
}
|
||||
|
||||
func (c *Connection) GetMedias() []*Media {
|
||||
return c.Medias
|
||||
}
|
||||
|
||||
func (c *Connection) GetTrack(media *Media, codec *Codec) (*Receiver, error) {
|
||||
for _, receiver := range c.Receivers {
|
||||
if receiver.Codec == codec {
|
||||
return receiver, nil
|
||||
}
|
||||
}
|
||||
receiver := NewReceiver(media, codec)
|
||||
c.Receivers = append(c.Receivers, receiver)
|
||||
return receiver, nil
|
||||
}
|
||||
|
||||
func (c *Connection) Stop() error {
|
||||
for _, receiver := range c.Receivers {
|
||||
receiver.Close()
|
||||
}
|
||||
for _, sender := range c.Senders {
|
||||
sender.Close()
|
||||
}
|
||||
if closer, ok := c.Transport.(io.Closer); ok {
|
||||
return closer.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deprecated:
|
||||
func (c *Connection) Codecs() []*Codec {
|
||||
codecs := make([]*Codec, len(c.Senders))
|
||||
for i, sender := range c.Senders {
|
||||
codecs[i] = sender.Codec
|
||||
}
|
||||
return codecs
|
||||
}
|
||||
|
||||
func (c *Connection) SetProtocol(s string) {
|
||||
c.Protocol = s
|
||||
}
|
||||
|
||||
func (c *Connection) SetRemoteAddr(s string) {
|
||||
if c.RemoteAddr == "" {
|
||||
c.RemoteAddr = s
|
||||
} else {
|
||||
c.RemoteAddr += " forwarded " + s
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Connection) SetSource(s string) {
|
||||
c.Source = s
|
||||
}
|
||||
|
||||
func (c *Connection) SetURL(s string) {
|
||||
c.URL = s
|
||||
}
|
||||
|
||||
func (c *Connection) WithRequest(r *http.Request) {
|
||||
if r.Header.Get("Upgrade") == "websocket" {
|
||||
c.Protocol = "ws"
|
||||
} else {
|
||||
c.Protocol = "http"
|
||||
}
|
||||
|
||||
c.RemoteAddr = r.RemoteAddr
|
||||
if remote := r.Header.Get("X-Forwarded-For"); remote != "" {
|
||||
c.RemoteAddr += " forwarded " + remote
|
||||
}
|
||||
|
||||
c.UserAgent = r.UserAgent()
|
||||
}
|
||||
|
||||
// Create like os.Create, init Consumer with existing Transport
|
||||
func Create(w io.Writer) (*Connection, error) {
|
||||
return &Connection{Transport: w}, nil
|
||||
}
|
||||
|
||||
// Open like os.Open, init Producer from existing Transport
|
||||
func Open(r io.Reader) (*Connection, error) {
|
||||
return &Connection{Transport: r}, nil
|
||||
}
|
||||
|
||||
// Dial like net.Dial, init Producer via Dialing
|
||||
func Dial(rawURL string) (*Connection, error) {
|
||||
return &Connection{}, nil
|
||||
}
|
@@ -1,5 +1,7 @@
|
||||
package core
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const (
|
||||
DirectionRecvonly = "recvonly"
|
||||
DirectionSendonly = "sendonly"
|
||||
@@ -18,6 +20,7 @@ const (
|
||||
CodecVP9 = "VP9"
|
||||
CodecAV1 = "AV1"
|
||||
CodecJPEG = "JPEG" // payloadType: 26
|
||||
CodecRAW = "RAW"
|
||||
|
||||
CodecPCMU = "PCMU" // payloadType: 0
|
||||
CodecPCMA = "PCMA" // payloadType: 8
|
||||
@@ -89,89 +92,6 @@ func (m Mode) String() string {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
type Info struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
RemoteAddr string `json:"remote_addr,omitempty"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
SDP string `json:"sdp,omitempty"`
|
||||
Medias []*Media `json:"medias,omitempty"`
|
||||
Receivers []*Receiver `json:"receivers,omitempty"`
|
||||
Senders []*Sender `json:"senders,omitempty"`
|
||||
Recv int `json:"recv,omitempty"`
|
||||
Send int `json:"send,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
UnsupportedCodec = "unsupported codec"
|
||||
WrongMediaDirection = "wrong media direction"
|
||||
)
|
||||
|
||||
type SuperProducer struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
SDP string `json:"sdp,omitempty"`
|
||||
Medias []*Media `json:"medias,omitempty"`
|
||||
Receivers []*Receiver `json:"receivers,omitempty"`
|
||||
Recv int `json:"recv,omitempty"`
|
||||
}
|
||||
|
||||
func (s *SuperProducer) GetMedias() []*Media {
|
||||
return s.Medias
|
||||
}
|
||||
|
||||
func (s *SuperProducer) GetTrack(media *Media, codec *Codec) (*Receiver, error) {
|
||||
for _, receiver := range s.Receivers {
|
||||
if receiver.Codec == codec {
|
||||
return receiver, nil
|
||||
}
|
||||
}
|
||||
receiver := NewReceiver(media, codec)
|
||||
s.Receivers = append(s.Receivers, receiver)
|
||||
return receiver, nil
|
||||
}
|
||||
|
||||
func (s *SuperProducer) Close() error {
|
||||
for _, receiver := range s.Receivers {
|
||||
receiver.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type SuperConsumer struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
RemoteAddr string `json:"remote_addr,omitempty"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
SDP string `json:"sdp,omitempty"`
|
||||
Medias []*Media `json:"medias,omitempty"`
|
||||
Senders []*Sender `json:"senders,omitempty"`
|
||||
Send int `json:"send,omitempty"`
|
||||
}
|
||||
|
||||
func (s *SuperConsumer) GetMedias() []*Media {
|
||||
return s.Medias
|
||||
}
|
||||
|
||||
func (s *SuperConsumer) AddTrack(media *Media, codec *Codec, track *Receiver) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
//func (b *SuperConsumer) WriteTo(w io.Writer) (n int64, err error) {
|
||||
// return 0, nil
|
||||
//}
|
||||
|
||||
func (s *SuperConsumer) Close() error {
|
||||
for _, sender := range s.Senders {
|
||||
sender.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SuperConsumer) Codecs() []*Codec {
|
||||
codecs := make([]*Codec, len(s.Senders))
|
||||
for i, sender := range s.Senders {
|
||||
codecs[i] = sender.Codec
|
||||
}
|
||||
return codecs
|
||||
func (m Mode) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(m.String())
|
||||
}
|
||||
|
120
pkg/core/core_test.go
Normal file
120
pkg/core/core_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type producer struct {
|
||||
Medias []*Media
|
||||
Receivers []*Receiver
|
||||
|
||||
id byte
|
||||
}
|
||||
|
||||
func (p *producer) GetMedias() []*Media {
|
||||
return p.Medias
|
||||
}
|
||||
|
||||
func (p *producer) GetTrack(_ *Media, codec *Codec) (*Receiver, error) {
|
||||
for _, receiver := range p.Receivers {
|
||||
if receiver.Codec == codec {
|
||||
return receiver, nil
|
||||
}
|
||||
}
|
||||
receiver := NewReceiver(nil, codec)
|
||||
p.Receivers = append(p.Receivers, receiver)
|
||||
return receiver, nil
|
||||
}
|
||||
|
||||
func (p *producer) Start() error {
|
||||
pkt := &Packet{Payload: []byte{p.id}}
|
||||
p.Receivers[0].Input(pkt)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *producer) Stop() error {
|
||||
for _, receiver := range p.Receivers {
|
||||
receiver.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type consumer struct {
|
||||
Medias []*Media
|
||||
Senders []*Sender
|
||||
|
||||
cache chan byte
|
||||
}
|
||||
|
||||
func (c *consumer) GetMedias() []*Media {
|
||||
return c.Medias
|
||||
}
|
||||
|
||||
func (c *consumer) AddTrack(_ *Media, _ *Codec, track *Receiver) error {
|
||||
c.cache = make(chan byte, 1)
|
||||
sender := NewSender(nil, track.Codec)
|
||||
sender.Output = func(packet *Packet) {
|
||||
c.cache <- packet.Payload[0]
|
||||
}
|
||||
sender.HandleRTP(track)
|
||||
c.Senders = append(c.Senders, sender)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *consumer) Stop() error {
|
||||
for _, sender := range c.Senders {
|
||||
sender.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *consumer) read() byte {
|
||||
return <-c.cache
|
||||
}
|
||||
|
||||
func TestName(t *testing.T) {
|
||||
GetProducer := func(b byte) Producer {
|
||||
return &producer{
|
||||
Medias: []*Media{
|
||||
{
|
||||
Kind: KindVideo,
|
||||
Direction: DirectionRecvonly,
|
||||
Codecs: []*Codec{
|
||||
{Name: CodecH264},
|
||||
},
|
||||
},
|
||||
},
|
||||
id: b,
|
||||
}
|
||||
}
|
||||
|
||||
// stage1
|
||||
prod1 := GetProducer(1)
|
||||
cons2 := &consumer{}
|
||||
|
||||
media1 := prod1.GetMedias()[0]
|
||||
track1, _ := prod1.GetTrack(media1, media1.Codecs[0])
|
||||
|
||||
_ = cons2.AddTrack(nil, nil, track1)
|
||||
|
||||
_ = prod1.Start()
|
||||
require.Equal(t, byte(1), cons2.read())
|
||||
|
||||
// stage2
|
||||
prod2 := GetProducer(2)
|
||||
media2 := prod2.GetMedias()[0]
|
||||
require.NotEqual(t, fmt.Sprintf("%p", media1), fmt.Sprintf("%p", media2))
|
||||
track2, _ := prod2.GetTrack(media2, media2.Codecs[0])
|
||||
track1.Replace(track2)
|
||||
|
||||
_ = prod1.Stop()
|
||||
|
||||
_ = prod2.Start()
|
||||
require.Equal(t, byte(2), cons2.read())
|
||||
|
||||
// stage3
|
||||
_ = prod2.Stop()
|
||||
}
|
@@ -38,6 +38,13 @@ func RandString(size, base byte) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func Before(s, sep string) string {
|
||||
if i := strings.Index(s, sep); i > 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func Between(s, sub1, sub2 string) string {
|
||||
i := strings.Index(s, sub1)
|
||||
if i < 0 {
|
||||
|
@@ -22,7 +22,7 @@ type Media struct {
|
||||
func (m *Media) String() string {
|
||||
s := fmt.Sprintf("%s, %s", m.Kind, m.Direction)
|
||||
for _, codec := range m.Codecs {
|
||||
name := codec.Text()
|
||||
name := codec.String()
|
||||
|
||||
if strings.Contains(s, name) {
|
||||
continue
|
||||
@@ -92,7 +92,7 @@ func (m *Media) Equal(media *Media) bool {
|
||||
|
||||
func GetKind(name string) string {
|
||||
switch name {
|
||||
case CodecH264, CodecH265, CodecVP8, CodecVP9, CodecAV1, CodecJPEG:
|
||||
case CodecH264, CodecH265, CodecVP8, CodecVP9, CodecAV1, CodecJPEG, CodecRAW:
|
||||
return KindVideo
|
||||
case CodecPCMU, CodecPCMA, CodecAAC, CodecOpus, CodecG722, CodecMP3, CodecPCM, CodecPCML, CodecELD, CodecFLAC:
|
||||
return KindAudio
|
||||
|
88
pkg/core/node.go
Normal file
88
pkg/core/node.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
//type Packet struct {
|
||||
// Payload []byte
|
||||
// Timestamp uint32 // PTS if DTS == 0 else DTS
|
||||
// Composition uint32 // CTS = PTS-DTS (for support B-frames)
|
||||
// Sequence uint16
|
||||
//}
|
||||
|
||||
type Packet = rtp.Packet
|
||||
|
||||
// HandlerFunc - process input packets (just like http.HandlerFunc)
|
||||
type HandlerFunc func(packet *Packet)
|
||||
|
||||
// Filter - a decorator for any HandlerFunc
|
||||
type Filter func(handler HandlerFunc) HandlerFunc
|
||||
|
||||
// Node - Receiver or Sender or Filter (transform)
|
||||
type Node struct {
|
||||
Codec *Codec
|
||||
Input HandlerFunc
|
||||
Output HandlerFunc
|
||||
|
||||
id uint32
|
||||
childs []*Node
|
||||
parent *Node
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (n *Node) WithParent(parent *Node) *Node {
|
||||
parent.AppendChild(n)
|
||||
return n
|
||||
}
|
||||
|
||||
func (n *Node) AppendChild(child *Node) {
|
||||
n.mu.Lock()
|
||||
n.childs = append(n.childs, child)
|
||||
n.mu.Unlock()
|
||||
|
||||
child.parent = n
|
||||
}
|
||||
|
||||
func (n *Node) RemoveChild(child *Node) {
|
||||
n.mu.Lock()
|
||||
for i, ch := range n.childs {
|
||||
if ch == child {
|
||||
n.childs = append(n.childs[:i], n.childs[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
n.mu.Unlock()
|
||||
}
|
||||
|
||||
func (n *Node) Close() {
|
||||
if parent := n.parent; parent != nil {
|
||||
parent.RemoveChild(n)
|
||||
|
||||
if len(parent.childs) == 0 {
|
||||
parent.Close()
|
||||
}
|
||||
} else {
|
||||
for _, childs := range n.childs {
|
||||
childs.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func MoveNode(dst, src *Node) {
|
||||
src.mu.Lock()
|
||||
childs := src.childs
|
||||
src.childs = nil
|
||||
src.mu.Unlock()
|
||||
|
||||
dst.mu.Lock()
|
||||
dst.childs = childs
|
||||
dst.mu.Unlock()
|
||||
|
||||
for _, child := range childs {
|
||||
child.parent = dst
|
||||
}
|
||||
}
|
@@ -3,201 +3,212 @@ package core
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
type Packet struct {
|
||||
PayloadType uint8
|
||||
Sequence uint16
|
||||
Timestamp uint32 // PTS if DTS == 0 else DTS
|
||||
Composition uint32 // CTS = PTS-DTS (for support B-frames)
|
||||
Payload []byte
|
||||
}
|
||||
|
||||
var ErrCantGetTrack = errors.New("can't get track")
|
||||
|
||||
type Receiver struct {
|
||||
Codec *Codec
|
||||
Media *Media
|
||||
Node
|
||||
|
||||
ID byte // Channel for RTSP, PayloadType for MPEG-TS
|
||||
// Deprecated: should be removed
|
||||
Media *Media `json:"-"`
|
||||
// Deprecated: should be removed
|
||||
ID byte `json:"-"` // Channel for RTSP, PayloadType for MPEG-TS
|
||||
|
||||
senders map[*Sender]chan *rtp.Packet
|
||||
mu sync.RWMutex
|
||||
bytes int
|
||||
Bytes int `json:"bytes,omitempty"`
|
||||
Packets int `json:"packets,omitempty"`
|
||||
}
|
||||
|
||||
func NewReceiver(media *Media, codec *Codec) *Receiver {
|
||||
Assert(codec != nil)
|
||||
return &Receiver{Codec: codec, Media: media}
|
||||
}
|
||||
|
||||
// WriteRTP - fast and non blocking write to all readers buffers
|
||||
func (t *Receiver) WriteRTP(packet *rtp.Packet) {
|
||||
t.mu.Lock()
|
||||
t.bytes += len(packet.Payload)
|
||||
for sender, buffer := range t.senders {
|
||||
select {
|
||||
case buffer <- packet:
|
||||
default:
|
||||
sender.overflow++
|
||||
r := &Receiver{
|
||||
Node: Node{id: NewID(), Codec: codec},
|
||||
Media: media,
|
||||
}
|
||||
r.Input = func(packet *Packet) {
|
||||
r.Bytes += len(packet.Payload)
|
||||
r.Packets++
|
||||
for _, child := range r.childs {
|
||||
child.Input(packet)
|
||||
}
|
||||
}
|
||||
t.mu.Unlock()
|
||||
return r
|
||||
}
|
||||
|
||||
func (t *Receiver) Senders() (senders []*Sender) {
|
||||
t.mu.RLock()
|
||||
for sender := range t.senders {
|
||||
senders = append(senders, sender)
|
||||
// Deprecated: should be removed
|
||||
func (r *Receiver) WriteRTP(packet *rtp.Packet) {
|
||||
r.Input(packet)
|
||||
}
|
||||
|
||||
// Deprecated: should be removed
|
||||
func (r *Receiver) Senders() []*Sender {
|
||||
if len(r.childs) > 0 {
|
||||
return []*Sender{{}}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
t.mu.RUnlock()
|
||||
return
|
||||
}
|
||||
|
||||
func (t *Receiver) Close() {
|
||||
t.mu.Lock()
|
||||
// close all sender channel buffers and erase senders list
|
||||
for _, buffer := range t.senders {
|
||||
close(buffer)
|
||||
}
|
||||
t.senders = nil
|
||||
t.mu.Unlock()
|
||||
// Deprecated: should be removed
|
||||
func (r *Receiver) Replace(target *Receiver) {
|
||||
MoveNode(&target.Node, &r.Node)
|
||||
}
|
||||
|
||||
func (t *Receiver) Replace(target *Receiver) {
|
||||
// move this receiver senders to new receiver
|
||||
t.mu.Lock()
|
||||
senders := t.senders
|
||||
t.mu.Unlock()
|
||||
|
||||
target.mu.Lock()
|
||||
target.senders = senders
|
||||
target.mu.Unlock()
|
||||
}
|
||||
|
||||
func (t *Receiver) String() string {
|
||||
s := t.Codec.String() + ", bytes=" + strconv.Itoa(t.bytes)
|
||||
t.mu.RLock()
|
||||
s += fmt.Sprintf(", senders=%d", len(t.senders))
|
||||
t.mu.RUnlock()
|
||||
return s
|
||||
}
|
||||
|
||||
func (t *Receiver) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(t.String())
|
||||
func (r *Receiver) Close() {
|
||||
r.Node.Close()
|
||||
}
|
||||
|
||||
type Sender struct {
|
||||
Codec *Codec
|
||||
Media *Media
|
||||
Node
|
||||
|
||||
Handler HandlerFunc
|
||||
// Deprecated:
|
||||
Media *Media `json:"-"`
|
||||
// Deprecated:
|
||||
Handler HandlerFunc `json:"-"`
|
||||
|
||||
receivers []*Receiver
|
||||
mu sync.RWMutex
|
||||
bytes int
|
||||
Bytes int `json:"bytes,omitempty"`
|
||||
Packets int `json:"packets,omitempty"`
|
||||
Drops int `json:"drops,omitempty"`
|
||||
|
||||
overflow int
|
||||
buf chan *Packet
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func NewSender(media *Media, codec *Codec) *Sender {
|
||||
return &Sender{Codec: codec, Media: media}
|
||||
}
|
||||
var bufSize uint16
|
||||
|
||||
// HandlerFunc like http.HandlerFunc
|
||||
type HandlerFunc func(packet *rtp.Packet)
|
||||
|
||||
func (s *Sender) HandleRTP(track *Receiver) {
|
||||
bufferSize := 100
|
||||
|
||||
if GetKind(track.Codec.Name) == KindVideo {
|
||||
if track.Codec.IsRTP() {
|
||||
if GetKind(codec.Name) == KindVideo {
|
||||
if codec.IsRTP() {
|
||||
// in my tests 40Mbit/s 4K-video can generate up to 1500 items
|
||||
// for the h264.RTPDepay => RTPPay queue
|
||||
bufferSize = 5000
|
||||
bufSize = 4096
|
||||
} else {
|
||||
bufferSize = 50
|
||||
bufSize = 64
|
||||
}
|
||||
} else {
|
||||
bufSize = 128
|
||||
}
|
||||
|
||||
buffer := make(chan *rtp.Packet, bufferSize)
|
||||
|
||||
track.mu.Lock()
|
||||
if track.senders == nil {
|
||||
track.senders = map[*Sender]chan *rtp.Packet{}
|
||||
buf := make(chan *Packet, bufSize)
|
||||
s := &Sender{
|
||||
Node: Node{id: NewID(), Codec: codec},
|
||||
Media: media,
|
||||
buf: buf,
|
||||
}
|
||||
track.senders[s] = buffer
|
||||
track.mu.Unlock()
|
||||
s.mu.Lock()
|
||||
s.receivers = append(s.receivers, track)
|
||||
s.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
// read packets from buffer channel until it will be closed
|
||||
for packet := range buffer {
|
||||
s.bytes += len(packet.Payload)
|
||||
s.Handler(packet)
|
||||
}
|
||||
|
||||
// remove current receiver from list
|
||||
// it can only happen when receiver close buffer channel
|
||||
s.Input = func(packet *Packet) {
|
||||
// writing to nil chan - OK, writing to closed chan - panic
|
||||
s.mu.Lock()
|
||||
for i, receiver := range s.receivers {
|
||||
if receiver == track {
|
||||
s.receivers = append(s.receivers[:i], s.receivers[i+1:]...)
|
||||
break
|
||||
}
|
||||
select {
|
||||
case s.buf <- packet:
|
||||
s.Bytes += len(packet.Payload)
|
||||
s.Packets++
|
||||
default:
|
||||
s.Drops++
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
s.Output = func(packet *Packet) {
|
||||
s.Handler(packet)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Deprecated: should be removed
|
||||
func (s *Sender) HandleRTP(parent *Receiver) {
|
||||
s.WithParent(parent)
|
||||
s.Start()
|
||||
}
|
||||
|
||||
// Deprecated: should be removed
|
||||
func (s *Sender) Bind(parent *Receiver) {
|
||||
s.WithParent(parent)
|
||||
}
|
||||
|
||||
func (s *Sender) WithParent(parent *Receiver) *Sender {
|
||||
s.Node.WithParent(&parent.Node)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Sender) Start() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.buf == nil || s.done != nil {
|
||||
return
|
||||
}
|
||||
s.done = make(chan struct{})
|
||||
|
||||
go func() {
|
||||
for packet := range s.buf {
|
||||
s.Output(packet)
|
||||
}
|
||||
close(s.done)
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Sender) Close() {
|
||||
s.mu.Lock()
|
||||
// remove this sender from all receivers list
|
||||
for _, receiver := range s.receivers {
|
||||
receiver.mu.Lock()
|
||||
if buffer := receiver.senders[s]; buffer != nil {
|
||||
// remove channel from list
|
||||
delete(receiver.senders, s)
|
||||
// close channel
|
||||
close(buffer)
|
||||
}
|
||||
receiver.mu.Unlock()
|
||||
func (s *Sender) Wait() {
|
||||
if done := s.done; s.done != nil {
|
||||
<-done
|
||||
}
|
||||
s.receivers = nil
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Sender) String() string {
|
||||
info := s.Codec.String() + ", bytes=" + strconv.Itoa(s.bytes)
|
||||
s.mu.RLock()
|
||||
info += ", receivers=" + strconv.Itoa(len(s.receivers))
|
||||
s.mu.RUnlock()
|
||||
if s.overflow > 0 {
|
||||
info += ", overflow=" + strconv.Itoa(s.overflow)
|
||||
func (s *Sender) State() string {
|
||||
if s.buf == nil {
|
||||
return "closed"
|
||||
}
|
||||
return info
|
||||
if s.done == nil {
|
||||
return "new"
|
||||
}
|
||||
return "connected"
|
||||
}
|
||||
|
||||
func (s *Sender) Close() {
|
||||
// close buffer if exists
|
||||
if buf := s.buf; buf != nil {
|
||||
s.buf = nil
|
||||
defer close(buf)
|
||||
}
|
||||
|
||||
s.Node.Close()
|
||||
}
|
||||
|
||||
func (r *Receiver) MarshalJSON() ([]byte, error) {
|
||||
v := struct {
|
||||
ID uint32 `json:"id"`
|
||||
Codec *Codec `json:"codec"`
|
||||
Childs []uint32 `json:"childs,omitempty"`
|
||||
Bytes int `json:"bytes,omitempty"`
|
||||
Packets int `json:"packets,omitempty"`
|
||||
}{
|
||||
ID: r.Node.id,
|
||||
Codec: r.Node.Codec,
|
||||
Bytes: r.Bytes,
|
||||
Packets: r.Packets,
|
||||
}
|
||||
for _, child := range r.childs {
|
||||
v.Childs = append(v.Childs, child.id)
|
||||
}
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
func (s *Sender) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(s.String())
|
||||
}
|
||||
|
||||
// VA - helper, for extract video and audio receivers from list
|
||||
func VA(receivers []*Receiver) (video, audio *Receiver) {
|
||||
for _, receiver := range receivers {
|
||||
switch GetKind(receiver.Codec.Name) {
|
||||
case KindVideo:
|
||||
video = receiver
|
||||
case KindAudio:
|
||||
audio = receiver
|
||||
}
|
||||
v := struct {
|
||||
ID uint32 `json:"id"`
|
||||
Codec *Codec `json:"codec"`
|
||||
Parent uint32 `json:"parent,omitempty"`
|
||||
Bytes int `json:"bytes,omitempty"`
|
||||
Packets int `json:"packets,omitempty"`
|
||||
Drops int `json:"drops,omitempty"`
|
||||
}{
|
||||
ID: s.Node.id,
|
||||
Codec: s.Node.Codec,
|
||||
Bytes: s.Bytes,
|
||||
Packets: s.Packets,
|
||||
Drops: s.Drops,
|
||||
}
|
||||
return
|
||||
if s.parent != nil {
|
||||
v.Parent = s.parent.id
|
||||
}
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
@@ -24,7 +24,7 @@ func Logger(include func(packet *rtp.Packet) bool) func(packet *rtp.Packet) {
|
||||
now := time.Now()
|
||||
|
||||
fmt.Printf(
|
||||
"%s: size:%6d, ts:%10d, type:%2d, ssrc:%d, seq:%5d, mark:%t, dts:%4d, dtime:%3d\n",
|
||||
"%s: size=%6d ts=%10d type=%2d ssrc=%d seq=%5d mark=%t dts=%4d dtime=%3dms\n",
|
||||
now.Format("15:04:05.000"),
|
||||
len(packet.Payload), packet.Timestamp, packet.PayloadType, packet.SSRC, packet.SequenceNumber, packet.Marker,
|
||||
packet.Timestamp-lastTS, now.Sub(lastTime).Milliseconds(),
|
||||
@@ -41,7 +41,7 @@ func Logger(include func(packet *rtp.Packet) bool) func(packet *rtp.Packet) {
|
||||
|
||||
if dt := now.Sub(secTime); dt > time.Second {
|
||||
fmt.Printf(
|
||||
"%s: size:%6d, cnt:%d, dts: %d, dtime:%d\n",
|
||||
"%s: size=%6d cnt=%d dts=%d dtime=%3dms\n",
|
||||
now.Format("15:04:05.000"),
|
||||
secSize, secCnt, lastTS-secTS, dt.Milliseconds(),
|
||||
)
|
||||
|
@@ -8,16 +8,16 @@ import (
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
type Consumer struct {
|
||||
core.SuperConsumer
|
||||
type Backchannel struct {
|
||||
core.Connection
|
||||
client *Client
|
||||
}
|
||||
|
||||
func (c *Consumer) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) {
|
||||
func (c *Backchannel) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) {
|
||||
return nil, core.ErrCantGetTrack
|
||||
}
|
||||
|
||||
func (c *Consumer) Start() error {
|
||||
func (c *Backchannel) Start() error {
|
||||
if err := c.client.conn.SetReadDeadline(time.Time{}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -30,12 +30,7 @@ func (c *Consumer) Start() error {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Consumer) Stop() error {
|
||||
_ = c.SuperConsumer.Close()
|
||||
return c.client.Close()
|
||||
}
|
||||
|
||||
func (c *Consumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error {
|
||||
func (c *Backchannel) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error {
|
||||
if err := c.client.Talk(); err != nil {
|
||||
return err
|
||||
}
|
@@ -114,7 +114,7 @@ func (c *Client) Play() error {
|
||||
}
|
||||
|
||||
func (c *Client) Talk() error {
|
||||
format := `{"Name":"OPTalk","SessionID":"0x%08X","OPTalk":{"Action":"%s"}}` + "\x0A\x00"
|
||||
format := `{"Name":"OPTalk","SessionID":"0x%08X","OPTalk":{"Action":"%s","AudioFormat":{"EncodeType":"G711_ALAW"}}}` + "\x0A\x00"
|
||||
|
||||
data := fmt.Sprintf(format, c.session, "Claim")
|
||||
if _, err := c.WriteCmd(OPTalkClaim, []byte(data)); err != nil {
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user