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=16000&#audio=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 ``; + return ``; }).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); - \ No newline at end of file + diff --git a/www/main.js b/www/main.js index 63dbde322..2c15e0712 100644 --- a/www/main.js +++ b/www/main.js @@ -18,7 +18,6 @@ i { nav { display: block; - /*width: 660px;*/ margin: 0 auto 10px; } @@ -41,6 +40,97 @@ nav a:hover { nav li { display: inline; } + +body { + font-family: Arial, Helvetica, sans-serif; + background-color: white; +} +table { + background-color: white; + text-align: left; + border-collapse: collapse; +} +table thead { + background: #CFCFCF; + background: linear-gradient(to bottom, #dbdbdb 0%, #d3d3d3 66%, #CFCFCF 100%); + border-bottom: 3px solid black; +} +table thead th { + font-size: 15px; + font-weight: bold; + color: black; + text-align: center; +} +table td, table th { + border: 1px solid black; + padding: 5px 5px; +} + +/* Dark mode styles */ +body.dark-mode { + background-color: #121212; + color: #e0e0e0; +} + +body.dark-mode nav ul { + background: #333; +} + +body.dark-mode a { + background: rgba(45, 45, 45, .8); + border-right: 1px solid #2c2c2c; + color: #c7c7c7; +} + +body.dark-mode a:hover { + background: #555; +} + +body.dark-mode a:visited { + color: #999; +} + +body.dark-mode table { + background-color: #222; + color: #ddd; +} + +body.dark-mode table thead { + background: linear-gradient(to bottom, #444 0%, #3d3d3d 66%, #333 100%); + border-bottom: 3px solid #888; +} +body.dark-mode table thead th { + font-size: 15px; + font-weight: bold; + color: #ddd; + text-align: center; +} +body.dark-mode table td, body.dark-mode table th { + border: 1px solid #444; +} + +body.dark-mode button { + background: rgba(255, 255, 255, .1); + border: 1px solid #444; + color: #ccc; +} + +body.dark-mode input, +body.dark-mode select, +body.dark-mode textarea { + background-color: #333; + color: #e0e0e0; + border: 1px solid #444; +} + +body.dark-mode input::placeholder, +body.dark-mode textarea::placeholder { + color: #bbb; +} + +body.dark-mode hr { + border-top: 1px solid #444; +} ` + document.body.innerHTML; + +const sunIcon = '☀️'; +const moonIcon = '🌕'; + +document.addEventListener('DOMContentLoaded', () => { + const darkModeToggle = document.getElementById('darkModeToggle'); + const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)'); + + const isDarkModeEnabled = () => document.body.classList.contains('dark-mode'); + + // Update the toggle button based on the dark mode state + const updateToggleButton = () => { + if (isDarkModeEnabled()) { + darkModeToggle.innerHTML = sunIcon; + darkModeToggle.setAttribute('aria-label', 'Enable light mode'); + } else { + darkModeToggle.innerHTML = moonIcon; + darkModeToggle.setAttribute('aria-label', 'Enable dark mode'); + } + }; + + const updateDarkMode = () => { + if (localStorage.getItem('darkMode') === 'enabled' || prefersDarkScheme.matches && localStorage.getItem('darkMode') !== 'disabled') { + document.body.classList.add('dark-mode'); + } else { + document.body.classList.remove('dark-mode'); + } + updateEditorTheme(); + updateToggleButton(); + }; + + // Update the editor theme based on the dark mode state + const updateEditorTheme = () => { + if (typeof editor !== 'undefined') { + editor.setTheme(isDarkModeEnabled() ? 'ace/theme/tomorrow_night_eighties' : 'ace/theme/github'); + } + }; + + // Initial update for dark mode and toggle button + updateDarkMode(); + + // Listen for changes in the system's color scheme preference + prefersDarkScheme.addEventListener('change', updateDarkMode); // Modern approach + + // Toggle dark mode and update local storage on button click + darkModeToggle.addEventListener('click', () => { + const enabled = document.body.classList.toggle('dark-mode'); + localStorage.setItem('darkMode', enabled ? 'enabled' : 'disabled'); + updateToggleButton(); // Update the button after toggling + updateEditorTheme(); + }); +});
${ts.toLocaleString()}${line['level']}${escapeHTML(msg)}
${ts.toLocaleString()}${escapeHTML(line['level'])}${escapeHTML(msg)}