diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index e709fad72..f0293f3e6 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -15,99 +15,113 @@ jobs:
env: { CGO_ENABLED: 0 }
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- name: Setup Go
- uses: actions/setup-go@v4
+ uses: actions/setup-go@v5
with: { go-version: '1.21' }
- name: Build go2rtc_win64
env: { GOOS: windows, GOARCH: amd64 }
run: go build -ldflags "-s -w" -trimpath
- name: Upload go2rtc_win64
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with: { name: go2rtc_win64, path: go2rtc.exe }
- name: Build go2rtc_win32
env: { GOOS: windows, GOARCH: 386 }
run: go build -ldflags "-s -w" -trimpath
- name: Upload go2rtc_win32
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with: { name: go2rtc_win32, path: go2rtc.exe }
- name: Build go2rtc_win_arm64
env: { GOOS: windows, GOARCH: arm64 }
run: go build -ldflags "-s -w" -trimpath
- name: Upload go2rtc_win_arm64
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with: { name: go2rtc_win_arm64, path: go2rtc.exe }
- name: Build go2rtc_linux_amd64
env: { GOOS: linux, GOARCH: amd64 }
run: go build -ldflags "-s -w" -trimpath
- name: Upload go2rtc_linux_amd64
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with: { name: go2rtc_linux_amd64, path: go2rtc }
- name: Build go2rtc_linux_i386
env: { GOOS: linux, GOARCH: 386 }
run: go build -ldflags "-s -w" -trimpath
- name: Upload go2rtc_linux_i386
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with: { name: go2rtc_linux_i386, path: go2rtc }
- name: Build go2rtc_linux_arm64
env: { GOOS: linux, GOARCH: arm64 }
run: go build -ldflags "-s -w" -trimpath
- name: Upload go2rtc_linux_arm64
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with: { name: go2rtc_linux_arm64, path: go2rtc }
- name: Build go2rtc_linux_arm
env: { GOOS: linux, GOARCH: arm, GOARM: 7 }
run: go build -ldflags "-s -w" -trimpath
- name: Upload go2rtc_linux_arm
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with: { name: go2rtc_linux_arm, path: go2rtc }
- name: Build go2rtc_linux_armv6
env: { GOOS: linux, GOARCH: arm, GOARM: 6 }
run: go build -ldflags "-s -w" -trimpath
- name: Upload go2rtc_linux_armv6
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with: { name: go2rtc_linux_armv6, path: go2rtc }
- name: Build go2rtc_linux_mipsel
env: { GOOS: linux, GOARCH: mipsle }
run: go build -ldflags "-s -w" -trimpath
- name: Upload go2rtc_linux_mipsel
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with: { name: go2rtc_linux_mipsel, path: go2rtc }
- name: Build go2rtc_mac_amd64
env: { GOOS: darwin, GOARCH: amd64 }
run: go build -ldflags "-s -w" -trimpath
- name: Upload go2rtc_mac_amd64
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with: { name: go2rtc_mac_amd64, path: go2rtc }
- name: Build go2rtc_mac_arm64
env: { GOOS: darwin, GOARCH: arm64 }
run: go build -ldflags "-s -w" -trimpath
- name: Upload go2rtc_mac_arm64
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with: { name: go2rtc_mac_arm64, path: go2rtc }
+ - name: Build go2rtc_freebsd_amd64
+ env: { GOOS: freebsd, GOARCH: amd64 }
+ run: go build -ldflags "-s -w" -trimpath
+ - name: Upload go2rtc_freebsd_amd64
+ uses: actions/upload-artifact@v3
+ with: { name: go2rtc_freebsd_amd64, path: go2rtc }
+
+ - name: Build go2rtc_freebsd_arm64
+ env: { GOOS: freebsd, GOARCH: arm64 }
+ run: go build -ldflags "-s -w" -trimpath
+ - name: Upload go2rtc_freebsd_arm64
+ uses: actions/upload-artifact@v3
+ with: { name: go2rtc_freebsd_arm64, path: go2rtc }
+
docker-master:
name: Build docker master
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- name: Docker meta
id: meta
- uses: docker/metadata-action@v4
+ uses: docker/metadata-action@v5
with:
images: ${{ github.repository }}
tags: |
@@ -116,20 +130,20 @@ jobs:
type=match,pattern=v(.*),group=1
- name: Set up QEMU
- uses: docker/setup-qemu-action@v2
+ uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v2
+ uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
if: github.event_name != 'pull_request'
- uses: docker/login-action@v2
+ uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
- uses: docker/build-push-action@v4
+ uses: docker/build-push-action@v5
with:
context: .
platforms: |
@@ -148,11 +162,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- name: Docker meta
id: meta-hw
- uses: docker/metadata-action@v4
+ uses: docker/metadata-action@v5
with:
images: ${{ github.repository }}
flavor: |
@@ -164,20 +178,20 @@ jobs:
type=match,pattern=v(.*),group=1
- name: Set up QEMU
- uses: docker/setup-qemu-action@v2
+ uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v2
+ uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
if: github.event_name != 'pull_request'
- uses: docker/login-action@v2
+ uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
- uses: docker/build-push-action@v4
+ uses: docker/build-push-action@v5
with:
context: .
file: hardware.Dockerfile
diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml
index f3d85c4d2..4d0e2e670 100644
--- a/.github/workflows/gh-pages.yml
+++ b/.github/workflows/gh-pages.yml
@@ -25,13 +25,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- name: Setup Pages
- uses: actions/configure-pages@v3
+ uses: actions/configure-pages@v4
- name: Upload artifact
- uses: actions/upload-pages-artifact@v1
+ uses: actions/upload-pages-artifact@v3
with:
path: './website'
- name: Deploy to GitHub Pages
id: deployment
- uses: actions/deploy-pages@v2
+ uses: actions/deploy-pages@v4
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index a98a83e53..b23faf535 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -21,10 +21,10 @@ jobs:
GOARCH: ${{ matrix.arch }}
steps:
- name: Checkout
- uses: actions/checkout@v2
+ uses: actions/checkout@v4
- name: Setup Go
- uses: actions/setup-go@v2
+ uses: actions/setup-go@v5
with:
go-version: '1.21'
@@ -70,13 +70,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- name: Set up QEMU
- uses: docker/setup-qemu-action@v2
+ uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v2
+ uses: docker/setup-buildx-action@v3
- name: Build and push
- uses: docker/build-push-action@v4
+ uses: docker/build-push-action@v5
with:
context: .
platforms: linux/${{ matrix.platform }}
@@ -89,7 +89,7 @@ jobs:
- name: Build and push Hardware
if: matrix.platform == 'amd64'
- uses: docker/build-push-action@v4
+ uses: docker/build-push-action@v5
with:
context: .
file: hardware.Dockerfile
diff --git a/.gitignore b/.gitignore
index d948344ed..c97dd7cea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,4 +6,6 @@ go2rtc.json
0_test.go
-.goreload
\ No newline at end of file
+.DS_Store
+
+.goreload
diff --git a/README.md b/README.md
index aaed9410c..3bfe3a483 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,12 @@
-# go2rtc
+
-[![](https://img.shields.io/github/stars/AlexxIT/go2rtc?style=flat-square&logo=github)](https://github.com/AlexxIT/go2rtc/stargazers)
-[![](https://img.shields.io/docker/pulls/alexxit/go2rtc?style=flat-square&logo=docker&logoColor=white&label=pulls)](https://hub.docker.com/r/alexxit/go2rtc)
-[![](https://img.shields.io/github/downloads/AlexxIT/go2rtc/total?color=blue&style=flat-square&logo=github)](https://github.com/AlexxIT/go2rtc/releases)
-[![](https://goreportcard.com/badge/github.com/AlexxIT/go2rtc)](https://goreportcard.com/report/github.com/AlexxIT/go2rtc)
+ ![go2rtc](assets/logo.png)
+
+ [![stars](https://img.shields.io/github/stars/AlexxIT/go2rtc?style=flat-square&logo=github)](https://github.com/AlexxIT/go2rtc/stargazers)
+ [![docker pulls](https://img.shields.io/docker/pulls/alexxit/go2rtc?style=flat-square&logo=docker&logoColor=white&label=pulls)](https://hub.docker.com/r/alexxit/go2rtc)
+ [![releases](https://img.shields.io/github/downloads/AlexxIT/go2rtc/total?color=blue&style=flat-square&logo=github)](https://github.com/AlexxIT/go2rtc/releases)
+ [![goreport](https://goreportcard.com/badge/github.com/AlexxIT/go2rtc)](https://goreportcard.com/report/github.com/AlexxIT/go2rtc)
+
Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg, RTMP, etc.
@@ -34,6 +37,7 @@ Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg
- [GStreamer](https://gstreamer.freedesktop.org/) framework pipeline idea
- [MediaSoup](https://mediasoup.org/) framework routing idea
- HomeKit Accessory Protocol from [@brutella](https://github.com/brutella/hap)
+- creator of the project's logo [@v_novoseltsev](https://www.instagram.com/v_novoseltsev)
---
@@ -146,13 +150,13 @@ Container [alexxit/go2rtc](https://hub.docker.com/r/alexxit/go2rtc) with support
Latest, but maybe unstable version:
-- Binary: GitHub > [Actions](https://github.com/AlexxIT/go2rtc/actions) > [Build and Push](https://github.com/AlexxIT/go2rtc/actions/workflows/build.yml) > latest run > Artifacts section (you should be logged in to GitHub)
+- Binary: [latest nightly release](https://nightly.link/AlexxIT/go2rtc/workflows/build/master)
- Docker: `alexxit/go2rtc:master` or `alexxit/go2rtc:master-hardware` versions
- Hass Add-on: `go2rtc master` or `go2rtc master hardware` versions
## Configuration
-- by default go2rtc will search `go2rtc.yaml` in the current work dirrectory
+- by default go2rtc will search `go2rtc.yaml` in the current work directory
- `api` server will start on default **1984 port** (TCP)
- `rtsp` server will start on default **8554 port** (TCP)
- `webrtc` will use port **8555** (TCP/UDP) for connections
@@ -213,6 +217,7 @@ Supported for sources:
- [TP-Link Tapo](#source-tapo) cameras
- [Hikvision ISAPI](#source-isapi) cameras
- [Roborock vacuums](#source-roborock) models with cameras
+- [Exec](#source-exec) audio on server
- [Any Browser](#incoming-browser) as IP-camera
Two way audio can be used in browser with [WebRTC](#module-webrtc) technology. The browser will give access to the microphone only for HTTPS sites ([read more](https://stackoverflow.com/questions/52759992/how-to-access-camera-and-microphone-in-chrome-without-https)).
@@ -230,7 +235,7 @@ streams:
amcrest_doorbell:
- rtsp://username:password@192.168.1.123:554/cam/realmonitor?channel=1&subtype=0#backchannel=0
unifi_camera: rtspx://192.168.1.123:7441/fD6ouM72bWoFijxK
- glichy_camera: ffmpeg:rstp://username:password@192.168.1.123/live/ch00_1
+ glichy_camera: ffmpeg:rtsp://username:password@192.168.1.123/live/ch00_1
```
**Recommendations**
@@ -265,7 +270,7 @@ streams:
#### Source: RTMP
-You can get stream from RTMP server, for example [Frigate](https://docs.frigate.video/configuration/rtmp).
+You can get stream from RTMP server, for example [Nginx with nginx-rtmp-module](https://github.com/arut/nginx-rtmp-module).
```yaml
streams:
@@ -403,20 +408,30 @@ Exec source can run any external application and expect data from it. Two transp
If you want to use **RTSP** transport - the command must contain the `{output}` argument in any place. On launch, it will be replaced by the local address of the RTSP server.
-**pipe** reads data from app stdout in different formats: **MJPEG**, **H.264/H.265 bitstream**, **MPEG-TS**.
+**pipe** reads data from app stdout in different formats: **MJPEG**, **H.264/H.265 bitstream**, **MPEG-TS**. Also pipe can write data to app stdin in two formats: **PCMA** and **PCM/48000**.
The source can be used with:
- [FFmpeg](https://ffmpeg.org/) - go2rtc ffmpeg source just a shortcut to exec source
+- [FFplay](https://ffmpeg.org/ffplay.html) - play audio on your server
- [GStreamer](https://gstreamer.freedesktop.org/)
- [Raspberry Pi Cameras](https://www.raspberrypi.com/documentation/computers/camera_software.html)
- any your own software
+Pipe commands support parameters (format: `exec:{command}#{param1}#{param2}`):
+
+- `killsignal` - signal which will be send to stop the process (numeric form)
+- `killtimeout` - time in seconds for forced termination with sigkill
+- `backchannel` - enable backchannel for two-way audio
+
```yaml
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 -
+ 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
```
#### Source: Echo
@@ -579,7 +594,7 @@ streams:
Any cameras in WebRTC format are supported. But at the moment Home Assistant only supports some [Nest](https://www.home-assistant.io/integrations/nest/) cameras in this fomat.
-The Nest API only allows you to get a link to a stream for 5 minutes. So every 5 minutes the stream will be reconnected.
+**Important.** The Nest API only allows you to get a link to a stream for 5 minutes. Do not use this with Frigate! If the stream expires, Frigate will consume all available ram on your machine within seconds. It's recommended to use [Nest source](#source-nest) - it supports extending the stream.
```yaml
streams:
@@ -610,7 +625,7 @@ streams:
*[New in v1.6.0](https://github.com/AlexxIT/go2rtc/releases/tag/v1.6.0)*
-Currently only WebRTC cameras are supported. Stream reconnects every 5 minutes.
+Currently only WebRTC cameras are supported.
For simplicity, it is recommended to connect the Nest/WebRTC camera to the [Home Assistant](#source-hass). But if you can somehow get the below parameters - Nest/WebRTC source will work without Hass.
@@ -640,7 +655,7 @@ This source type support four connection formats.
**whep**
-[WebRTC/WHEP](https://www.ietf.org/id/draft-murillo-whep-02.html) - is an unapproved standard for WebRTC video/audio viewers. But it may already be supported in some third-party software. It is supported in go2rtc.
+[WebRTC/WHEP](https://datatracker.ietf.org/doc/draft-murillo-whep/) - is replaced by [WebRTC/WISH](https://datatracker.ietf.org/doc/charter-ietf-wish/02/) standard for WebRTC video/audio viewers. But it may already be supported in some third-party software. It is supported in go2rtc.
**go2rtc**
@@ -1352,6 +1367,7 @@ streams:
**Distributions**
- [Alpine Linux](https://pkgs.alpinelinux.org/packages?name=go2rtc)
+- [Arch User Repository](https://linux-packages.com/aur/package/go2rtc)
- [Gentoo](https://github.com/inode64/inode64-overlay/tree/main/media-video/go2rtc)
- [NixOS](https://search.nixos.org/packages?query=go2rtc)
- [Proxmox Helper Scripts](https://tteck.github.io/Proxmox/)
diff --git a/assets/logo.png b/assets/logo.png
new file mode 100644
index 000000000..2f5bab6f8
Binary files /dev/null and b/assets/logo.png differ
diff --git a/hardware.Dockerfile b/hardware.Dockerfile
index 238ede697..0aa85374c 100644
--- a/hardware.Dockerfile
+++ b/hardware.Dockerfile
@@ -53,7 +53,8 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked --mount=type=cache,t
python3 curl jq \
intel-media-va-driver-non-free \
mesa-va-drivers \
- libasound2-plugins
+ libasound2-plugins && \
+ apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --link --from=rootfs / /
diff --git a/internal/api/api.go b/internal/api/api.go
index db23360a0..86817bd01 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -11,9 +11,9 @@ import (
"strings"
"sync"
"syscall"
+ "time"
"github.com/AlexxIT/go2rtc/internal/app"
- "github.com/AlexxIT/go2rtc/pkg/shell"
"github.com/rs/zerolog"
)
@@ -96,7 +96,10 @@ func listen(network, address string) {
Port = ln.Addr().(*net.TCPAddr).Port
}
- server := http.Server{Handler: Handler}
+ server := http.Server{
+ Handler: Handler,
+ ReadHeaderTimeout: 5 * time.Second, // Example: Set to 5 seconds
+ }
if err = server.Serve(ln); err != nil {
log.Fatal().Err(err).Msg("[api] serve")
}
@@ -126,8 +129,9 @@ func tlsListen(network, address, certFile, keyFile string) {
log.Info().Str("addr", address).Msg("[api] tls listen")
server := &http.Server{
- Handler: Handler,
- TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}},
+ Handler: Handler,
+ TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}},
+ ReadHeaderTimeout: 5 * time.Second,
}
if err = server.ServeTLS(ln, "", ""); err != nil {
log.Fatal().Err(err).Msg("[api] tls serve")
@@ -251,7 +255,15 @@ func restartHandler(w http.ResponseWriter, r *http.Request) {
return
}
- go shell.Restart()
+ path, err := os.Executable()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ log.Debug().Msgf("[api] restart %s", path)
+
+ go syscall.Exec(path, os.Args, os.Environ())
}
func logHandler(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/app/app.go b/internal/app/app.go
index b27437dbb..d67b9025a 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -5,6 +5,7 @@ import (
"flag"
"fmt"
"os"
+ "os/exec"
"path/filepath"
"runtime"
"strings"
@@ -24,14 +25,34 @@ var Info = map[string]any{
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.Parse()
if version {
- fmt.Println("Current version: ", Version)
+ fmt.Println("Current version:", Version)
+ os.Exit(0)
+ }
+
+ if daemon {
+ args := os.Args[1:]
+ for i, arg := range args {
+ if arg == "-daemon" {
+ args[i] = ""
+ }
+ }
+ // Re-run the program in background and exit
+ cmd := exec.Command(os.Args[0], args...)
+ if err := cmd.Start(); err != nil {
+ log.Fatal().Err(err).Send()
+ }
+ fmt.Println("Running in daemon mode with PID:", cmd.Process.Pid)
os.Exit(0)
}
diff --git a/internal/exec/exec.go b/internal/exec/exec.go
index 36dacfaa6..e3ffe5a2d 100644
--- a/internal/exec/exec.go
+++ b/internal/exec/exec.go
@@ -5,8 +5,10 @@ import (
"encoding/hex"
"errors"
"fmt"
+ "net/url"
"os"
"os/exec"
+ "strings"
"sync"
"time"
@@ -17,6 +19,7 @@ import (
"github.com/AlexxIT/go2rtc/pkg/magic"
pkg "github.com/AlexxIT/go2rtc/pkg/rtsp"
"github.com/AlexxIT/go2rtc/pkg/shell"
+ "github.com/AlexxIT/go2rtc/pkg/stdin"
"github.com/rs/zerolog"
)
@@ -44,17 +47,19 @@ func Init() {
log = app.GetLogger("exec")
}
-func execHandle(url string) (core.Producer, error) {
+func execHandle(rawURL string) (core.Producer, error) {
var path string
- args := shell.QuoteSplit(url[5:]) // remove `exec:`
+ rawURL, rawQuery, _ := strings.Cut(rawURL, "#")
+
+ args := shell.QuoteSplit(rawURL[5:]) // remove `exec:`
for i, arg := range args {
if arg == "{output}" {
if rtsp.Port == "" {
return nil, errors.New("rtsp module disabled")
}
- sum := md5.Sum([]byte(url))
+ sum := md5.Sum([]byte(rawURL))
path = "/" + hex.EncodeToString(sum[:])
args[i] = "rtsp://127.0.0.1:" + rtsp.Port + path
break
@@ -67,14 +72,19 @@ func execHandle(url string) (core.Producer, error) {
}
if path == "" {
- return handlePipe(url, cmd)
+ query := streams.ParseQuery(rawQuery)
+ return handlePipe(rawURL, cmd, query)
}
- return handleRTSP(url, path, cmd)
+ return handleRTSP(rawURL, path, cmd)
}
-func handlePipe(url string, cmd *exec.Cmd) (core.Producer, error) {
- r, err := PipeCloser(cmd)
+func handlePipe(_ string, cmd *exec.Cmd, query url.Values) (core.Producer, error) {
+ if query.Get("backchannel") == "1" {
+ return stdin.NewClient(cmd)
+ }
+
+ r, err := PipeCloser(cmd, query)
if err != nil {
return nil, err
}
@@ -108,7 +118,7 @@ func handleRTSP(url, path string, cmd *exec.Cmd) (core.Producer, error) {
waitersMu.Unlock()
}()
- log.Debug().Str("url", url).Msg("[exec] run")
+ log.Debug().Str("url", url).Str("cmd", fmt.Sprintf("%s", strings.Join(cmd.Args, " "))).Msg("[exec] run")
ts := time.Now()
@@ -144,6 +154,8 @@ func handleRTSP(url, path string, cmd *exec.Cmd) (core.Producer, error) {
// internal
-var log zerolog.Logger
-var waiters = map[string]chan core.Producer{}
-var waitersMu sync.Mutex
+var (
+ log zerolog.Logger
+ waiters = map[string]chan core.Producer{}
+ waitersMu sync.Mutex
+)
diff --git a/internal/exec/pipe.go b/internal/exec/pipe.go
index a76f381cb..12ea136b7 100644
--- a/internal/exec/pipe.go
+++ b/internal/exec/pipe.go
@@ -2,29 +2,55 @@ 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) (io.ReadCloser, error) {
+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}, nil
+ return &pipeCloser{bufio.NewReaderSize(stdout, core.BufferSize), stdout, cmd, query}, nil
}
type pipeCloser struct {
io.Reader
io.Closer
- cmd *exec.Cmd
+ cmd *exec.Cmd
+ query url.Values
}
-func (p pipeCloser) Close() error {
- return core.Any(p.Closer.Close(), p.cmd.Process.Kill(), p.cmd.Wait())
+func (p *pipeCloser) Close() error {
+ return errors.Join(p.Closer.Close(), p.Kill(), p.Wait())
+}
+
+func (p *pipeCloser) Kill() error {
+ if s := p.query.Get("killsignal"); s != "" {
+ log.Trace().Msgf("[exec] kill with custom sig=%s", s)
+ sig := syscall.Signal(core.Atoi(s))
+ return p.cmd.Process.Signal(sig)
+ }
+ return p.cmd.Process.Kill()
+}
+
+func (p *pipeCloser) Wait() error {
+ if s := p.query.Get("killtimeout"); s != "" {
+ timeout := time.Duration(core.Atoi(s)) * time.Second
+ timer := time.AfterFunc(timeout, func() {
+ log.Trace().Msgf("[exec] kill after timeout=%s", s)
+ _ = p.cmd.Process.Kill()
+ })
+ defer timer.Stop() // stop timer if Wait ends before timeout
+ }
+ return p.cmd.Wait()
}
diff --git a/internal/ffmpeg/device/device_freebsd.go b/internal/ffmpeg/device/device_freebsd.go
new file mode 100644
index 000000000..f3a26a304
--- /dev/null
+++ b/internal/ffmpeg/device/device_freebsd.go
@@ -0,0 +1,97 @@
+package device
+
+import (
+ "net/url"
+ "os"
+ "os/exec"
+ "regexp"
+ "strings"
+
+ "github.com/AlexxIT/go2rtc/internal/api"
+ "github.com/AlexxIT/go2rtc/pkg/core"
+)
+
+func queryToInput(query url.Values) string {
+ if video := query.Get("video"); video != "" {
+ // https://ffmpeg.org/ffmpeg-devices.html#video4linux2_002c-v4l2
+ input := "-f v4l2"
+
+ for key, value := range query {
+ switch key {
+ case "resolution":
+ input += " -video_size " + value[0]
+ case "video_size", "pixel_format", "input_format", "framerate", "use_libv4l2":
+ input += " -" + key + " " + value[0]
+ }
+ }
+
+ return input + " -i " + indexToItem(videos, video)
+ }
+
+ if audio := query.Get("audio"); audio != "" {
+ input := "-f oss"
+
+ for key, value := range query {
+ switch key {
+ case "channels", "sample_rate":
+ input += " -" + key + " " + value[0]
+ }
+ }
+
+ return input + " -i " + indexToItem(audios, audio)
+ }
+
+ return ""
+}
+
+func initDevices() {
+ files, err := os.ReadDir("/dev")
+ if err != nil {
+ return
+ }
+
+ for _, file := range files {
+ if !strings.HasPrefix(file.Name(), core.KindVideo) {
+ continue
+ }
+
+ name := "/dev/" + file.Name()
+
+ cmd := exec.Command(
+ Bin, "-hide_banner", "-f", "v4l2", "-list_formats", "all", "-i", name,
+ )
+ b, _ := cmd.CombinedOutput()
+
+ // [video4linux2,v4l2 @ 0x860b92280] Raw : yuyv422 : YUYV 4:2:2 : 640x480 160x120 176x144 320x176 320x240 352x288 432x240 544x288 640x360 752x416 800x448 800x600 864x480 960x544 960x720 1024x576 1184x656 1280x720 1280x960
+ // [video4linux2,v4l2 @ 0x860b92280] Compressed: mjpeg : Motion-JPEG : 640x480 160x120 176x144 320x176 320x240 352x288 432x240 544x288 640x360 752x416 800x448 800x600 864x480 960x544 960x720 1024x576 1184x656 1280x720 1280x960
+ re := regexp.MustCompile("(Raw *|Compressed): +(.+?) : +(.+?) : (.+)")
+ m := re.FindAllStringSubmatch(string(b), -1)
+ for _, i := range m {
+ size, _, _ := strings.Cut(i[4], " ")
+ stream := &api.Source{
+ Name: i[3],
+ Info: i[4],
+ URL: "ffmpeg:device?video=" + name + "&input_format=" + i[2] + "&video_size=" + size,
+ }
+
+ if i[1] != "Compressed" {
+ stream.URL += "#video=h264#hardware"
+ }
+
+ videos = append(videos, name)
+ streams = append(streams, stream)
+ }
+ }
+
+ err = exec.Command(Bin, "-f", "oss", "-i", "/dev/dsp", "-t", "1", "-f", "null", "-").Run()
+ if err == nil {
+ stream := &api.Source{
+ Name: "OSS default",
+ Info: " ",
+ URL: "ffmpeg:device?audio=default&channels=1&sample_rate=16000audio=opus",
+ }
+
+ audios = append(audios, "default")
+ streams = append(streams, stream)
+ }
+}
diff --git a/internal/ffmpeg/hardware/hardware_freebsd.go b/internal/ffmpeg/hardware/hardware_freebsd.go
new file mode 100644
index 000000000..6ef753acd
--- /dev/null
+++ b/internal/ffmpeg/hardware/hardware_freebsd.go
@@ -0,0 +1,60 @@
+package hardware
+
+import (
+ "runtime"
+
+ "github.com/AlexxIT/go2rtc/internal/api"
+)
+
+const (
+ ProbeV4L2M2MH264 = "-f lavfi -i testsrc2 -t 1 -c h264_v4l2m2m -f null -"
+ ProbeV4L2M2MH265 = "-f lavfi -i testsrc2 -t 1 -c hevc_v4l2m2m -f null -"
+ ProbeRKMPPH264 = "-f lavfi -i testsrc2 -t 1 -c h264_rkmpp_encoder -f null -"
+ ProbeRKMPPH265 = "-f lavfi -i testsrc2 -t 1 -c hevc_rkmpp_encoder -f null -"
+)
+
+func ProbeAll(bin string) []*api.Source {
+ return []*api.Source{
+ {
+ Name: runToString(bin, ProbeV4L2M2MH264),
+ URL: "ffmpeg:...#video=h264#hardware=" + EngineV4L2M2M,
+ },
+ {
+ Name: runToString(bin, ProbeV4L2M2MH265),
+ URL: "ffmpeg:...#video=h265#hardware=" + EngineV4L2M2M,
+ },
+ {
+ Name: runToString(bin, ProbeRKMPPH264),
+ URL: "ffmpeg:...#video=h264#hardware=" + EngineRKMPP,
+ },
+ {
+ Name: runToString(bin, ProbeRKMPPH265),
+ URL: "ffmpeg:...#video=h265#hardware=" + EngineRKMPP,
+ },
+ }
+}
+
+func ProbeHardware(bin, name string) string {
+ if runtime.GOARCH == "arm64" || runtime.GOARCH == "arm" {
+ switch name {
+ case "h264":
+ if run(bin, ProbeV4L2M2MH264) {
+ return EngineV4L2M2M
+ }
+ if run(bin, ProbeRKMPPH264) {
+ return EngineRKMPP
+ }
+ case "h265":
+ if run(bin, ProbeV4L2M2MH265) {
+ return EngineV4L2M2M
+ }
+ if run(bin, ProbeRKMPPH265) {
+ return EngineRKMPP
+ }
+ }
+
+ return EngineSoftware
+ }
+
+ return EngineSoftware
+}
diff --git a/internal/mp4/mp4.go b/internal/mp4/mp4.go
index 654d9ae71..78708a35f 100644
--- a/internal/mp4/mp4.go
+++ b/internal/mp4/mp4.go
@@ -91,8 +91,7 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) {
return
}
- src := query.Get("src")
- stream := streams.Get(src)
+ stream := streams.GetOrPatch(query)
if stream == nil {
http.Error(w, api.StreamNotFound, http.StatusNotFound)
return
diff --git a/internal/streams/helpers.go b/internal/streams/helpers.go
index e59dab77c..2ead1aa3e 100644
--- a/internal/streams/helpers.go
+++ b/internal/streams/helpers.go
@@ -6,6 +6,9 @@ import (
)
func ParseQuery(s string) url.Values {
+ if len(s) == 0 {
+ return nil
+ }
params := url.Values{}
for _, key := range strings.Split(s, "#") {
var value string
diff --git a/internal/streams/stream.go b/internal/streams/stream.go
index 75b855b8d..0a8108e24 100644
--- a/internal/streams/stream.go
+++ b/internal/streams/stream.go
@@ -22,8 +22,13 @@ func NewStream(source any) *Stream {
}
case []any:
s := new(Stream)
- for _, source := range source {
- s.producers = append(s.producers, NewProducer(source.(string)))
+ for _, src := range source {
+ str, ok := src.(string)
+ if !ok {
+ log.Error().Msgf("[stream] NewStream: Expected string, got %v", src)
+ continue
+ }
+ s.producers = append(s.producers, NewProducer(str))
}
return s
case map[string]any:
diff --git a/internal/webrtc/client.go b/internal/webrtc/client.go
index 2fac57731..ae1a455b8 100644
--- a/internal/webrtc/client.go
+++ b/internal/webrtc/client.go
@@ -7,6 +7,7 @@ import (
"net/http"
"net/url"
"strings"
+ "sync"
"time"
"github.com/AlexxIT/go2rtc/internal/api/ws"
@@ -48,7 +49,9 @@ func streamsHandler(rawURL string) (core.Producer, error) {
}
case "http", "https":
- if format == "wyze" {
+ if format == "milestone" {
+ return milestoneClient(rawURL, query)
+ } else if format == "wyze" {
// https://github.com/mrlt8/docker-wyze-bridge
return wyzeClient(rawURL)
} else {
@@ -80,6 +83,7 @@ func go2rtcClient(url string) (core.Producer, error) {
// 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"
@@ -89,7 +93,9 @@ func go2rtcClient(url string) (core.Producer, error) {
case *pion.ICECandidate:
s := msg.ToJSON().Candidate
log.Trace().Str("candidate", s).Msg("[webrtc] local ")
+ connMu.Lock()
_ = conn.WriteJSON(&ws.Message{Type: "webrtc/candidate", Value: s})
+ connMu.Unlock()
case pion.PeerConnectionState:
switch msg {
@@ -116,9 +122,9 @@ func go2rtcClient(url string) (core.Producer, error) {
// 4. Send offer
msg := &ws.Message{Type: "webrtc/offer", Value: offer}
- if err = conn.WriteJSON(msg); err != nil {
- return nil, err
- }
+ connMu.Lock()
+ _ = conn.WriteJSON(msg)
+ connMu.Unlock()
// 5. Get answer
if err = conn.ReadJSON(msg); err != nil {
@@ -189,10 +195,10 @@ func whepClient(url string) (core.Producer, error) {
}
req, err := http.NewRequest("POST", url, strings.NewReader(offer))
- req.Header.Set("Content-Type", MimeSDP)
if err != nil {
return nil, err
}
+ req.Header.Set("Content-Type", MimeSDP)
client := http.Client{Timeout: time.Second * 5000}
defer client.CloseIdleConnections()
diff --git a/internal/webrtc/milestone.go b/internal/webrtc/milestone.go
new file mode 100644
index 000000000..b4e695c97
--- /dev/null
+++ b/internal/webrtc/milestone.go
@@ -0,0 +1,218 @@
+package webrtc
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "github.com/AlexxIT/go2rtc/pkg/core"
+ "github.com/AlexxIT/go2rtc/pkg/tcp"
+ "github.com/AlexxIT/go2rtc/pkg/webrtc"
+ pion "github.com/pion/webrtc/v3"
+)
+
+// This package handles the Milestone WebRTC session lifecycle, including authentication,
+// session creation, and session update with an SDP answer. It is designed to be used with
+// a specific URL format that encodes session parameters. For example:
+// webrtc:https://milestone-host/api#format=milestone#username=User#password=TestPassword#cameraId=a539f254-af05-4d67-a1bb-cd9b3c74d122
+//
+// https://github.com/milestonesys/mipsdk-samples-protocol/tree/main/WebRTC_JavaScript
+
+type milestoneAPI struct {
+ url string
+ query url.Values
+ token string
+ sessionID string
+}
+
+func (m *milestoneAPI) GetToken() error {
+ data := url.Values{
+ "client_id": {"GrantValidatorClient"},
+ "grant_type": {"password"},
+ "username": m.query["username"],
+ "password": m.query["password"],
+ }
+
+ req, err := http.NewRequest("POST", m.url+"/IDP/connect/token", strings.NewReader(data.Encode()))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ // support httpx protocol
+ res, err := tcp.Do(req)
+ if err != nil {
+ return err
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ return errors.New("milesone: authentication failed: " + res.Status)
+ }
+
+ var payload map[string]interface{}
+ if err = json.NewDecoder(res.Body).Decode(&payload); err != nil {
+ return err
+ }
+
+ token, ok := payload["access_token"].(string)
+ if !ok {
+ return errors.New("milesone: token not found in the response")
+ }
+
+ m.token = token
+
+ return nil
+}
+
+func parseFloat(s string) float64 {
+ if s == "" {
+ return 0
+ }
+ f, _ := strconv.ParseFloat(s, 64)
+ return f
+}
+
+func (m *milestoneAPI) GetOffer() (string, error) {
+ request := struct {
+ CameraId string `json:"cameraId"`
+ StreamId string `json:"streamId,omitempty"`
+ PlaybackTimeNode struct {
+ PlaybackTime string `json:"playbackTime,omitempty"`
+ SkipGaps bool `json:"skipGaps,omitempty"`
+ Speed float64 `json:"speed,omitempty"`
+ } `json:"playbackTimeNode,omitempty"`
+ //ICEServers []string `json:"iceServers,omitempty"`
+ //Resolution string `json:"resolution,omitempty"`
+ }{
+ CameraId: m.query.Get("cameraId"),
+ StreamId: m.query.Get("streamId"),
+ }
+ request.PlaybackTimeNode.PlaybackTime = m.query.Get("playbackTime")
+ request.PlaybackTimeNode.SkipGaps = m.query.Has("skipGaps")
+ request.PlaybackTimeNode.Speed = parseFloat(m.query.Get("speed"))
+
+ data, err := json.Marshal(request)
+ if err != nil {
+ return "", err
+ }
+
+ req, err := http.NewRequest("POST", m.url+"/REST/v1/WebRTC/Session", bytes.NewBuffer(data))
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Authorization", "Bearer "+m.token)
+ req.Header.Set("Content-Type", "application/json")
+
+ res, err := tcp.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ return "", errors.New("milesone: create session: " + res.Status)
+ }
+
+ var response struct {
+ SessionId string `json:"sessionId"`
+ OfferSDP string `json:"offerSDP"`
+ }
+ if err = json.NewDecoder(res.Body).Decode(&response); err != nil {
+ return "", err
+ }
+
+ var offer pion.SessionDescription
+ if err = json.Unmarshal([]byte(response.OfferSDP), &offer); err != nil {
+ return "", err
+ }
+
+ m.sessionID = response.SessionId
+
+ return offer.SDP, nil
+}
+
+func (m *milestoneAPI) SetAnswer(sdp string) error {
+ answer := pion.SessionDescription{
+ Type: pion.SDPTypeAnswer,
+ SDP: sdp,
+ }
+ data, err := json.Marshal(answer)
+ if err != nil {
+ return err
+ }
+
+ request := struct {
+ AnswerSDP string `json:"answerSDP"`
+ }{
+ AnswerSDP: string(data),
+ }
+ if data, err = json.Marshal(request); err != nil {
+ return err
+ }
+
+ req, err := http.NewRequest("PATCH", m.url+"/REST/v1/WebRTC/Session/"+m.sessionID, bytes.NewBuffer(data))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+m.token)
+ req.Header.Set("Content-Type", "application/json")
+
+ res, err := tcp.Do(req)
+ if err != nil {
+ return err
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ return errors.New("milesone: patch session: " + res.Status)
+ }
+
+ return nil
+}
+
+func milestoneClient(rawURL string, query url.Values) (core.Producer, error) {
+ mc := &milestoneAPI{url: rawURL, query: query}
+ if err := mc.GetToken(); err != nil {
+ return nil, err
+ }
+
+ api, err := webrtc.NewAPI()
+ if err != nil {
+ return nil, err
+ }
+
+ conf := pion.Configuration{}
+ pc, err := api.NewPeerConnection(conf)
+ if err != nil {
+ return nil, err
+ }
+
+ prod := webrtc.NewConn(pc)
+ prod.Desc = "WebRTC/Milestone"
+ prod.Mode = core.ModeActiveProducer
+
+ offer, err := mc.GetOffer()
+ if err != nil {
+ return nil, err
+ }
+
+ if err = prod.SetOffer(offer); err != nil {
+ return nil, err
+ }
+
+ answer, err := prod.GetAnswer()
+ if err != nil {
+ return nil, err
+ }
+
+ if err = mc.SetAnswer(answer); err != nil {
+ return nil, err
+ }
+
+ return prod, nil
+}
diff --git a/pkg/hap/camera/accessory.go b/pkg/hap/camera/accessory.go
index fca77de82..42037d96f 100644
--- a/pkg/hap/camera/accessory.go
+++ b/pkg/hap/camera/accessory.go
@@ -62,6 +62,7 @@ func ServiceCameraRTPStreamManagement() *hap.Service {
VideoAttrs: []VideoAttrs{
{Width: 1920, Height: 1080, Framerate: 30},
{Width: 1280, Height: 720, Framerate: 30}, // important for iPhones
+ {Width: 320, Height: 240, Framerate: 15}, // apple watch
},
},
},
diff --git a/pkg/isapi/client.go b/pkg/isapi/client.go
index 6e28a154a..ff25a79fa 100644
--- a/pkg/isapi/client.go
+++ b/pkg/isapi/client.go
@@ -90,8 +90,15 @@ func (c *Client) Dial() (err error) {
}
func (c *Client) Open() (err error) {
- link := c.url + "/ISAPI/System/TwoWayAudio/channels/" + c.channel
+ // Hikvision ISAPI may not accept a new open request if the previous one was not closed (e.g.
+ // using the test button on-camera or via curl command) but a close request can be sent even if
+ // the audio is already closed. So, we send a close request first and then open it again. Seems
+ // janky but it works.
+ if err = c.Close(); err != nil {
+ return err
+ }
+ link := c.url + "/ISAPI/System/TwoWayAudio/channels/" + c.channel
req, err := http.NewRequest("PUT", link+"/open", nil)
if err != nil {
return err
@@ -130,8 +137,8 @@ func (c *Client) Open() (err error) {
}
func (c *Client) Close() (err error) {
- link := c.url + "/ISAPI/System/TwoWayAudio/channels/" + c.channel + "/close"
- req, err := http.NewRequest("PUT", link+"/open", nil)
+ link := c.url + "/ISAPI/System/TwoWayAudio/channels/" + c.channel
+ req, err := http.NewRequest("PUT", link+"/close", nil)
if err != nil {
return err
}
diff --git a/pkg/ivideon/client.go b/pkg/ivideon/client.go
index 0158f08d2..c1b055b84 100644
--- a/pkg/ivideon/client.go
+++ b/pkg/ivideon/client.go
@@ -132,6 +132,9 @@ func (c *Client) Handle() error {
case "stream-init":
continue
+ case "metadata":
+ continue
+
case "fragment":
_, data, err = c.conn.ReadMessage()
if err != nil {
@@ -183,6 +186,9 @@ func (c *Client) getTracks() error {
}
switch msg.Type {
+ case "metadata":
+ continue
+
case "stream-init":
s := msg.CodecString
i := strings.IndexByte(s, '.')
diff --git a/pkg/mdns/syscall_freebsd.go b/pkg/mdns/syscall_freebsd.go
new file mode 100644
index 000000000..c1f1225b3
--- /dev/null
+++ b/pkg/mdns/syscall_freebsd.go
@@ -0,0 +1,24 @@
+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)
+}
diff --git a/pkg/nest/api.go b/pkg/nest/api.go
index 9c7f4546f..5e0d3407c 100644
--- a/pkg/nest/api.go
+++ b/pkg/nest/api.go
@@ -14,6 +14,13 @@ import (
type API struct {
Token string
ExpiresAt time.Time
+
+ StreamProjectID string
+ StreamDeviceID string
+ StreamSessionID string
+ StreamExpiresAt time.Time
+
+ extendTimer *time.Timer
}
type Auth struct {
@@ -159,7 +166,7 @@ func (a *API) ExchangeSDP(projectID, deviceID, offer string) (string, error) {
Results struct {
Answer string `json:"answerSdp"`
ExpiresAt time.Time `json:"expiresAt"`
- MediaSessionId string `json:"mediaSessionId"`
+ MediaSessionID string `json:"mediaSessionId"`
} `json:"results"`
}
@@ -167,9 +174,65 @@ func (a *API) ExchangeSDP(projectID, deviceID, offer string) (string, error) {
return "", err
}
+ a.StreamProjectID = projectID
+ a.StreamDeviceID = deviceID
+ a.StreamSessionID = resv.Results.MediaSessionID
+ a.StreamExpiresAt = resv.Results.ExpiresAt
+
return resv.Results.Answer, nil
}
+func (a *API) ExtendStream() error {
+ var reqv struct {
+ Command string `json:"command"`
+ Params struct {
+ MediaSessionID string `json:"mediaSessionId"`
+ } `json:"params"`
+ }
+ reqv.Command = "sdm.devices.commands.CameraLiveStream.ExtendWebRtcStream"
+ reqv.Params.MediaSessionID = a.StreamSessionID
+
+ b, err := json.Marshal(reqv)
+ if err != nil {
+ return err
+ }
+
+ uri := "https://smartdevicemanagement.googleapis.com/v1/enterprises/" +
+ a.StreamProjectID + "/devices/" + a.StreamDeviceID + ":executeCommand"
+ req, err := http.NewRequest("POST", uri, bytes.NewReader(b))
+ if err != nil {
+ return err
+ }
+
+ req.Header.Set("Authorization", "Bearer "+a.Token)
+
+ client := &http.Client{Timeout: time.Second * 5000}
+ res, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+
+ if res.StatusCode != 200 {
+ return errors.New("nest: wrong status: " + res.Status)
+ }
+
+ var resv struct {
+ Results struct {
+ ExpiresAt time.Time `json:"expiresAt"`
+ MediaSessionID string `json:"mediaSessionId"`
+ } `json:"results"`
+ }
+
+ if err = json.NewDecoder(res.Body).Decode(&resv); err != nil {
+ return err
+ }
+
+ a.StreamSessionID = resv.Results.MediaSessionID
+ a.StreamExpiresAt = resv.Results.ExpiresAt
+
+ return nil
+}
+
type Device struct {
Name string `json:"name"`
Type string `json:"type"`
@@ -203,3 +266,23 @@ type Device struct {
// DisplayName string `json:"displayName"`
//} `json:"parentRelations"`
}
+
+func (a *API) StartExtendStreamTimer() {
+ // Calculate the duration until 30 seconds before the stream expires
+ duration := time.Until(a.StreamExpiresAt.Add(-30 * time.Second))
+ a.extendTimer = time.AfterFunc(duration, func() {
+ if err := a.ExtendStream(); err != nil {
+ return
+ }
+ duration = time.Until(a.StreamExpiresAt.Add(-30 * time.Second))
+ a.extendTimer.Reset(duration)
+ })
+
+}
+
+func (a *API) StopExtendStreamTimer() {
+ if a.extendTimer == nil {
+ return
+ }
+ a.extendTimer.Stop()
+}
diff --git a/pkg/nest/client.go b/pkg/nest/client.go
index b2b0c964b..cb73cc985 100644
--- a/pkg/nest/client.go
+++ b/pkg/nest/client.go
@@ -11,6 +11,7 @@ import (
type Client struct {
conn *webrtc.Conn
+ api *API
}
func NewClient(rawURL string) (*Client, error) {
@@ -74,7 +75,7 @@ func NewClient(rawURL string) (*Client, error) {
return nil, err
}
- return &Client{conn: conn}, nil
+ return &Client{conn: conn, api: nestAPI}, nil
}
func (c *Client) GetMedias() []*core.Media {
@@ -90,10 +91,12 @@ func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Rece
}
func (c *Client) Start() error {
+ c.api.StartExtendStreamTimer()
return c.conn.Start()
}
func (c *Client) Stop() error {
+ c.api.StopExtendStreamTimer()
return c.conn.Stop()
}
diff --git a/pkg/shell/shell.go b/pkg/shell/shell.go
index 157927256..d538b961b 100644
--- a/pkg/shell/shell.go
+++ b/pkg/shell/shell.go
@@ -2,9 +2,7 @@ package shell
import (
"os"
- "os/exec"
"os/signal"
- "path/filepath"
"regexp"
"strings"
"syscall"
@@ -70,20 +68,3 @@ func RunUntilSignal() {
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
println("exit with signal:", (<-sigs).String())
}
-
-// Restart idea taken from https://github.com/tillberg/autorestart
-// Copyright (c) 2015, Dan Tillberg
-func Restart() {
- path, err := exec.LookPath(os.Args[0])
- if err != nil {
- return
- }
- path, err = filepath.Abs(path)
- if err != nil {
- return
- }
- path = filepath.Clean(path)
- if err = syscall.Exec(path, os.Args, os.Environ()); err != nil {
- panic(err)
- }
-}
diff --git a/pkg/stdin/client.go b/pkg/stdin/client.go
new file mode 100644
index 000000000..00337f341
--- /dev/null
+++ b/pkg/stdin/client.go
@@ -0,0 +1,41 @@
+package stdin
+
+import (
+ "io"
+ "os/exec"
+
+ "github.com/AlexxIT/go2rtc/pkg/core"
+)
+
+type Client struct {
+ cmd *exec.Cmd
+ pipe io.WriteCloser
+
+ medias []*core.Media
+ sender *core.Sender
+ send int
+}
+
+func NewClient(cmd *exec.Cmd) (*Client, error) {
+ pipe, err := PipeCloser(cmd)
+ if err != nil {
+ return nil, err
+ }
+
+ c := &Client{
+ pipe: pipe,
+ cmd: cmd,
+ medias: []*core.Media{
+ {
+ Kind: core.KindAudio,
+ Direction: core.DirectionSendonly,
+ Codecs: []*core.Codec{
+ {Name: core.CodecPCMA, ClockRate: 8000},
+ {Name: core.CodecPCM},
+ },
+ },
+ },
+ }
+
+ return c, nil
+}
diff --git a/pkg/stdin/consumer.go b/pkg/stdin/consumer.go
new file mode 100644
index 000000000..827ea735d
--- /dev/null
+++ b/pkg/stdin/consumer.go
@@ -0,0 +1,52 @@
+package stdin
+
+import (
+ "encoding/json"
+
+ "github.com/AlexxIT/go2rtc/pkg/core"
+ "github.com/pion/rtp"
+)
+
+func (c *Client) GetMedias() []*core.Media {
+ return c.medias
+}
+
+func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) {
+ return nil, core.ErrCantGetTrack
+}
+
+func (c *Client) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error {
+ if c.sender == nil {
+ c.sender = core.NewSender(media, track.Codec)
+ c.sender.Handler = func(packet *rtp.Packet) {
+ _, _ = c.pipe.Write(packet.Payload)
+ c.send += len(packet.Payload)
+ }
+ }
+
+ c.sender.HandleRTP(track)
+ return nil
+}
+
+func (c *Client) Start() (err error) {
+ return c.cmd.Run()
+}
+
+func (c *Client) Stop() (err error) {
+ if c.sender != nil {
+ c.sender.Close()
+ }
+ return c.pipe.Close()
+}
+
+func (c *Client) MarshalJSON() ([]byte, error) {
+ info := &core.Info{
+ Type: "Exec active consumer",
+ Medias: c.medias,
+ Send: c.send,
+ }
+ if c.sender != nil {
+ info.Senders = []*core.Sender{c.sender}
+ }
+ return json.Marshal(info)
+}
diff --git a/pkg/stdin/pipe.go b/pkg/stdin/pipe.go
new file mode 100644
index 000000000..c58a10767
--- /dev/null
+++ b/pkg/stdin/pipe.go
@@ -0,0 +1,26 @@
+package stdin
+
+import (
+ "errors"
+ "io"
+ "os/exec"
+)
+
+type pipeCloser struct {
+ io.Writer
+ io.Closer
+ cmd *exec.Cmd
+}
+
+func PipeCloser(cmd *exec.Cmd) (io.WriteCloser, error) {
+ stdin, err := cmd.StdinPipe()
+ if err != nil {
+ return nil, err
+ }
+
+ return pipeCloser{stdin, stdin, cmd}, nil
+}
+
+func (p pipeCloser) Close() (err error) {
+ return errors.Join(p.Closer.Close(), p.cmd.Process.Kill(), p.cmd.Wait())
+}
diff --git a/pkg/tapo/client.go b/pkg/tapo/client.go
index 5f1f2465d..ed79e500f 100644
--- a/pkg/tapo/client.go
+++ b/pkg/tapo/client.go
@@ -75,6 +75,12 @@ func (c *Client) newConn() (net.Conn, error) {
return nil, err
}
+ query := u.Query()
+
+ if deviceId := query.Get("deviceId"); deviceId != "" {
+ req.URL.RawQuery = "deviceId=" + deviceId
+ }
+
req.URL.User = u.User
req.Header.Set("Content-Type", "multipart/mixed; boundary=--client-stream-boundary--")
@@ -91,7 +97,6 @@ func (c *Client) newConn() (net.Conn, error) {
c.newDectypter(res)
}
- query := u.Query()
channel := query.Get("channel")
if channel == "" {
channel = "0"
@@ -277,11 +282,12 @@ func dial(req *http.Request) (net.Conn, *http.Response, error) {
if err != nil {
return nil, nil, err
}
+ _ = res.Body.Close() // ignore response body
auth := res.Header.Get("WWW-Authenticate")
if res.StatusCode != http.StatusUnauthorized || !strings.HasPrefix(auth, "Digest") {
- return nil, nil, err
+ return nil, nil, fmt.Errorf("Expected StatusCode to be %d, received %d", http.StatusUnauthorized, res.StatusCode)
}
if password == "" {
@@ -301,14 +307,19 @@ func dial(req *http.Request) (net.Conn, *http.Response, error) {
ha1 := tcp.HexMD5(username, realm, password)
ha2 := tcp.HexMD5(req.Method, uri)
nc := "00000001"
- cnonce := "00000001"
+ cnonce := core.RandString(32, 64)
response := tcp.HexMD5(ha1, nonce, nc, cnonce, qop, ha2)
+ // https://datatracker.ietf.org/doc/html/rfc7616
header := fmt.Sprintf(
`Digest username="%s", realm="%s", nonce="%s", uri="%s", qop=%s, nc=%s, cnonce="%s", response="%s"`,
username, realm, nonce, uri, qop, nc, cnonce, response,
)
+ if opaque := tcp.Between(auth, `opaque="`, `"`); opaque != "" {
+ header += fmt.Sprintf(`, opaque="%s", algorithm=MD5`, opaque)
+ }
+
req.Header.Set("Authorization", header)
if err = req.Write(conn); err != nil {
diff --git a/pkg/tcp/request.go b/pkg/tcp/request.go
index dda32ce8e..88da83b62 100644
--- a/pkg/tcp/request.go
+++ b/pkg/tcp/request.go
@@ -9,6 +9,8 @@ import (
"net/http"
"strings"
"time"
+
+ "github.com/AlexxIT/go2rtc/pkg/core"
)
// Do - http.Client with support Digest Authorization
@@ -115,7 +117,7 @@ func Do(req *http.Request) (*http.Response, error) {
)
case "auth":
nc := "00000001"
- cnonce := "00000001" // TODO: random...
+ cnonce := core.RandString(32, 64)
response := HexMD5(ha1, nonce, nc, cnonce, qop, ha2)
header = fmt.Sprintf(
`Digest username="%s", realm="%s", nonce="%s", uri="%s", qop=%s, nc=%s, cnonce="%s", response="%s"`,
diff --git a/pkg/webrtc/track.go b/pkg/webrtc/track.go
index 547fafb10..3102abd14 100644
--- a/pkg/webrtc/track.go
+++ b/pkg/webrtc/track.go
@@ -1,6 +1,8 @@
package webrtc
import (
+ "sync"
+
"github.com/pion/rtp"
"github.com/pion/webrtc/v3"
)
@@ -12,6 +14,7 @@ type Track struct {
sequence uint16
ssrc uint32
writer webrtc.TrackLocalWriter
+ mu sync.Mutex
}
func NewTrack(kind string) *Track {
@@ -23,8 +26,10 @@ func NewTrack(kind string) *Track {
}
func (t *Track) Bind(context webrtc.TrackLocalContext) (webrtc.RTPCodecParameters, error) {
+ t.mu.Lock()
t.ssrc = uint32(context.SSRC())
t.writer = context.WriteStream()
+ t.mu.Unlock()
for _, parameters := range context.CodecParameters() {
// return first parameters
@@ -35,7 +40,9 @@ func (t *Track) Bind(context webrtc.TrackLocalContext) (webrtc.RTPCodecParameter
}
func (t *Track) Unbind(context webrtc.TrackLocalContext) error {
+ t.mu.Lock()
t.writer = nil
+ t.mu.Unlock()
return nil
}
@@ -55,19 +62,22 @@ func (t *Track) Kind() webrtc.RTPCodecType {
return webrtc.NewRTPCodecType(t.kind)
}
-func (t *Track) WriteRTP(payloadType uint8, packet *rtp.Packet) error {
+func (t *Track) WriteRTP(payloadType uint8, packet *rtp.Packet) (err error) {
+ // using mutex because Unbind https://github.com/AlexxIT/go2rtc/issues/994
+ t.mu.Lock()
+
// in case when we start WriteRTP before Track.Bind
- if t.writer == nil {
- return nil
- }
+ if t.writer != nil {
+ // important to have internal counter if input packets from different sources
+ t.sequence++
- // important to have internal counter if input packets from different sources
- t.sequence++
+ header := packet.Header
+ header.SSRC = t.ssrc
+ header.PayloadType = payloadType
+ header.SequenceNumber = t.sequence
+ _, err = t.writer.WriteRTP(&header, packet.Payload)
+ }
- header := packet.Header
- header.SSRC = t.ssrc
- header.PayloadType = payloadType
- header.SequenceNumber = t.sequence
- _, err := t.writer.WriteRTP(&header, packet.Payload)
- return err
+ t.mu.Unlock()
+ return
}
diff --git a/www/add.html b/www/add.html
index 2b1bb9d7d..3058f8dca 100644
--- a/www/add.html
+++ b/www/add.html
@@ -5,11 +5,6 @@
diff --git a/www/editor.html b/www/editor.html
index c24e7f953..8ace08818 100644
--- a/www/editor.html
+++ b/www/editor.html
@@ -4,7 +4,7 @@
File Editor
-
+
@@ -54,6 +50,7 @@
+
@@ -85,15 +82,27 @@
.replace(/\n/g, '
');
}
+ const reverseBtn = document.getElementById('reverse');
+ const update = document.getElementById('update');
+
+ let reverseOrder = false;
+ let autoUpdateEnabled = true;
+
+ reverseBtn.textContent = `Reverse Log Order: ${reverseOrder ? 'ON' : 'OFF'}`;
+ update.textContent = `Auto Update: ${autoUpdateEnabled ? 'ON' : 'OFF'}`;
+
function applyLogStyling(jsonlines) {
const KEYS = ['time', 'level', 'message'];
- const lines = JSON.parse('[' + jsonlines.trimEnd().replaceAll('\n', ',') + ']');
+ let lines = JSON.parse('[' + jsonlines.trimEnd().replaceAll('\n', ',') + ']');
+ if (reverseOrder) {
+ lines = lines.reverse();
+ }
return lines.map(line => {
const ts = new Date(line['time']);
const msg = Object.keys(line).reduce((msg, key) => {
return KEYS.indexOf(key) < 0 ? `${msg} ${key}=${line[key]}` : msg;
}, line['message']);
- return `${ts.toLocaleString()} | ${line['level']} | ${escapeHTML(msg)} |
`;
+ return `${ts.toLocaleString()} | ${escapeHTML(line['level'])} | ${escapeHTML(msg)} |
`;
}).join('');
}
@@ -112,19 +121,24 @@
reload();
- // Handle auto-update switch
- let autoUpdateEnabled = true;
-
- const update = document.getElementById('update');
+ update.textContent = `Auto Update: ${autoUpdateEnabled ? 'ON' : 'OFF'}`;
update.addEventListener('click', () => {
autoUpdateEnabled = !autoUpdateEnabled;
update.textContent = `Auto Update: ${autoUpdateEnabled ? 'ON' : 'OFF'}`;
});
+ // Toggle log order
+ reverseBtn.textContent = `Reverse Log Order: ${reverseOrder ? 'ON' : 'OFF'}`;
+ reverseBtn.addEventListener('click', () => {
+ reverseOrder = !reverseOrder;
+ reverseBtn.textContent = `Reverse Log Order: ${reverseOrder ? 'ON' : 'OFF'}`;
+ reload(); // Reload logs to apply the new order
+ });
+
// Reload the logs every 5 seconds
setInterval(() => {
if (autoUpdateEnabled) reload();
}, 5000);