mirror of
https://github.com/AlexxIT/go2rtc.git
synced 2025-10-05 08:16:55 +08:00
Compare commits
51 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
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 |
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -170,8 +170,8 @@ jobs:
|
||||
with:
|
||||
images: ${{ github.repository }}
|
||||
flavor: |
|
||||
suffix=-hardware
|
||||
latest=false
|
||||
suffix=-hardware,onlatest=true
|
||||
latest=auto
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}},enable=false
|
||||
|
16
README.md
16
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)*
|
||||
@@ -1187,6 +1193,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 |
@@ -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() {
|
||||
|
54
internal/app/README.md
Normal file
54
internal/app/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
- By default go2rtc will search config file `go2rtc.yaml` in current work directory
|
||||
- go2rtc support multiple config files
|
||||
- go2rtc support inline config as `YAML`, `JSON` or `key=value` format from command line
|
||||
- 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
|
||||
|
||||
${LOGS:} # empty default value
|
||||
|
||||
rtsp:
|
||||
username: ${RTSP_USER:admin} # "admin" if env "RTSP_USER" not set
|
||||
password: ${RTSP_PASS:secret} # "secret" if env "RTSP_PASS" not set
|
||||
```
|
||||
|
||||
## Defaults
|
||||
|
||||
```yaml
|
||||
api:
|
||||
listen: ":1984"
|
||||
|
||||
ffmpeg:
|
||||
bin: "ffmpeg"
|
||||
|
||||
log:
|
||||
level: "info"
|
||||
|
||||
rtsp:
|
||||
listen: ":8554"
|
||||
default_query: "video&audio"
|
||||
|
||||
srtp:
|
||||
listen: ":8443"
|
||||
|
||||
webrtc:
|
||||
listen: ":8555/tcp"
|
||||
ice_servers:
|
||||
- urls: [ "stun:stun.l.google.com:19302" ]
|
||||
```
|
@@ -8,6 +8,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
@@ -15,7 +16,7 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var Version = "1.9.1"
|
||||
var Version = "1.9.2"
|
||||
var UserAgent = "go2rtc/" + Version
|
||||
|
||||
var ConfigPath string
|
||||
@@ -23,24 +24,41 @@ 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 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(&confs, "config", "")
|
||||
flag.Var(&confs, "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 {
|
||||
if runtime.GOOS == "windows" {
|
||||
fmt.Println("Daemon not supported on Windows")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
args := os.Args[1:]
|
||||
for i, arg := range args {
|
||||
if arg == "-daemon" {
|
||||
@@ -61,22 +79,26 @@ func Init() {
|
||||
}
|
||||
|
||||
for _, conf := range confs {
|
||||
if conf[0] != '{' {
|
||||
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
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(conf)
|
||||
if data == nil {
|
||||
if data, _ = os.ReadFile(conf); data == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
data = []byte(shell.ReplaceEnvVars(string(data)))
|
||||
configs = append(configs, data)
|
||||
} else {
|
||||
// config as raw YAML
|
||||
configs = append(configs, []byte(conf))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +111,8 @@ func Init() {
|
||||
Info["config_path"] = ConfigPath
|
||||
}
|
||||
|
||||
Info["revision"] = revision
|
||||
|
||||
var cfg struct {
|
||||
Mod map[string]string `yaml:"log"`
|
||||
}
|
||||
@@ -99,7 +123,13 @@ func Init() {
|
||||
|
||||
modules = cfg.Mod
|
||||
|
||||
log.Info().Msgf("go2rtc version %s %s/%s", Version, runtime.GOOS, runtime.GOARCH)
|
||||
platform := fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)
|
||||
log.Info().Str("version", Version).Str("platform", platform).Str("revision", revision).Msg("go2rtc")
|
||||
log.Debug().Str("version", runtime.Version()).Str("vcs.time", vcsTime).Msg("build")
|
||||
|
||||
if ConfigPath != "" {
|
||||
log.Info().Str("path", ConfigPath).Msg("config")
|
||||
}
|
||||
|
||||
migrateStore()
|
||||
}
|
||||
@@ -142,3 +172,47 @@ func (c *Config) Set(value string) error {
|
||||
}
|
||||
|
||||
var configs [][]byte
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func parseConfString(s string) []byte {
|
||||
i := strings.IndexByte(s, '=')
|
||||
if i < 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
items := strings.Split(s[:i], ".")
|
||||
if len(items) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// `log.level=trace` => `{log: {level: trace}}`
|
||||
var pre string
|
||||
var suf = s[i+1:]
|
||||
for _, item := range items {
|
||||
pre += "{" + item + ": "
|
||||
suf += "}"
|
||||
}
|
||||
|
||||
return []byte(pre + suf)
|
||||
}
|
||||
|
@@ -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 (
|
||||
|
@@ -55,7 +55,7 @@ var defaults = map[string]string{
|
||||
// `-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",
|
||||
|
||||
@@ -280,6 +280,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"] {
|
||||
|
@@ -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"
|
||||
|
@@ -11,13 +11,13 @@ func GetInput(src string) (string, error) {
|
||||
}
|
||||
|
||||
// set defaults (using Add instead of Set)
|
||||
query.Add("source", "testsrc")
|
||||
query.Add("video", "testsrc")
|
||||
query.Add("size", "1920x1080")
|
||||
query.Add("decimals", "2")
|
||||
|
||||
// https://ffmpeg.org/ffmpeg-filters.html
|
||||
source := query.Get("source")
|
||||
input := "-re -f lavfi -i " + source
|
||||
video := query.Get("video")
|
||||
input := "-re -f lavfi -i " + video
|
||||
|
||||
sep := "=" // first separator
|
||||
for key, values := range query {
|
||||
@@ -29,18 +29,18 @@ func GetInput(src string) (string, error) {
|
||||
case "size":
|
||||
switch value {
|
||||
case "720":
|
||||
value = "1280x720"
|
||||
value = "1280x720" // crf=1 -> 12 Mbps
|
||||
case "1080":
|
||||
value = "1920x1080"
|
||||
value = "1920x1080" // crf=1 -> 25 Mbps
|
||||
case "2K":
|
||||
value = "2560x1440"
|
||||
value = "2560x1440" // crf=1 -> 43 Mbps
|
||||
case "4K":
|
||||
value = "3840x2160"
|
||||
value = "3840x2160" // crf=1 -> 103 Mbps
|
||||
case "8K":
|
||||
value = "7680x4230" // https://reolink.com/blog/8k-resolution/
|
||||
}
|
||||
case "decimals":
|
||||
if source != "testsrc" {
|
||||
if video != "testsrc" {
|
||||
continue
|
||||
}
|
||||
default:
|
||||
|
@@ -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"`
|
||||
|
@@ -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)
|
||||
|
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,12 +5,14 @@ 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/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"
|
||||
@@ -21,6 +23,7 @@ import (
|
||||
func Init() {
|
||||
api.HandleFunc("api/frame.jpeg", handlerKeyframe)
|
||||
api.HandleFunc("api/stream.mjpeg", handlerStream)
|
||||
api.HandleFunc("api/stream.ascii", handlerStream)
|
||||
|
||||
ws.HandleFunc("mjpeg", handlerWS)
|
||||
}
|
||||
@@ -99,38 +102,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.Type = "ASCII passive consumer "
|
||||
|
||||
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) {
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@@ -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"`
|
||||
}
|
||||
|
||||
|
@@ -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 {
|
||||
|
@@ -9,6 +9,8 @@ import (
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/pkg/probe"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
@@ -148,7 +150,27 @@ func streamsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Not sure about all this API. Should be rewrited...
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
api.ResponsePrettyJSON(w, streams[src])
|
||||
stream := Get(src)
|
||||
if stream == nil {
|
||||
http.Error(w, "", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
cons := probe.NewProbe(query)
|
||||
if len(cons.Medias) != 0 {
|
||||
cons.RemoteAddr = tcp.RemoteAddr(r)
|
||||
cons.UserAgent = r.UserAgent()
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
api.ResponsePrettyJSON(w, stream)
|
||||
|
||||
stream.RemoveConsumer(cons)
|
||||
} else {
|
||||
api.ResponsePrettyJSON(w, streams[src])
|
||||
}
|
||||
|
||||
case "PUT":
|
||||
name := query.Get("name")
|
||||
|
@@ -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) {
|
||||
|
@@ -178,10 +178,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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -248,10 +246,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 {
|
||||
|
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
|
166
pkg/ascii/ascii.go
Normal file
166
pkg/ascii/ascii.go
Normal file
@@ -0,0 +1,166 @@
|
||||
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 := uint16(r-x256r[i]) + uint16(g-x256g[i]) + uint16(b-x256b[i])
|
||||
if diff < best {
|
||||
best = diff
|
||||
index = uint8(i)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
@@ -113,7 +113,12 @@ func NewSender(media *Media, codec *Codec) *Sender {
|
||||
type HandlerFunc func(packet *rtp.Packet)
|
||||
|
||||
func (s *Sender) HandleRTP(track *Receiver) {
|
||||
bufferSize := 100
|
||||
s.Bind(track)
|
||||
go s.worker(track)
|
||||
}
|
||||
|
||||
func (s *Sender) Bind(track *Receiver) {
|
||||
var bufferSize uint16
|
||||
|
||||
if GetKind(track.Codec.Name) == KindVideo {
|
||||
if track.Codec.IsRTP() {
|
||||
@@ -123,6 +128,8 @@ func (s *Sender) HandleRTP(track *Receiver) {
|
||||
} else {
|
||||
bufferSize = 50
|
||||
}
|
||||
} else {
|
||||
bufferSize = 100
|
||||
}
|
||||
|
||||
buffer := make(chan *rtp.Packet, bufferSize)
|
||||
@@ -133,28 +140,43 @@ func (s *Sender) HandleRTP(track *Receiver) {
|
||||
}
|
||||
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
|
||||
func (s *Sender) worker(track *Receiver) {
|
||||
track.mu.Lock()
|
||||
buffer := track.senders[s]
|
||||
track.mu.Unlock()
|
||||
|
||||
// read packets from buffer channel until it will be closed
|
||||
if buffer != nil {
|
||||
for packet := range buffer {
|
||||
s.bytes += len(packet.Payload)
|
||||
s.Handler(packet)
|
||||
}
|
||||
}
|
||||
|
||||
// remove current receiver from list
|
||||
// it can only happen when receiver close buffer channel
|
||||
s.mu.Lock()
|
||||
for i, receiver := range s.receivers {
|
||||
if receiver == track {
|
||||
s.receivers = append(s.receivers[:i], s.receivers[i+1:]...)
|
||||
break
|
||||
}
|
||||
// remove current receiver from list
|
||||
// it can only happen when receiver close buffer channel
|
||||
s.mu.Lock()
|
||||
for i, receiver := range s.receivers {
|
||||
if receiver == track {
|
||||
s.receivers = append(s.receivers[:i], s.receivers[i+1:]...)
|
||||
break
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Sender) Start() {
|
||||
s.mu.Lock()
|
||||
for _, track := range s.receivers {
|
||||
go s.worker(track)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Sender) Close() {
|
||||
|
@@ -37,14 +37,34 @@ func Dial(url string) (*Producer, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// KC200
|
||||
// HTTP/1.0 200 OK
|
||||
// Content-Type: multipart/x-mixed-replace;boundary=data-boundary--
|
||||
// KD110, KC401, KC420WS:
|
||||
// HTTP/1.0 200 OK
|
||||
// Content-Type: multipart/x-mixed-replace;boundary=data-boundary--
|
||||
// Transfer-Encoding: chunked
|
||||
// HTTP/1.0 + chunked = out of standard, so golang remove this header
|
||||
// and we need to check first two bytes
|
||||
buf := bufio.NewReader(res.Body)
|
||||
|
||||
b, err := buf.Peek(2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rd := struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}{
|
||||
httputil.NewChunkedReader(res.Body),
|
||||
buf,
|
||||
res.Body,
|
||||
}
|
||||
|
||||
if string(b) != "--" {
|
||||
rd.Reader = httputil.NewChunkedReader(buf)
|
||||
}
|
||||
|
||||
prod := &Producer{rd: core.NewReadBuffer(rd)}
|
||||
if err = prod.probe(); err != nil {
|
||||
return nil, err
|
||||
|
@@ -1,3 +1,5 @@
|
||||
//go:build !(darwin || ios || freebsd || openbsd || netbsd || dragonfly || windows)
|
||||
|
||||
package mdns
|
||||
|
||||
import (
|
@@ -1,3 +1,5 @@
|
||||
//go:build darwin || ios || freebsd || openbsd || netbsd || dragonfly
|
||||
|
||||
package mdns
|
||||
|
||||
import (
|
@@ -1,24 +0,0 @@
|
||||
package mdns
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func SetsockoptInt(fd uintptr, level, opt int, value int) (err error) {
|
||||
// change SO_REUSEADDR and REUSEPORT flags simultaneously for BSD-like OS
|
||||
// https://github.com/AlexxIT/go2rtc/issues/626
|
||||
// https://stackoverflow.com/questions/14388706/how-do-so-reuseaddr-and-so-reuseport-differ/14388707
|
||||
if opt == syscall.SO_REUSEADDR {
|
||||
if err = syscall.SetsockoptInt(int(fd), level, opt, value); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
opt = syscall.SO_REUSEPORT
|
||||
}
|
||||
|
||||
return syscall.SetsockoptInt(int(fd), level, opt, value)
|
||||
}
|
||||
|
||||
func SetsockoptIPMreq(fd uintptr, level, opt int, mreq *syscall.IPMreq) (err error) {
|
||||
return syscall.SetsockoptIPMreq(int(fd), level, opt, mreq)
|
||||
}
|
@@ -1,3 +1,5 @@
|
||||
//go:build windows
|
||||
|
||||
package mdns
|
||||
|
||||
import "syscall"
|
||||
|
38
pkg/mjpeg/writer.go
Normal file
38
pkg/mjpeg/writer.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package mjpeg
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func NewWriter(w io.Writer) io.Writer {
|
||||
h := w.(http.ResponseWriter).Header()
|
||||
h.Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
||||
return &writer{wr: w, buf: []byte(header)}
|
||||
}
|
||||
|
||||
const header = "--frame\r\nContent-Type: image/jpeg\r\nContent-Length: "
|
||||
|
||||
type writer struct {
|
||||
wr io.Writer
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func (w *writer) Write(p []byte) (n int, err error) {
|
||||
w.buf = w.buf[:len(header)]
|
||||
w.buf = append(w.buf, strconv.Itoa(len(p))...)
|
||||
w.buf = append(w.buf, "\r\n\r\n"...)
|
||||
w.buf = append(w.buf, p...)
|
||||
w.buf = append(w.buf, "\r\n"...)
|
||||
|
||||
// Chrome bug: mjpeg image always shows the second to last image
|
||||
// https://bugs.chromium.org/p/chromium/issues/detail?id=527446
|
||||
if _, err = w.wr.Write(w.buf); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
w.wr.(http.Flusher).Flush()
|
||||
|
||||
return len(p), nil
|
||||
}
|
70
pkg/probe/probe.go
Normal file
70
pkg/probe/probe.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
type Probe struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
RemoteAddr string `json:"remote_addr,omitempty"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
Medias []*core.Media `json:"medias,omitempty"`
|
||||
Receivers []*core.Receiver `json:"receivers,omitempty"`
|
||||
Senders []*core.Sender `json:"senders,omitempty"`
|
||||
}
|
||||
|
||||
func NewProbe(query url.Values) *Probe {
|
||||
c := &Probe{Type: "probe"}
|
||||
c.Medias = core.ParseQuery(query)
|
||||
|
||||
for _, value := range query["microphone"] {
|
||||
media := &core.Media{Kind: core.KindAudio, Direction: core.DirectionRecvonly}
|
||||
|
||||
for _, name := range strings.Split(value, ",") {
|
||||
name = strings.ToUpper(name)
|
||||
switch name {
|
||||
case "", "COPY":
|
||||
name = core.CodecAny
|
||||
}
|
||||
media.Codecs = append(media.Codecs, &core.Codec{Name: name})
|
||||
}
|
||||
|
||||
c.Medias = append(c.Medias, media)
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (p *Probe) GetMedias() []*core.Media {
|
||||
return p.Medias
|
||||
}
|
||||
|
||||
func (p *Probe) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error {
|
||||
sender := core.NewSender(media, track.Codec)
|
||||
sender.Bind(track)
|
||||
p.Senders = append(p.Senders, sender)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Probe) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) {
|
||||
receiver := core.NewReceiver(media, codec)
|
||||
p.Receivers = append(p.Receivers, receiver)
|
||||
return receiver, nil
|
||||
}
|
||||
|
||||
func (p *Probe) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Probe) Stop() error {
|
||||
for _, receiver := range p.Receivers {
|
||||
receiver.Close()
|
||||
}
|
||||
for _, sender := range p.Senders {
|
||||
sender.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
@@ -2,6 +2,7 @@ package webrtc
|
||||
|
||||
import (
|
||||
"net"
|
||||
"slices"
|
||||
|
||||
"github.com/pion/interceptor"
|
||||
"github.com/pion/webrtc/v3"
|
||||
@@ -15,7 +16,15 @@ func NewAPI() (*webrtc.API, error) {
|
||||
return NewServerAPI("", "", nil)
|
||||
}
|
||||
|
||||
func NewServerAPI(address, network string, candidateHost []string) (*webrtc.API, error) {
|
||||
type Filters struct {
|
||||
Candidates []string `yaml:"candidates"`
|
||||
Interfaces []string `yaml:"interfaces"`
|
||||
IPs []string `yaml:"ips"`
|
||||
Networks []string `yaml:"networks"`
|
||||
UDPPorts []uint16 `yaml:"udp_ports"`
|
||||
}
|
||||
|
||||
func NewServerAPI(network, address string, filters *Filters) (*webrtc.API, error) {
|
||||
// for debug logs add to env: `PION_LOG_DEBUG=all`
|
||||
m := &webrtc.MediaEngine{}
|
||||
//if err := m.RegisterDefaultCodecs(); err != nil {
|
||||
@@ -32,23 +41,55 @@ func NewServerAPI(address, network string, candidateHost []string) (*webrtc.API,
|
||||
|
||||
s := webrtc.SettingEngine{}
|
||||
|
||||
// disable listen on Hassio docker interfaces
|
||||
s.SetInterfaceFilter(func(name string) bool {
|
||||
return name != "hassio" && name != "docker0"
|
||||
})
|
||||
|
||||
// fix https://github.com/pion/webrtc/pull/2407
|
||||
s.SetDTLSInsecureSkipHelloVerify(true)
|
||||
|
||||
s.SetReceiveMTU(ReceiveMTU)
|
||||
if filters != nil && filters.Interfaces != nil {
|
||||
s.SetIncludeLoopbackCandidate(true)
|
||||
s.SetInterfaceFilter(func(name string) bool {
|
||||
return slices.Contains(filters.Interfaces, name)
|
||||
})
|
||||
} else {
|
||||
// disable listen on Hassio docker interfaces
|
||||
s.SetInterfaceFilter(func(name string) bool {
|
||||
return name != "hassio" && name != "docker0"
|
||||
})
|
||||
}
|
||||
|
||||
s.SetNAT1To1IPs(candidateHost, webrtc.ICECandidateTypeHost)
|
||||
if filters != nil && filters.IPs != nil {
|
||||
s.SetIncludeLoopbackCandidate(true)
|
||||
s.SetIPFilter(func(ip net.IP) bool {
|
||||
return slices.Contains(filters.IPs, ip.String())
|
||||
})
|
||||
}
|
||||
|
||||
// by default enable IPv4 + IPv6 modes
|
||||
s.SetNetworkTypes([]webrtc.NetworkType{
|
||||
webrtc.NetworkTypeUDP4, webrtc.NetworkTypeTCP4,
|
||||
webrtc.NetworkTypeUDP6, webrtc.NetworkTypeTCP6,
|
||||
})
|
||||
if filters != nil && filters.Networks != nil {
|
||||
var networkTypes []webrtc.NetworkType
|
||||
for _, s := range filters.Networks {
|
||||
if networkType, err := webrtc.NewNetworkType(s); err == nil {
|
||||
networkTypes = append(networkTypes, networkType)
|
||||
}
|
||||
}
|
||||
s.SetNetworkTypes(networkTypes)
|
||||
} else {
|
||||
s.SetNetworkTypes([]webrtc.NetworkType{
|
||||
webrtc.NetworkTypeUDP4, webrtc.NetworkTypeUDP6,
|
||||
webrtc.NetworkTypeTCP4, webrtc.NetworkTypeTCP6,
|
||||
})
|
||||
}
|
||||
|
||||
if filters != nil && len(filters.UDPPorts) == 2 {
|
||||
_ = s.SetEphemeralUDPPortRange(filters.UDPPorts[0], filters.UDPPorts[1])
|
||||
}
|
||||
|
||||
//if len(hosts) != 0 {
|
||||
// // support only: host, srflx
|
||||
// if candidateType, err := webrtc.NewICECandidateType(hosts[0]); err == nil {
|
||||
// s.SetNAT1To1IPs(hosts[1:], candidateType)
|
||||
// } else {
|
||||
// s.SetNAT1To1IPs(hosts, 0) // 0 = host
|
||||
// }
|
||||
//}
|
||||
|
||||
if address != "" {
|
||||
if network == "" || network == "tcp" {
|
||||
|
@@ -120,6 +120,10 @@ func NewConn(pc *webrtc.PeerConnection) *Conn {
|
||||
c.Fire(state)
|
||||
|
||||
switch state {
|
||||
case webrtc.PeerConnectionStateConnected:
|
||||
for _, sender := range c.senders {
|
||||
sender.Start()
|
||||
}
|
||||
case webrtc.PeerConnectionStateDisconnected, webrtc.PeerConnectionStateFailed, webrtc.PeerConnectionStateClosed:
|
||||
// disconnect event comes earlier, than failed
|
||||
// but it comes only for success connections
|
||||
|
@@ -20,7 +20,7 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv
|
||||
|
||||
for _, sender := range c.senders {
|
||||
if sender.Codec == codec {
|
||||
sender.HandleRTP(track)
|
||||
sender.Bind(track)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv
|
||||
sender.Handler = pcm.RepackG711(false, sender.Handler)
|
||||
}
|
||||
|
||||
sender.HandleRTP(track)
|
||||
sender.Bind(track)
|
||||
|
||||
c.senders = append(c.senders, sender)
|
||||
return nil
|
||||
|
@@ -273,38 +273,41 @@ func MimeType(codec *core.Codec) string {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
// 4.1.2.2. Guidelines for Choosing Type and Local Preferences
|
||||
// The RECOMMENDED values are 126 for host candidates, 100
|
||||
// for server reflexive candidates, 110 for peer reflexive candidates,
|
||||
// and 0 for relayed candidates.
|
||||
|
||||
const PriorityTypeHostUDP = (1 << 24) * int(126)
|
||||
const PriorityTypeHostTCP = (1 << 24) * int(126-27)
|
||||
const PriorityLocalUDP = (1 << 8) * int(65535)
|
||||
const PriorityLocalTCPPassive = (1 << 8) * int((1<<13)*4+8191)
|
||||
const PriorityComponentRTP = 1 * int(256-ice.ComponentRTP)
|
||||
|
||||
func CandidateManualHostUDP(host, port string, offset int) string {
|
||||
foundation := crc32.ChecksumIEEE([]byte("host" + host + "udp4"))
|
||||
priority := PriorityTypeHostUDP + PriorityLocalUDP + PriorityComponentRTP + offset
|
||||
|
||||
func CandidateICE(network, host, port string, priority uint32) string {
|
||||
// 1. Foundation
|
||||
// 2. Component, always 1 because RTP
|
||||
// 3. udp or tcp
|
||||
// 3. "udp" or "tcp"
|
||||
// 4. Priority
|
||||
// 5. Host - IP4 or IP6 or domain name
|
||||
// 6. Port
|
||||
// 7. typ host
|
||||
return fmt.Sprintf("candidate:%d 1 udp %d %s %s typ host", foundation, priority, host, port)
|
||||
// 7. "typ host"
|
||||
foundation := crc32.ChecksumIEEE([]byte("host" + host + network + "4"))
|
||||
s := fmt.Sprintf("candidate:%d 1 %s %d %s %s typ host", foundation, network, priority, host, port)
|
||||
if network == "tcp" {
|
||||
return s + " tcptype passive"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func CandidateManualHostTCPPassive(host, port string, offset int) string {
|
||||
foundation := crc32.ChecksumIEEE([]byte("host" + host + "tcp4"))
|
||||
priority := PriorityTypeHostTCP + PriorityLocalTCPPassive + PriorityComponentRTP + offset
|
||||
// Priority = type << 24 + local << 8 + component
|
||||
// https://www.rfc-editor.org/rfc/rfc8445#section-5.1.2.1
|
||||
|
||||
return fmt.Sprintf(
|
||||
"candidate:%d 1 tcp %d %s %s typ host tcptype passive", foundation, priority, host, port,
|
||||
)
|
||||
const PriorityHostUDP uint32 = 0x001F_FFFF |
|
||||
126<<24 | // udp host
|
||||
7<<21 // udp
|
||||
const PriorityHostTCPPassive uint32 = 0x001F_FFFF |
|
||||
99<<24 | // tcp host
|
||||
4<<21 // tcp passive
|
||||
|
||||
// CandidateHostPriority (lower indexes has a higher priority)
|
||||
func CandidateHostPriority(network string, index int) uint32 {
|
||||
switch network {
|
||||
case "udp":
|
||||
return PriorityHostUDP - uint32(index)
|
||||
case "tcp":
|
||||
return PriorityHostTCPPassive - uint32(index)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func UnmarshalICEServers(b []byte) ([]webrtc.ICEServer, error) {
|
||||
|
@@ -81,11 +81,42 @@ transeivers:
|
||||
return c.pc.LocalDescription().SDP, nil
|
||||
}
|
||||
|
||||
func (c *Conn) GetCompleteAnswer() (answer string, err error) {
|
||||
if _, err = c.GetAnswer(); err != nil {
|
||||
return
|
||||
// GetCompleteAnswer - get SDP answer with candidates inside
|
||||
func (c *Conn) GetCompleteAnswer(candidates []string, filter func(*webrtc.ICECandidate) bool) (string, error) {
|
||||
var done = make(chan struct{})
|
||||
|
||||
c.pc.OnICECandidate(func(candidate *webrtc.ICECandidate) {
|
||||
if candidate != nil {
|
||||
if filter == nil || filter(candidate) {
|
||||
candidates = append(candidates, candidate.ToJSON().Candidate)
|
||||
}
|
||||
} else {
|
||||
done <- struct{}{}
|
||||
}
|
||||
})
|
||||
|
||||
answer, err := c.GetAnswer()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
<-webrtc.GatheringCompletePromise(c.pc)
|
||||
return c.pc.LocalDescription().SDP, nil
|
||||
<-done
|
||||
|
||||
sd := &sdp.SessionDescription{}
|
||||
if err = sd.Unmarshal([]byte(answer)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
md := sd.MediaDescriptions[0]
|
||||
|
||||
for _, candidate := range candidates {
|
||||
md.WithPropertyAttribute(candidate)
|
||||
}
|
||||
|
||||
b, err := sd.Marshal()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(b), nil
|
||||
}
|
||||
|
BIN
website/icons/android-chrome-192x192.png
Normal file
BIN
website/icons/android-chrome-192x192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.3 KiB |
BIN
website/icons/android-chrome-512x512.png
Normal file
BIN
website/icons/android-chrome-512x512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
BIN
website/icons/apple-touch-icon-180x180.png
Normal file
BIN
website/icons/apple-touch-icon-180x180.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.9 KiB |
BIN
website/icons/favicon.ico
Normal file
BIN
website/icons/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
18
website/manifest.json
Normal file
18
website/manifest.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "go2rtc",
|
||||
"icons": [
|
||||
{
|
||||
"src": "https://alexxit.github.io/go2rtc/icons/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "https://alexxit.github.io/go2rtc/icons/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#000000"
|
||||
}
|
@@ -72,6 +72,22 @@ User-Agent: `Mozilla/5.0 (X11; Linux armv7l) AppleWebKit/537.36 (KHTML, like Gec
|
||||
|
||||
https://webrtc.org/getting-started/unified-plan-transition-guide?hl=en
|
||||
|
||||
## Web Icons
|
||||
|
||||
[Favicon checker](https://realfavicongenerator.net/), skip:
|
||||
|
||||
- Windows 8 and 10 (`browserconfig.xml`)
|
||||
- Mac OS X El Capitan Safari
|
||||
|
||||
```html
|
||||
<!-- iOS Safari -->
|
||||
<link rel="apple-touch-icon" href="https://alexxit.github.io/go2rtc/icons/apple-touch-icon-180x180.png" sizes="180x180">
|
||||
<!-- Classic, desktop browsers -->
|
||||
<link rel="icon" href="https://alexxit.github.io/go2rtc/icons/favicon.ico">
|
||||
<!-- Android Chrome -->
|
||||
<link rel="manifest" href="https://alexxit.github.io/go2rtc/manifest.json">
|
||||
```
|
||||
|
||||
## Useful links
|
||||
|
||||
- https://www.webrtc-experiment.com/DetectRTC/
|
||||
|
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Add Stream</title>
|
||||
<title>go2rtc - Add Stream</title>
|
||||
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<style>
|
||||
|
@@ -1,10 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>File Editor</title>
|
||||
<title>go2rtc - File Editor</title>
|
||||
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<script src="https://unpkg.com/ace-builds@1.33.0/src-min/ace.js"></script>
|
||||
<script src="https://unpkg.com/ace-builds@1.33.1/src-min/ace.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
@@ -31,7 +31,7 @@
|
||||
<script>
|
||||
let dump;
|
||||
|
||||
ace.config.set('basePath', 'https://unpkg.com/ace-builds@1.33.0/src-min/');
|
||||
ace.config.set('basePath', 'https://unpkg.com/ace-builds@1.33.1/src-min/');
|
||||
const editor = ace.edit('config', {
|
||||
mode: 'ace/mode/yaml',
|
||||
});
|
||||
|
@@ -4,6 +4,9 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<link rel="apple-touch-icon" href="https://alexxit.github.io/go2rtc/icons/apple-touch-icon-180x180.png" sizes="180x180">
|
||||
<link rel="icon" href="https://alexxit.github.io/go2rtc/icons/favicon.ico">
|
||||
<link rel="manifest" href="https://alexxit.github.io/go2rtc/manifest.json">
|
||||
<title>go2rtc</title>
|
||||
<style>
|
||||
body {
|
||||
@@ -136,7 +139,7 @@
|
||||
const isChecked = checkboxStates[name] ? 'checked' : '';
|
||||
tr.innerHTML =
|
||||
`<td><label><input type="checkbox" name="${name}" ${isChecked}>${name}</label></td>` +
|
||||
`<td><a href="api/streams?src=${src}">${online} / info</a></td>` +
|
||||
`<td><a href="api/streams?src=${src}">${online} / info</a> / <a href="api/streams?src=${src}&video=all&audio=allµphone">probe</a></td>` +
|
||||
`<td>${links}</td>`;
|
||||
}
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Logs</title>
|
||||
<title>go2rtc - Logs</title>
|
||||
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<style>
|
||||
|
@@ -2,6 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="apple-touch-icon" href="https://alexxit.github.io/go2rtc/icons/apple-touch-icon-180x180.png" sizes="180x180">
|
||||
<link rel="icon" href="https://alexxit.github.io/go2rtc/icons/favicon.ico">
|
||||
<link rel="manifest" href="https://alexxit.github.io/go2rtc/manifest.json">
|
||||
<title>go2rtc - Stream</title>
|
||||
<style>
|
||||
body {
|
||||
|
@@ -19,7 +19,7 @@ export class VideoRTC extends HTMLElement {
|
||||
super();
|
||||
|
||||
this.DISCONNECT_TIMEOUT = 5000;
|
||||
this.RECONNECT_TIMEOUT = 30000;
|
||||
this.RECONNECT_TIMEOUT = 15000;
|
||||
|
||||
this.CODECS = [
|
||||
'avc1.640029', // H.264 high 4.1 (Chromecast 1st and 2nd Gen)
|
||||
@@ -70,6 +70,7 @@ export class VideoRTC extends HTMLElement {
|
||||
* @type {RTCConfiguration}
|
||||
*/
|
||||
this.pcConfig = {
|
||||
bundlePolicy: 'max-bundle',
|
||||
iceServers: [{urls: 'stun:stun.l.google.com:19302'}],
|
||||
sdpSemantics: 'unified-plan', // important for Chromecast 1
|
||||
};
|
||||
@@ -247,6 +248,11 @@ export class VideoRTC extends HTMLElement {
|
||||
|
||||
this.appendChild(this.video);
|
||||
|
||||
this.video.addEventListener('error', ev => {
|
||||
console.warn(ev);
|
||||
if (this.ws) this.ws.close(); // run reconnect for broken MSE stream
|
||||
});
|
||||
|
||||
// all Safari lies about supported audio codecs
|
||||
const m = window.navigator.userAgent.match(/Version\/(\d+).+Safari/);
|
||||
if (m) {
|
||||
|
Reference in New Issue
Block a user