diff --git a/Dockerfile b/Dockerfile index 6ead9a1b..09e6adad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,7 +40,8 @@ FROM base # Install ffmpeg, tini (for signal handling), # and other common tools for the echo source. -RUN apk add --no-cache tini ffmpeg bash curl jq +# alsa-plugins-pulse for ALSA support (+0MB) +RUN apk add --no-cache tini ffmpeg bash curl jq alsa-plugins-pulse # Hardware Acceleration for Intel CPU (+50MB) ARG TARGETARCH diff --git a/README.md b/README.md index aec59fec..1ec7792c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # 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) + Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg, RTMP, etc. ![](assets/go2rtc.png) @@ -42,6 +46,7 @@ Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg * [Source: RTSP](#source-rtsp) * [Source: RTMP](#source-rtmp) * [Source: HTTP](#source-http) + * [Source: ONVIF](#source-onvif) * [Source: FFmpeg](#source-ffmpeg) * [Source: FFmpeg Device](#source-ffmpeg-device) * [Source: Exec](#source-exec) @@ -97,10 +102,12 @@ Download binary for your OS from [latest release](https://github.com/AlexxIT/go2 - `go2rtc_win64.zip` - Windows 64-bit - `go2rtc_win32.zip` - Windows 32-bit +- `go2rtc_win_arm64.zip` - Windows ARM 64-bit - `go2rtc_linux_amd64` - Linux 64-bit - `go2rtc_linux_i386` - Linux 32-bit - `go2rtc_linux_arm64` - Linux ARM 64-bit (ex. Raspberry 64-bit OS) - `go2rtc_linux_arm` - Linux ARM 32-bit (ex. Raspberry 32-bit OS) +- `go2rtc_linux_armv6` - Linux ARMv6 (for old Raspberry 1 and Zero) - `go2rtc_linux_mipsel` - Linux MIPS (ex. [Xiaomi Gateway 3](https://github.com/AlexxIT/XiaomiGateway3)) - `go2rtc_mac_amd64.zip` - Mac Intel 64-bit - `go2rtc_mac_arm64.zip` - Mac ARM 64-bit @@ -156,10 +163,11 @@ Available source types: - [rtsp](#source-rtsp) - `RTSP` and `RTSPS` cameras with [two way audio](#two-way-audio) support - [rtmp](#source-rtmp) - `RTMP` streams -- [http](#source-http) - `HTTP-FLV`, `MPEG-TS`, `JPEG` (snapshots), `MJPEG` streams +- [http](#source-http) - `HTTP-FLV`, `MPEG-TS`, `JPEG` (snapshots), `MJPEG` streams +- [onvif](#source-onvif) - get camera `RTSP` link and snapshot link using `ONVIF` protocol - [ffmpeg](#source-ffmpeg) - FFmpeg integration (`HLS`, `files` and many others) - [ffmpeg:device](#source-ffmpeg-device) - local USB Camera or Webcam -- [exec](#source-exec) - advanced FFmpeg and GStreamer integration +- [exec](#source-exec) - get media from external app output - [echo](#source-echo) - get stream link from bash or python - [homekit](#source-homekit) - streaming from HomeKit Camera - [dvrip](#source-dvrip) - streaming from DVR-IP NVR @@ -229,6 +237,8 @@ Support Content-Type: - **HTTP-MJPEG** (`multipart/x`) - simple MJPEG stream over HTTP - **MPEG-TS** (`video/mpeg`) - legacy [streaming format](https://en.wikipedia.org/wiki/MPEG_transport_stream) +Source also support HTTP and TCP streams with autodetection for different formats: **MJPEG**, **H.264/H.265 bitstream**, **MPEG-TS**. + ```yaml streams: # [HTTP-FLV] stream in video/x-flv format @@ -239,10 +249,26 @@ streams: # [MJPEG] stream will be proxied without modification http_mjpeg: https://mjpeg.sanford.io/count.mjpeg + + # [MJPEG or H.264/H.265 bitstream or MPEG-TS] + tcp_magic: tcp://192.168.1.123:12345 ``` **PS.** Dahua camera has bug: if you select MJPEG codec for RTSP second stream - snapshot won't work. +#### Source: ONVIF + +The source is not very useful if you already know RTSP and snapshot links for your camera. But it can be useful if you don't. + +**WebUI > Add** webpage support ONVIF autodiscovery. Your server must be on the same subnet as the camera. If you use docker, you must use "network host". + +```yaml +streams: + dahua1: onvif://admin:password@192.168.1.123 + reolink1: onvif://admin:password@192.168.1.123:8000 + tapo1: onvif://admin:password@192.168.1.123:2020 +``` + #### Source: FFmpeg You can get any stream or file or device via FFmpeg and push it to go2rtc. The app will automatically start FFmpeg with the proper arguments when someone starts watching the stream. @@ -273,7 +299,7 @@ streams: rotate: ffmpeg:rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0#video=h264#rotate=90 ``` -All trascoding formats has [built-in templates](https://github.com/AlexxIT/go2rtc/blob/master/cmd/ffmpeg/ffmpeg.go): `h264`, `h265`, `opus`, `pcmu`, `pcmu/16000`, `pcmu/48000`, `pcma`, `pcma/16000`, `pcma/48000`, `aac`, `aac/16000`. +All trascoding formats has [built-in templates](https://github.com/AlexxIT/go2rtc/blob/master/internal/ffmpeg/ffmpeg.go): `h264`, `h265`, `opus`, `pcmu`, `pcmu/16000`, `pcmu/48000`, `pcma`, `pcma/16000`, `pcma/48000`, `aac`, `aac/16000`. But you can override them via YAML config. You can also add your own formats to config and use them with source params. @@ -301,25 +327,40 @@ Read more about encoding [hardware acceleration](https://github.com/AlexxIT/go2r You can get video from any USB-camera or Webcam as RTSP or WebRTC stream. This is part of FFmpeg integration. - check available devices in Web interface -- `resolution` and `framerate` must be supported by your camera! +- `video_size` and `framerate` must be supported by your camera! - for Linux supported only video for now - for macOS you can stream Facetime camera or whole Desktop! - for macOS important to set right framerate +Format: `ffmpeg:device?{input-params}#{param1}#{param2}#{param3}` + ```yaml streams: - linux_usbcam: ffmpeg:device?video=0&resolution=1280x720#video=h264 + linux_usbcam: ffmpeg:device?video=0&video_size=1280x720#video=h264 windows_webcam: ffmpeg:device?video=0#video=h264 - macos_facetime: ffmpeg:device?video=0&audio=1&resolution=1280x720&framerate=30#video=h264#audio=pcma + macos_facetime: ffmpeg:device?video=0&audio=1&video_size=1280x720&framerate=30#video=h264#audio=pcma ``` #### Source: Exec -FFmpeg source just a shortcut to exec source. You can get any stream or file or device via FFmpeg or GStreamer and push it to go2rtc via RTSP protocol: +Exec source can run any external application and expect data from it. Two transports are supported - **pipe** and **RTSP**. + +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**. + +The source can be used with: + +- [FFmpeg](https://ffmpeg.org/) - go2rtc ffmpeg source just a shortcut to exec source +- [GStreamer](https://gstreamer.freedesktop.org/) +- [Raspberry Pi Cameras](https://www.raspberrypi.com/documentation/computers/camera_software.html) +- any your own software ```yaml streams: - stream1: exec:ffmpeg -hide_banner -re -stream_loop -1 -i /media/BigBuckBunny.mp4 -c copy -rtsp_transport tcp -f rtsp {output} + 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 - ``` #### Source: Echo @@ -412,8 +453,10 @@ streams: Support import camera links from [Home Assistant](https://www.home-assistant.io/) config files: -- support [Generic Camera](https://www.home-assistant.io/integrations/generic/), setup via GUI -- support [HomeKit Camera](https://www.home-assistant.io/integrations/homekit_controller/) +- [Generic Camera](https://www.home-assistant.io/integrations/generic/), setup via GUI +- [HomeKit Camera](https://www.home-assistant.io/integrations/homekit_controller/) +- [ONVIF](https://www.home-assistant.io/integrations/onvif/) +- [Roborock](https://github.com/humbertogontijo/homeassistant-roborock) vacuums with camera ```yaml hass: @@ -424,7 +467,7 @@ streams: aqara_g3: hass:Camera-Hub-G3-AB12 ``` -More cameras, like [Tuya](https://www.home-assistant.io/integrations/tuya/), [ONVIF](https://www.home-assistant.io/integrations/onvif/), and possibly others can also be imported by using [this method](https://github.com/felipecrs/hass-expose-camera-stream-source#importing-home-assistant-cameras-to-go2rtc-andor-frigate). +More cameras, like [Tuya](https://www.home-assistant.io/integrations/tuya/), and possibly others can also be imported by using [this method](https://github.com/felipecrs/hass-expose-camera-stream-source#importing-home-assistant-cameras-to-go2rtc-andor-frigate). #### Source: ISAPI @@ -778,6 +821,7 @@ You have several options on how to add a camera to Home Assistant: 2. Camera [any source](#module-streams) => [go2rtc config](#configuration) => [Generic Camera](https://www.home-assistant.io/integrations/generic/) - Install any [go2rtc](#fast-start) - Add your stream to [go2rtc config](#configuration) + - Hass > Settings > Integrations > Add Integration > [ONVIF](https://my.home-assistant.io/redirect/config_flow_start/?domain=onvif) > Host: `127.0.0.1`, Port: `1984` - Hass > Settings > Integrations > Add Integration > [Generic Camera](https://my.home-assistant.io/redirect/config_flow_start/?domain=generic) > `rtsp://127.0.0.1:8554/camera1` (change to your stream name) You have several options on how to watch the stream from the cameras in Home Assistant: @@ -811,8 +855,8 @@ Provides several features: API examples: -- MP4 stream: `http://192.168.1.123:1984/api/stream.mp4?src=camera1` -- MP4 snapshot: `http://192.168.1.123:1984/api/frame.mp4?src=camera1` +- MP4 snapshot: `http://192.168.1.123:1984/api/frame.mp4?src=camera1` (H264, H265) +- MP4 stream: `http://192.168.1.123:1984/api/stream.mp4?src=camera1` (H264, H265, AAC) Read more about [codecs filters](#codecs-filters). @@ -903,7 +947,7 @@ But it cannot be done for [RTSP](#module-rtsp), [HTTP progressive streaming](#mo Without filters: -- RTSP will provide only the first video and only the first audio +- RTSP will provide only the first video and only the first audio (any codec) - MP4 will include only compatible codecs (H264, H265, AAC) - HLS will output in the legacy TS format (H264 without audio) @@ -914,23 +958,25 @@ Some examples: - `rtsp://192.168.1.123:8554/camera1?video=h264&audio=aac&audio=opus` - H264 video codec and two separate audio tracks - `rtsp://192.168.1.123:8554/camera1?video&audio=all` - any video codec and all audio codecs as separate tracks - `http://192.168.1.123:1984/api/stream.m3u8?src=camera1&mp4` - HLS stream with MP4 compatible codecs (HLS/fMP4) -- `http://192.168.1.123:1984/api/stream.mp4?src=camera1&video=h264,h265&audio=aac,opus,mp3,pcma,pcmu` - MP4 file with non standard audio codecs, does not work in some players +- `http://192.168.1.123:1984/api/stream.m3u8?src=camera1&mp4=flac` - HLS stream with PCMA/PCMU/PCM audio support (HLS/fMP4), won't work on old devices +- `http://192.168.1.123:1984/api/stream.mp4?src=camera1&mp4=flac` - MP4 file with PCMA/PCMU/PCM audio support, won't work on old devices (ex. iOS 12) +- `http://192.168.1.123:1984/api/stream.mp4?src=camera1&mp4=all` - MP4 file with non standard audio codecs, won't work on some players ## Codecs madness `AVC/H.264` video can be played almost anywhere. But `HEVC/H.265` has a lot of limitations in supporting with different devices and browsers. It's all about patents and money, you can't do anything about it. -| Device | WebRTC | MSE | HTTP Progressive Streaming | -|---------------------|-------------------------------|------------------------|-----------------------------------------| -| *latency* | best | medium | bad | -| Desktop Chrome 107+ | H264, OPUS, PCMU, PCMA | H264, H265*, AAC, OPUS | H264, H265*, AAC, OPUS, PCMU, PCMA, MP3 | -| Desktop Edge | H264, OPUS, PCMU, PCMA | H264, H265*, AAC, OPUS | H264, H265*, AAC, OPUS, PCMU, PCMA, MP3 | -| Desktop Safari | H264, H265*, OPUS, PCMU, PCMA | H264, H265, AAC | **no!** | -| Desktop Firefox | H264, OPUS, PCMU, PCMA | H264, AAC, OPUS | H264, AAC, OPUS | -| Android Chrome 107+ | H264, OPUS, PCMU, PCMA | H264, H265*, AAC, OPUS | H264, ?, AAC, OPUS, PCMU, PCMA, MP3 | -| iPad Safari 13+ | H264, H265*, OPUS, PCMU, PCMA | H264, H265, AAC | **no!** | -| iPhone Safari 13+ | H264, H265*, OPUS, PCMU, PCMA | **no!** | **no!** | -| masOS Hass App | no | no | no | +| Device | WebRTC | MSE | HTTP Progressive Streaming | +|---------------------|-------------------------------|-------------------------------|------------------------------------| +| *latency* | best | medium | bad | +| Desktop Chrome 107+ | H264, OPUS, PCMU, PCMA | H264, H265*, AAC, FLAC*, OPUS | H264, H265*, AAC, FLAC*, OPUS, MP3 | +| Desktop Edge | H264, OPUS, PCMU, PCMA | H264, H265*, AAC, FLAC*, OPUS | H264, H265*, AAC, FLAC*, OPUS, MP3 | +| Android Chrome 107+ | H264, OPUS, PCMU, PCMA | H264, H265*, AAC, FLAC*, OPUS | H264, H265*, AAC, FLAC*, OPUS, MP3 | +| Desktop Firefox | H264, OPUS, PCMU, PCMA | H264, AAC, FLAC*, OPUS | H264, AAC, FLAC*, OPUS | +| Desktop Safari | H264, H265*, OPUS, PCMU, PCMA | H264, H265, AAC, FLAC* | **no!** | +| iPad Safari 13+ | H264, H265*, OPUS, PCMU, PCMA | H264, H265, AAC, FLAC* | **no!** | +| iPhone Safari 13+ | H264, H265*, OPUS, PCMU, PCMA | **no!** | **no!** | +| masOS Hass App | no | no | no | - Chrome H265: [read this](https://chromestatus.com/feature/5186511939567616) and [read this](https://github.com/StaZhu/enable-chromium-hevc-hardware-decoding) - Edge H265: [read this](https://www.reddit.com/r/MicrosoftEdge/comments/v9iw8k/enable_hevc_support_in_edge/) @@ -939,9 +985,9 @@ Some examples: **Audio** +- Go2rtc support [automatic repack](#built-in-transcoding) `PCMA/PCMU/PCM` codecs to `FLAC` for MSE/MP4/HLS so they will work almost anywhere - **WebRTC** audio codecs: `PCMU/8000`, `PCMA/8000`, `OPUS/48000/2` - `OPUS` and `MP3` inside **MP4** is part of the standard, but some players do not support them anyway (especially Apple) -- `PCMU` and `PCMA` inside **MP4** isn't a standard, but some players support them, for example Chromium browsers **Apple devices** @@ -949,6 +995,45 @@ Some examples: - iPhones don't support MSE technology because it competes with the HTTP Live Streaming (HLS) technology, invented by Apple - HLS is the worst technology for **live** streaming, it still exists only because of iPhones +**Codec names** + +- H264 = H.264 = AVC (Advanced Video Coding) +- H265 = H.265 = HEVC (High Efficiency Video Coding) +- PCMU = G.711 PCM (A-law) = PCM A-law (`alaw`) +- PCMA = G.711 PCM (ยต-law) = PCM mu-law (`mulaw`) +- PCM = L16 = PCM signed 16-bit big-endian (`s16be`) +- AAC = MPEG4-GENERIC +- MP3 = MPEG-1 Audio Layer III or MPEG-2 Audio Layer III + +## Built-in transcoding + +There are no plans to embed complex transcoding algorithms inside go2rtc. [FFmpeg source](#source-ffmpeg) does a great job with this. Including [hardware acceleration](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration) support. + +But go2rtc has some simple algorithms. They are turned on automatically, you do not need to set them up additionally. + +**PCM for MSE/MP4/HLS** + +Go2rtc can pack `PCMA`, `PCMU` and `PCM` codecs into an MP4 container so that they work in all browsers and all built-in players on modern devices. Including Apple QuickTime: + +``` +PCMA/PCMU => PCM => FLAC => MSE/MP4/HLS +``` + +**Resample PCMA/PCMU for WebRTC** + +By default WebRTC support only `PCMA/8000` and `PCMU/8000`. But go2rtc can automatically resample PCMA and PCMU codec with with a different sample rate. Also go2rtc can transcode `PCM` codec to `PCMA/8000`, so WebRTC can play it: + +``` +PCM/xxx => PCMA/8000 => WebRTC +PCMA/xxx => PCMA/8000 => WebRTC +PCMU/xxx => PCMU/8000 => WebRTC +``` + +**Important** + +- FLAC codec not supported in a RTSP stream. If you using Frigate or Hass for recording MP4 files with PCMA/PCMU/PCM audio - you should setup transcoding to AAC codec. +- PCMA and PCMU are VERY low quality codecs. Them support only 256! different sounds. Use them only when you have no other options. + ## Codecs negotiation For example, you want to watch RTSP-stream from [Dahua IPC-K42](https://www.dahuasecurity.com/fr/products/All-Products/Network-Cameras/Wireless-Series/Wi-Fi-Series/4MP/IPC-K42) camera in your Chrome browser. diff --git a/build/docker/run.sh b/build/docker/run.sh deleted file mode 100644 index ca544747..00000000 --- a/build/docker/run.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -echo "Starting go2rtc..." >&2 - -readonly config_path="/config" - -if [[ -x "${config_path}/go2rtc" ]]; then - readonly binary_path="${config_path}/go2rtc" - echo "Using go2rtc binary from '${binary_path}' instead of the embedded one" >&2 -else - readonly binary_path="/usr/local/bin/go2rtc" -fi - -# set cwd for go2rtc (for config file, Hass integration, etc) -cd "${config_path}" || echo "Could not change working directory to '${config_path}'" >&2 - -exec "${binary_path}" diff --git a/cmd/README.md b/cmd/README.md deleted file mode 100644 index d3d1388c..00000000 --- a/cmd/README.md +++ /dev/null @@ -1,4 +0,0 @@ -**Project layout** - -- https://github.com/golang-standards/project-layout -- https://github.com/micro/micro diff --git a/cmd/ffmpeg/device/device_darwin.go b/cmd/ffmpeg/device/device_darwin.go deleted file mode 100644 index a22f7e13..00000000 --- a/cmd/ffmpeg/device/device_darwin.go +++ /dev/null @@ -1,61 +0,0 @@ -package device - -import ( - "bytes" - "github.com/AlexxIT/go2rtc/pkg/core" - "os/exec" - "strings" -) - -// https://trac.ffmpeg.org/wiki/Capture/Webcam -const deviceInputPrefix = "-f avfoundation" - -func deviceInputSuffix(videoIdx, audioIdx int) string { - video := findMedia(core.KindVideo, videoIdx) - audio := findMedia(core.KindAudio, audioIdx) - switch { - case video != nil && audio != nil: - return `"` + video.ID + `:` + audio.ID + `"` - case video != nil: - return `"` + video.ID + `"` - case audio != nil: - return `"` + audio.ID + `"` - } - return "" -} - -func loadMedias() { - cmd := exec.Command( - Bin, "-hide_banner", "-list_devices", "true", "-f", "avfoundation", "-i", "dummy", - ) - - var buf bytes.Buffer - cmd.Stderr = &buf - _ = cmd.Run() - - var kind string - - lines := strings.Split(buf.String(), "\n") -process: - for _, line := range lines { - switch { - case strings.HasSuffix(line, "video devices:"): - kind = core.KindVideo - continue - case strings.HasSuffix(line, "audio devices:"): - kind = core.KindAudio - continue - case strings.HasPrefix(line, "dummy"): - break process - } - - // [AVFoundation indev @ 0x7fad54604380] [0] FaceTime HD Camera - name := line[42:] - media := loadMedia(kind, name) - medias = append(medias, media) - } -} - -func loadMedia(kind, name string) *core.Media { - return &core.Media{Kind: kind, ID: name} -} diff --git a/cmd/ffmpeg/device/device_linux.go b/cmd/ffmpeg/device/device_linux.go deleted file mode 100644 index 3ce29a86..00000000 --- a/cmd/ffmpeg/device/device_linux.go +++ /dev/null @@ -1,48 +0,0 @@ -package device - -import ( - "bytes" - "github.com/AlexxIT/go2rtc/pkg/core" - "io/ioutil" - "os/exec" - "strings" -) - -// https://trac.ffmpeg.org/wiki/Capture/Webcam -const deviceInputPrefix = "-f v4l2" - -func deviceInputSuffix(videoIdx, audioIdx int) string { - video := findMedia(core.KindVideo, videoIdx) - return video.ID -} - -func loadMedias() { - files, err := ioutil.ReadDir("/dev") - if err != nil { - return - } - for _, file := range files { - log.Trace().Msg("[ffmpeg] " + file.Name()) - if strings.HasPrefix(file.Name(), core.KindVideo) { - media := loadMedia(core.KindVideo, "/dev/"+file.Name()) - if media != nil { - medias = append(medias, media) - } - } - } -} - -func loadMedia(kind, name string) *core.Media { - cmd := exec.Command( - Bin, "-hide_banner", "-f", "v4l2", "-list_formats", "all", "-i", name, - ) - var buf bytes.Buffer - cmd.Stderr = &buf - _ = cmd.Run() - - if !bytes.Contains(buf.Bytes(), []byte("Raw")) { - return nil - } - - return &core.Media{Kind: kind, ID: name} -} diff --git a/cmd/ffmpeg/device/device_windows.go b/cmd/ffmpeg/device/device_windows.go deleted file mode 100644 index f465386a..00000000 --- a/cmd/ffmpeg/device/device_windows.go +++ /dev/null @@ -1,57 +0,0 @@ -package device - -import ( - "bytes" - "github.com/AlexxIT/go2rtc/pkg/core" - "os/exec" - "strings" -) - -// https://trac.ffmpeg.org/wiki/DirectShow -const deviceInputPrefix = "-f dshow" - -func deviceInputSuffix(videoIdx, audioIdx int) string { - video := findMedia(core.KindVideo, videoIdx) - audio := findMedia(core.KindAudio, audioIdx) - switch { - case video != nil && audio != nil: - return `video="` + video.ID + `":audio=` + audio.ID + `"` - case video != nil: - return `video="` + video.ID + `"` - case audio != nil: - return `audio="` + audio.ID + `"` - } - return "" -} - -func loadMedias() { - cmd := exec.Command( - Bin, "-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "", - ) - - var buf bytes.Buffer - cmd.Stderr = &buf - _ = cmd.Run() - - lines := strings.Split(buf.String(), "\r\n") - for _, line := range lines { - var kind string - if strings.HasSuffix(line, "(video)") { - kind = core.KindVideo - } else if strings.HasSuffix(line, "(audio)") { - kind = core.KindAudio - } else { - continue - } - - // hope we have constant prefix and suffix sizes - // [dshow @ 00000181e8d028c0] "VMware Virtual USB Video Device" (video) - name := line[28 : len(line)-9] - media := loadMedia(kind, name) - medias = append(medias, media) - } -} - -func loadMedia(kind, name string) *core.Media { - return &core.Media{Kind: kind, ID: name} -} diff --git a/cmd/ffmpeg/device/devices.go b/cmd/ffmpeg/device/devices.go deleted file mode 100644 index 33610c10..00000000 --- a/cmd/ffmpeg/device/devices.go +++ /dev/null @@ -1,91 +0,0 @@ -package device - -import ( - "github.com/AlexxIT/go2rtc/cmd/api" - "github.com/AlexxIT/go2rtc/cmd/app" - "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/rs/zerolog" - "net/http" - "net/url" - "strconv" - "strings" -) - -func Init() { - log = app.GetLogger("exec") - - api.HandleFunc("api/devices", handle) -} - -func GetInput(src string) (string, error) { - if medias == nil { - loadMedias() - } - - input := deviceInputPrefix - - var videoIdx, audioIdx int - if i := strings.IndexByte(src, '?'); i > 0 { - query, err := url.ParseQuery(src[i+1:]) - if err != nil { - return "", err - } - for key, value := range query { - switch key { - case "video": - videoIdx, _ = strconv.Atoi(value[0]) - case "audio": - audioIdx, _ = strconv.Atoi(value[0]) - case "framerate": - input += " -framerate " + value[0] - case "resolution": - input += " -video_size " + value[0] - } - } - } - - input += " -i " + deviceInputSuffix(videoIdx, audioIdx) - - return input, nil -} - -var Bin string -var log zerolog.Logger -var medias []*core.Media - -func findMedia(kind string, index int) *core.Media { - for _, media := range medias { - if media.Kind != kind { - continue - } - if index == 0 { - return media - } - index-- - } - return nil -} - -func handle(w http.ResponseWriter, r *http.Request) { - if medias == nil { - loadMedias() - } - - var items []api.Stream - var iv, ia int - - for _, media := range medias { - var source string - switch media.Kind { - case core.KindVideo: - source = "ffmpeg:device?video=" + strconv.Itoa(iv) - iv++ - case core.KindAudio: - source = "ffmpeg:device?audio=" + strconv.Itoa(ia) - ia++ - } - items = append(items, api.Stream{Name: media.ID, URL: source}) - } - - api.ResponseStreams(w, items) -} diff --git a/cmd/ffmpeg/hardware.go b/cmd/ffmpeg/hardware.go deleted file mode 100644 index 45001271..00000000 --- a/cmd/ffmpeg/hardware.go +++ /dev/null @@ -1,120 +0,0 @@ -package ffmpeg - -import ( - "os/exec" - "strings" - - "github.com/rs/zerolog/log" -) - -const ( - EngineSoftware = "software" - EngineVAAPI = "vaapi" // Intel iGPU and AMD GPU - EngineV4L2M2M = "v4l2m2m" // Raspberry Pi 3 and 4 - EngineCUDA = "cuda" // NVidia on Windows and Linux - EngineDXVA2 = "dxva2" // Intel on Windows - EngineVideoToolbox = "videotoolbox" // macOS -) - -var cache = map[string]string{} - -// MakeHardware converts software FFmpeg args to hardware args -// empty engine for autoselect -func MakeHardware(args *Args, engine string) { - for i, codec := range args.codecs { - if len(codec) < 12 { - continue // skip short line (-c:v libx264...) - } - - // get current codec name - name := cut(codec, ' ', 1) - switch name { - case "libx264": - name = "h264" - case "libx265": - name = "h265" - case "mjpeg": - default: - continue // skip unsupported codec - } - - // temporary disable probe for H265 and MJPEG - if engine == "" && name == "h264" { - if engine = cache[name]; engine == "" { - engine = ProbeHardware(name) - cache[name] = engine - } - } - - switch engine { - case EngineVAAPI: - args.input = "-hwaccel vaapi -hwaccel_output_format vaapi " + args.input - args.codecs[i] = defaults[name+"/"+engine] - - for i, filter := range args.filters { - if strings.HasPrefix(filter, "scale=") { - args.filters[i] = "scale_vaapi=" + filter[6:] - } - if strings.HasPrefix(filter, "transpose=") { - if filter == "transpose=1,transpose=1" { // 180 degrees half-turn - args.filters[i] = "transpose_vaapi=4" // reversal - } else { - args.filters[i] = "transpose_vaapi=" + filter[10:] - } - } - } - - // fix if input doesn't support hwaccel, do nothing when support - args.InsertFilter("format=vaapi|nv12,hwupload") - - case EngineCUDA: - args.input = "-hwaccel cuda -hwaccel_output_format cuda -extra_hw_frames 2 " + args.input - args.codecs[i] = defaults[name+"/"+engine] - - for i, filter := range args.filters { - if strings.HasPrefix(filter, "scale=") { - args.filters[i] = "scale_cuda=" + filter[6:] - } - } - - case EngineDXVA2: - args.input = "-hwaccel dxva2 -hwaccel_output_format dxva2_vld " + args.input - args.codecs[i] = defaults[name+"/"+engine] - - for i, filter := range args.filters { - if strings.HasPrefix(filter, "scale=") { - args.filters[i] = "scale_qsv=" + filter[6:] - } - } - - args.InsertFilter("hwmap=derive_device=qsv,format=qsv") - - case EngineVideoToolbox: - args.input = "-hwaccel videotoolbox -hwaccel_output_format videotoolbox_vld " + args.input - args.codecs[i] = defaults[name+"/"+engine] - - case EngineV4L2M2M: - args.codecs[i] = defaults[name+"/"+engine] - } - } -} - -func run(arg ...string) bool { - err := exec.Command(defaults["bin"], arg...).Run() - log.Printf("%v %v", arg, err) - return err == nil -} - -func cut(s string, sep byte, pos int) string { - for n := 0; n < pos; n++ { - if i := strings.IndexByte(s, sep); i > 0 { - s = s[i+1:] - } else { - return "" - } - } - if i := strings.IndexByte(s, sep); i > 0 { - return s[:i] - } - return s -} diff --git a/cmd/ffmpeg/hardware_darwin.go b/cmd/ffmpeg/hardware_darwin.go deleted file mode 100644 index fb4a7170..00000000 --- a/cmd/ffmpeg/hardware_darwin.go +++ /dev/null @@ -1,21 +0,0 @@ -package ffmpeg - -func ProbeHardware(name string) string { - switch name { - case "h264": - if run( - "-f", "lavfi", "-i", "testsrc2", "-t", "1", - "-c", "h264_videotoolbox", "-f", "null", "-") { - return EngineVideoToolbox - } - - case "h265": - if run( - "-f", "lavfi", "-i", "testsrc2", "-t", "1", - "-c", "hevc_videotoolbox", "-f", "null", "-") { - return EngineVideoToolbox - } - } - - return EngineSoftware -} diff --git a/cmd/ffmpeg/hardware_linux.go b/cmd/ffmpeg/hardware_linux.go deleted file mode 100644 index 00839bd1..00000000 --- a/cmd/ffmpeg/hardware_linux.go +++ /dev/null @@ -1,67 +0,0 @@ -package ffmpeg - -import ( - "runtime" -) - -func ProbeHardware(name string) string { - if runtime.GOARCH == "arm64" || runtime.GOARCH == "arm" { - switch name { - case "h264": - if run( - "-f", "lavfi", "-i", "testsrc2", "-t", "1", - "-c", "h264_v4l2m2m", "-f", "null", "-") { - return EngineV4L2M2M - } - - case "h265": - if run( - "-f", "lavfi", "-i", "testsrc2", "-t", "1", - "-c", "hevc_v4l2m2m", "-f", "null", "-") { - return EngineV4L2M2M - } - } - - return EngineSoftware - } - - switch name { - case "h264": - if run("-init_hw_device", "cuda", - "-f", "lavfi", "-i", "testsrc2", "-t", "1", - "-c", "h264_nvenc", "-f", "null", "-") { - return EngineCUDA - } - - if run("-init_hw_device", "vaapi", - "-f", "lavfi", "-i", "testsrc2", "-t", "1", - "-vf", "format=nv12,hwupload", - "-c", "h264_vaapi", "-f", "null", "-") { - return EngineVAAPI - } - - case "h265": - if run("-init_hw_device", "cuda", - "-f", "lavfi", "-i", "testsrc2", "-t", "1", - "-c", "hevc_nvenc", "-f", "null", "-") { - return EngineCUDA - } - - if run("-init_hw_device", "vaapi", - "-f", "lavfi", "-i", "testsrc2", "-t", "1", - "-vf", "format=nv12,hwupload", - "-c", "hevc_vaapi", "-f", "null", "-") { - return EngineVAAPI - } - - case "mjpeg": - if run("-init_hw_device", "vaapi", - "-f", "lavfi", "-i", "testsrc2", "-t", "1", - "-vf", "format=nv12,hwupload", - "-c", "mjpeg_vaapi", "-f", "null", "-") { - return EngineVAAPI - } - } - - return EngineSoftware -} diff --git a/cmd/ffmpeg/hardware_windows.go b/cmd/ffmpeg/hardware_windows.go deleted file mode 100644 index 4a259fe6..00000000 --- a/cmd/ffmpeg/hardware_windows.go +++ /dev/null @@ -1,40 +0,0 @@ -package ffmpeg - -func ProbeHardware(name string) string { - switch name { - case "h264": - if run("-init_hw_device", "cuda", - "-f", "lavfi", "-i", "testsrc2", "-t", "1", - "-c", "h264_nvenc", "-f", "null", "-") { - return EngineCUDA - } - - if run("-init_hw_device", "dxva2", - "-f", "lavfi", "-i", "testsrc2", "-t", "1", - "-c", "h264_qsv", "-f", "null", "-") { - return EngineDXVA2 - } - - case "h265": - if run("-init_hw_device", "cuda", - "-f", "lavfi", "-i", "testsrc2", "-t", "1", - "-c", "hevc_nvenc", "-f", "null", "-") { - return EngineCUDA - } - - if run("-init_hw_device", "dxva2", - "-f", "lavfi", "-i", "testsrc2", "-t", "1", - "-c", "hevc_qsv", "-f", "null", "-") { - return EngineDXVA2 - } - - case "mjpeg": - if run("-init_hw_device", "dxva2", - "-f", "lavfi", "-i", "testsrc2", "-t", "1", - "-c", "mjpeg_qsv", "-f", "null", "-") { - return EngineDXVA2 - } - } - - return EngineSoftware -} diff --git a/cmd/go2rtc_hass/main.go b/cmd/go2rtc_hass/main.go new file mode 100644 index 00000000..42c2d150 --- /dev/null +++ b/cmd/go2rtc_hass/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/hass" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/shell" +) + +func main() { + app.Init() + streams.Init() + + api.Init() + + hass.Init() + + shell.RunUntilSignal() +} diff --git a/cmd/go2rtc_rtsp/main.go b/cmd/go2rtc_rtsp/main.go new file mode 100644 index 00000000..07d32564 --- /dev/null +++ b/cmd/go2rtc_rtsp/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/rtsp" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/shell" +) + +func main() { + app.Init() + streams.Init() + + rtsp.Init() + + shell.RunUntilSignal() +} diff --git a/cmd/hass/api.go b/cmd/hass/api.go deleted file mode 100644 index 54a20c94..00000000 --- a/cmd/hass/api.go +++ /dev/null @@ -1,173 +0,0 @@ -package hass - -import ( - "encoding/base64" - "encoding/json" - "github.com/AlexxIT/go2rtc/cmd/api" - "github.com/AlexxIT/go2rtc/cmd/streams" - "github.com/AlexxIT/go2rtc/cmd/webrtc" - "net" - "net/http" - "net/url" - "strings" -) - -func initAPI() { - ok := func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"status":1,"payload":{}}`)) - } - - // support https://www.home-assistant.io/integrations/rtsp_to_webrtc/ - api.HandleFunc("/static", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - api.HandleFunc("/streams", ok) - - api.HandleFunc("/stream/", func(w http.ResponseWriter, r *http.Request) { - switch { - // /stream/{id}/add - case strings.HasSuffix(r.RequestURI, "/add"): - var v addJSON - if err := json.NewDecoder(r.Body).Decode(&v); err != nil { - return - } - - // we can get three types of links: - // 1. link to go2rtc stream: rtsp://...:8554/{stream_name} - // 2. static link to Hass camera - // 3. dynamic link to Hass camera - stream := streams.Get(v.Name) - if stream == nil { - // check if it is rtsp link to go2rtc - stream = rtspStream(v.Channels.First.Url) - if stream != nil { - streams.New(v.Name, stream) - } else { - stream = streams.New(v.Name, "{input}") - } - } - - stream.SetSource(v.Channels.First.Url) - - ok(w, r) - - // /stream/{id}/channel/0/webrtc - default: - i := strings.IndexByte(r.RequestURI[8:], '/') - if i <= 0 { - log.Warn().Msgf("wrong request: %s", r.RequestURI) - return - } - name := r.RequestURI[8 : 8+i] - - stream := streams.Get(name) - if stream == nil { - w.WriteHeader(http.StatusNotFound) - return - } - - if err := r.ParseForm(); err != nil { - log.Error().Err(err).Msg("[api.hass] parse form") - return - } - - s := r.FormValue("data") - offer, err := base64.StdEncoding.DecodeString(s) - if err != nil { - log.Error().Err(err).Msg("[api.hass] sdp64 decode") - return - } - - s, err = webrtc.ExchangeSDP(stream, string(offer), "WebRTC/Hass sync", r.UserAgent()) - if err != nil { - log.Error().Err(err).Msg("[api.hass] exchange SDP") - return - } - - s = base64.StdEncoding.EncodeToString([]byte(s)) - _, _ = w.Write([]byte(s)) - } - }) - - // api from RTSPtoWebRTC - api.HandleFunc("/stream", func(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - return - } - - str := r.FormValue("sdp64") - offer, err := base64.StdEncoding.DecodeString(str) - if err != nil { - return - } - - src := r.FormValue("url") - src, err = url.QueryUnescape(src) - if err != nil { - return - } - - stream := streams.Get(src) - if stream == nil { - if stream = rtspStream(src); stream != nil { - streams.New(src, stream) - } else { - stream = streams.New(src, src) - } - } - - str, err = webrtc.ExchangeSDP(stream, string(offer), "WebRTC/Hass sync", r.UserAgent()) - if err != nil { - return - } - - v := struct { - Answer string `json:"sdp64"` - }{ - Answer: base64.StdEncoding.EncodeToString([]byte(str)), - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(v) - }) -} - -func HassioAddr() string { - ints, _ := net.Interfaces() - - for _, i := range ints { - if i.Name != "hassio" { - continue - } - - addrs, _ := i.Addrs() - for _, addr := range addrs { - if addr, ok := addr.(*net.IPNet); ok { - return addr.IP.String() - } - } - } - - return "" -} - -func rtspStream(url string) *streams.Stream { - if strings.HasPrefix(url, "rtsp://") { - if i := strings.IndexByte(url[7:], '/'); i > 0 { - return streams.Get(url[8+i:]) - } - } - return nil -} - -type addJSON struct { - Name string `json:"name"` - Channels struct { - First struct { - //Name string `json:"name"` - Url string `json:"url"` - } `json:"0"` - } `json:"channels"` -} diff --git a/cmd/http/http.go b/cmd/http/http.go deleted file mode 100644 index a261b0c6..00000000 --- a/cmd/http/http.go +++ /dev/null @@ -1,65 +0,0 @@ -package http - -import ( - "errors" - "fmt" - "github.com/AlexxIT/go2rtc/cmd/streams" - "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/AlexxIT/go2rtc/pkg/mjpeg" - "github.com/AlexxIT/go2rtc/pkg/mpegts" - "github.com/AlexxIT/go2rtc/pkg/rtmp" - "github.com/AlexxIT/go2rtc/pkg/tcp" - "net/http" - "strings" -) - -func Init() { - streams.HandleFunc("http", handle) - streams.HandleFunc("https", handle) -} - -func handle(url string) (core.Producer, error) { - // first we get the Content-Type to define supported producer - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } - - res, err := tcp.Do(req) - if err != nil { - return nil, err - } - - if res.StatusCode != http.StatusOK { - return nil, errors.New(res.Status) - } - - ct := res.Header.Get("Content-Type") - if i := strings.IndexByte(ct, ';'); i > 0 { - ct = ct[:i] - } - - switch ct { - case "image/jpeg", "multipart/x-mixed-replace": - return mjpeg.NewClient(res), nil - - case "video/x-flv": - var conn *rtmp.Client - if conn, err = rtmp.Accept(res); err != nil { - return nil, err - } - if err = conn.Describe(); err != nil { - return nil, err - } - return conn, nil - - case "video/mpeg": - client := mpegts.NewClient(res) - if err = client.Handle(); err != nil { - return nil, err - } - return client, nil - } - - return nil, fmt.Errorf("unsupported Content-Type: %s", ct) -} diff --git a/cmd/tcp/init.go b/cmd/tcp/init.go deleted file mode 100644 index 353d28e6..00000000 --- a/cmd/tcp/init.go +++ /dev/null @@ -1,35 +0,0 @@ -package tcp - -import ( - "github.com/AlexxIT/go2rtc/cmd/streams" - "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/AlexxIT/go2rtc/pkg/mpegts" - "net" - "net/http" - "net/url" - "time" -) - -func Init() { - streams.HandleFunc("tcp", handle) -} - -func handle(rawURL string) (core.Producer, error) { - u, err := url.Parse(rawURL) - if err != nil { - return nil, err - } - - conn, err := net.DialTimeout("tcp", u.Host, time.Second*3) - if err != nil { - return nil, err - } - - req := &http.Request{URL: u} - res := &http.Response{Body: conn, Request: req} - client := mpegts.NewClient(res) - if err := client.Handle(); err != nil { - return nil, err - } - return client, nil -} diff --git a/go.mod b/go.mod index 6a0a8307..beae7f79 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,8 @@ require ( github.com/pion/stun v0.4.0 github.com/pion/webrtc/v3 v3.1.58 github.com/rs/zerolog v1.29.0 + github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3 + github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f github.com/stretchr/testify v1.8.2 github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index f62d2bc1..20a2e4ee 100644 --- a/go.sum +++ b/go.sum @@ -106,6 +106,10 @@ github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w= github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw= +github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3 h1:aQKxg3+2p+IFXXg97McgDGT5zcMrQoi0EICZs8Pgchs= +github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA= +github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f h1:1R9KdKjCNSd7F8iGTxIpoID9prlYH8nuNYKt0XvweHA= +github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f/go.mod h1:vQhwQ4meQEDfahT5kd61wLAF5AAeh5ZPLVI4JJ/tYo8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= diff --git a/hardware.Dockerfile b/hardware.Dockerfile index d23c1691..c6424d29 100644 --- a/hardware.Dockerfile +++ b/hardware.Dockerfile @@ -38,9 +38,13 @@ RUN rm -f /etc/apt/apt.conf.d/docker-clean \ # Install ffmpeg, bash (for run.sh), tini (for signal handling), # and other common tools for the echo source. # non-free for Intel QSV support (not used by go2rtc, just for tests) +# libasound2-plugins for ALSA support RUN --mount=type=cache,target=/var/cache/apt,sharing=locked --mount=type=cache,target=/var/lib/apt,sharing=locked \ echo 'deb http://deb.debian.org/debian bookworm non-free' > /etc/apt/sources.list.d/debian-non-free.list && \ - apt-get -y update && apt-get -y install tini ffmpeg python3 curl jq intel-media-va-driver-non-free + apt-get -y update && apt-get -y install tini ffmpeg \ + python3 curl jq \ + intel-media-va-driver-non-free \ + libasound2-plugins COPY --link --from=rootfs / / diff --git a/internal/README.md b/internal/README.md new file mode 100644 index 00000000..e200c163 --- /dev/null +++ b/internal/README.md @@ -0,0 +1,11 @@ +## Go + +``` +go mod why github.com/pion/rtcp +go list -deps .\cmd\go2rtc_rtsp\ +``` + +## Useful links + +- https://github.com/golang-standards/project-layout +- https://github.com/micro/micro diff --git a/cmd/api/api.go b/internal/api/api.go similarity index 99% rename from cmd/api/api.go rename to internal/api/api.go index f261a6eb..66f738a9 100644 --- a/cmd/api/api.go +++ b/internal/api/api.go @@ -3,7 +3,7 @@ package api import ( "crypto/tls" "encoding/json" - "github.com/AlexxIT/go2rtc/cmd/app" + "github.com/AlexxIT/go2rtc/internal/app" "github.com/rs/zerolog" "net" "net/http" diff --git a/cmd/api/config.go b/internal/api/config.go similarity index 98% rename from cmd/api/config.go rename to internal/api/config.go index 1bc257cb..d817b689 100644 --- a/cmd/api/config.go +++ b/internal/api/config.go @@ -1,7 +1,7 @@ package api import ( - "github.com/AlexxIT/go2rtc/cmd/app" + "github.com/AlexxIT/go2rtc/internal/app" "gopkg.in/yaml.v3" "io" "net/http" diff --git a/cmd/api/static.go b/internal/api/static.go similarity index 100% rename from cmd/api/static.go rename to internal/api/static.go diff --git a/cmd/api/ws.go b/internal/api/ws.go similarity index 100% rename from cmd/api/ws.go rename to internal/api/ws.go diff --git a/cmd/app/app.go b/internal/app/app.go similarity index 99% rename from cmd/app/app.go rename to internal/app/app.go index 9a66a535..8aeeee0f 100644 --- a/cmd/app/app.go +++ b/internal/app/app.go @@ -16,7 +16,7 @@ import ( "gopkg.in/yaml.v3" ) -var Version = "1.3.1" +var Version = "1.5.0" var UserAgent = "go2rtc/" + Version var ConfigPath string diff --git a/cmd/app/store/store.go b/internal/app/store/store.go similarity index 100% rename from cmd/app/store/store.go rename to internal/app/store/store.go diff --git a/cmd/debug/debug.go b/internal/debug/debug.go similarity index 72% rename from cmd/debug/debug.go rename to internal/debug/debug.go index 90861b37..3d40d1f1 100644 --- a/cmd/debug/debug.go +++ b/internal/debug/debug.go @@ -1,8 +1,8 @@ package debug import ( - "github.com/AlexxIT/go2rtc/cmd/api" - "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" ) diff --git a/cmd/debug/stack.go b/internal/debug/stack.go similarity index 79% rename from cmd/debug/stack.go rename to internal/debug/stack.go index 048ed77e..5d23cb5b 100644 --- a/cmd/debug/stack.go +++ b/internal/debug/stack.go @@ -13,15 +13,15 @@ var stackSkip = [][]byte{ []byte("created by os/signal.Notify"), // api/stack.go - []byte("github.com/AlexxIT/go2rtc/cmd/api.stackHandler"), + []byte("github.com/AlexxIT/go2rtc/internal/api.stackHandler"), // api/api.go - []byte("created by github.com/AlexxIT/go2rtc/cmd/api.Init"), + []byte("created by github.com/AlexxIT/go2rtc/internal/api.Init"), []byte("created by net/http.(*connReader).startBackgroundRead"), []byte("created by net/http.(*Server).Serve"), // TODO: why two? - []byte("created by github.com/AlexxIT/go2rtc/cmd/rtsp.Init"), - []byte("created by github.com/AlexxIT/go2rtc/cmd/srtp.Init"), + []byte("created by github.com/AlexxIT/go2rtc/internal/rtsp.Init"), + []byte("created by github.com/AlexxIT/go2rtc/internal/srtp.Init"), // webrtc/api.go []byte("created by github.com/pion/ice/v2.NewTCPMuxDefault"), diff --git a/cmd/dvrip/dvrip.go b/internal/dvrip/dvrip.go similarity index 90% rename from cmd/dvrip/dvrip.go rename to internal/dvrip/dvrip.go index 0826b009..80fe760d 100644 --- a/cmd/dvrip/dvrip.go +++ b/internal/dvrip/dvrip.go @@ -1,7 +1,7 @@ package dvrip import ( - "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/dvrip" ) diff --git a/cmd/echo/echo.go b/internal/echo/echo.go similarity index 84% rename from cmd/echo/echo.go rename to internal/echo/echo.go index d0714fc9..6d7644f7 100644 --- a/cmd/echo/echo.go +++ b/internal/echo/echo.go @@ -2,8 +2,8 @@ package echo import ( "bytes" - "github.com/AlexxIT/go2rtc/cmd/app" - "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/shell" "os/exec" diff --git a/cmd/exec/exec.go b/internal/exec/exec.go similarity index 60% rename from cmd/exec/exec.go rename to internal/exec/exec.go index 20d2fc14..1b7842dd 100644 --- a/cmd/exec/exec.go +++ b/internal/exec/exec.go @@ -5,26 +5,21 @@ import ( "encoding/hex" "errors" "fmt" - "github.com/AlexxIT/go2rtc/cmd/app" - "github.com/AlexxIT/go2rtc/cmd/rtsp" - "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/rtsp" + "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/magic" pkg "github.com/AlexxIT/go2rtc/pkg/rtsp" "github.com/AlexxIT/go2rtc/pkg/shell" "github.com/rs/zerolog" "os" "os/exec" - "strings" "sync" "time" ) func Init() { - // depends on RTSP server - if rtsp.Port == "" { - return - } - rtsp.HandleFunc(func(conn *pkg.Conn) bool { waitersMu.Lock() waiter := waiters[conn.URL.Path] @@ -43,30 +38,66 @@ func Init() { } }) - streams.HandleFunc("exec", Handle) + streams.HandleFunc("exec", execHandle) log = app.GetLogger("exec") } -func Handle(url string) (core.Producer, error) { - sum := md5.Sum([]byte(url)) - path := "/" + hex.EncodeToString(sum[:]) +func execHandle(url string) (core.Producer, error) { + var path string - url = strings.Replace( - url, "{output}", "rtsp://localhost:"+rtsp.Port+path, 1, - ) + args := shell.QuoteSplit(url[5:]) // remove `exec:` + for i, arg := range args { + if arg == "{output}" { + if rtsp.Port == "" { + return nil, errors.New("rtsp module disabled") + } - // remove `exec:` - args := shell.QuoteSplit(url[5:]) - cmd := exec.Command(args[0], args[1:]...) - - if log.Trace().Enabled() { - cmd.Stdout = os.Stdout + sum := md5.Sum([]byte(url)) + path = "/" + hex.EncodeToString(sum[:]) + args[i] = "rtsp://127.0.0.1:" + rtsp.Port + path + break + } } + + cmd := exec.Command(args[0], args[1:]...) if log.Debug().Enabled() { cmd.Stderr = os.Stderr } + if path == "" { + return handlePipe(url, cmd) + } + + return handleRTSP(url, path, cmd) +} + +func handlePipe(url string, cmd *exec.Cmd) (core.Producer, error) { + r, err := PipeCloser(cmd) + if err != nil { + return nil, err + } + + if err = cmd.Start(); err != nil { + return nil, err + } + + client := magic.NewClient(r) + if err = client.Probe(); err != nil { + return nil, err + } + + client.Desc = "exec active producer" + client.URL = url + + return client, nil +} + +func handleRTSP(url, path string, cmd *exec.Cmd) (core.Producer, error) { + if log.Trace().Enabled() { + cmd.Stdout = os.Stdout + } + ch := make(chan core.Producer) waitersMu.Lock() diff --git a/internal/exec/pipe.go b/internal/exec/pipe.go new file mode 100644 index 00000000..de101e04 --- /dev/null +++ b/internal/exec/pipe.go @@ -0,0 +1,26 @@ +package exec + +import ( + "github.com/AlexxIT/go2rtc/pkg/core" + "io" + "os/exec" +) + +// PipeCloser - return StdoutPipe that Kill cmd on Close call +func PipeCloser(cmd *exec.Cmd) (io.ReadCloser, error) { + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + + return pipeCloser{stdout, cmd}, nil +} + +type pipeCloser struct { + io.ReadCloser + cmd *exec.Cmd +} + +func (p pipeCloser) Close() error { + return core.Any(p.ReadCloser.Close(), p.cmd.Process.Kill(), p.cmd.Wait()) +} diff --git a/cmd/ffmpeg/README.md b/internal/ffmpeg/README.md similarity index 100% rename from cmd/ffmpeg/README.md rename to internal/ffmpeg/README.md diff --git a/internal/ffmpeg/device/device_darwin.go b/internal/ffmpeg/device/device_darwin.go new file mode 100644 index 00000000..7526b885 --- /dev/null +++ b/internal/ffmpeg/device/device_darwin.go @@ -0,0 +1,68 @@ +package device + +import ( + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/pkg/core" + "os/exec" + "regexp" + "strings" +) + +// https://trac.ffmpeg.org/wiki/Capture/Webcam +const deviceInputPrefix = "-f avfoundation" + +func deviceInputSuffix(video, audio string) string { + switch { + case video != "" && audio != "": + return `"` + video + `:` + audio + `"` + case video != "": + return `"` + video + `"` + case audio != "": + return `":` + audio + `"` + } + return "" +} + +func initDevices() { + // [AVFoundation indev @ 0x147f04510] AVFoundation video devices: + // [AVFoundation indev @ 0x147f04510] [0] FaceTime HD Camera + // [AVFoundation indev @ 0x147f04510] [1] Capture screen 0 + // [AVFoundation indev @ 0x147f04510] AVFoundation audio devices: + // [AVFoundation indev @ 0x147f04510] [0] MacBook Pro Microphone + cmd := exec.Command( + Bin, "-hide_banner", "-list_devices", "true", "-f", "avfoundation", "-i", "", + ) + b, _ := cmd.CombinedOutput() + + re := regexp.MustCompile(`\[\d+] (.+)`) + + var kind string + for _, line := range strings.Split(string(b), "\n") { + switch { + case strings.HasSuffix(line, "video devices:"): + kind = core.KindVideo + continue + case strings.HasSuffix(line, "audio devices:"): + kind = core.KindAudio + continue + } + + m := re.FindStringSubmatch(line) + if m == nil { + continue + } + + name := m[1] + + switch kind { + case core.KindVideo: + videos = append(videos, name) + case core.KindAudio: + audios = append(audios, name) + } + + streams = append(streams, api.Stream{ + Name: name, URL: "ffmpeg:device?" + kind + "=" + name, + }) + } +} diff --git a/internal/ffmpeg/device/device_linux.go b/internal/ffmpeg/device/device_linux.go new file mode 100644 index 00000000..1b35472c --- /dev/null +++ b/internal/ffmpeg/device/device_linux.go @@ -0,0 +1,60 @@ +package device + +import ( + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/pkg/core" + "os" + "os/exec" + "regexp" + "strings" +) + +// https://trac.ffmpeg.org/wiki/Capture/Webcam +const deviceInputPrefix = "-f v4l2" + +func deviceInputSuffix(video, audio string) string { + if video != "" { + return video + } + 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 @ 0x204e1c0] Compressed: mjpeg : Motion-JPEG : 640x360 1280x720 1920x1080 + // [video4linux2,v4l2 @ 0x204e1c0] Raw : yuyv422 : YUYV 4:2:2 : 640x360 1280x720 1920x1080 + // [video4linux2,v4l2 @ 0x204e1c0] Compressed: h264 : H.264 : 640x360 1280x720 1920x1080 + re := regexp.MustCompile("(Raw *|Compressed): +(.+?) : +(.+?) : (.+)") + m := re.FindAllStringSubmatch(string(b), -1) + for _, i := range m { + size, _, _ := strings.Cut(i[4], " ") + stream := api.Stream{ + Name: i[3] + " | " + 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) + } + } +} diff --git a/internal/ffmpeg/device/device_windows.go b/internal/ffmpeg/device/device_windows.go new file mode 100644 index 00000000..1f614891 --- /dev/null +++ b/internal/ffmpeg/device/device_windows.go @@ -0,0 +1,50 @@ +package device + +import ( + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/pkg/core" + "os/exec" + "regexp" +) + +// https://trac.ffmpeg.org/wiki/DirectShow +const deviceInputPrefix = "-f dshow" + +func deviceInputSuffix(video, audio string) string { + switch { + case video != "" && audio != "": + return `video="` + video + `":audio=` + audio + `"` + case video != "": + return `video="` + video + `"` + case audio != "": + return `audio="` + audio + `"` + } + return "" +} + +func initDevices() { + cmd := exec.Command( + Bin, "-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "", + ) + b, _ := cmd.CombinedOutput() + + re := regexp.MustCompile(`"([^"]+)" \((video|audio)\)`) + for _, m := range re.FindAllStringSubmatch(string(b), -1) { + name := m[1] + kind := m[2] + + stream := api.Stream{ + Name: name, URL: "ffmpeg:device?" + kind + "=" + name, + } + + switch kind { + case core.KindVideo: + videos = append(videos, name) + stream.URL += "#video=h264#hardware" + case core.KindAudio: + audios = append(audios, name) + } + + streams = append(streams, stream) + } +} diff --git a/internal/ffmpeg/device/devices.go b/internal/ffmpeg/device/devices.go new file mode 100644 index 00000000..3e657906 --- /dev/null +++ b/internal/ffmpeg/device/devices.go @@ -0,0 +1,70 @@ +package device + +import ( + "github.com/AlexxIT/go2rtc/internal/api" + "net/http" + "net/url" + "strconv" + "strings" + "sync" +) + +func Init(bin string) { + Bin = bin + + api.HandleFunc("api/ffmpeg/devices", apiDevices) +} + +func GetInput(src string) (string, error) { + runonce.Do(initDevices) + + input := deviceInputPrefix + + var video, audio string + + if i := strings.IndexByte(src, '?'); i > 0 { + query, err := url.ParseQuery(src[i+1:]) + if err != nil { + return "", err + } + for key, value := range query { + switch key { + case "video": + video = value[0] + case "audio": + audio = value[0] + case "resolution": + input += " -video_size " + value[0] + default: // "input_format", "framerate", "video_size" + input += " -" + key + " " + value[0] + } + } + } + + if video != "" { + if i, err := strconv.Atoi(video); err == nil && i < len(videos) { + video = videos[i] + } + } + if audio != "" { + if i, err := strconv.Atoi(audio); err == nil && i < len(audios) { + audio = audios[i] + } + } + + input += " -i " + deviceInputSuffix(video, audio) + + return input, nil +} + +var Bin string + +var videos, audios []string +var streams []api.Stream +var runonce sync.Once + +func apiDevices(w http.ResponseWriter, r *http.Request) { + runonce.Do(initDevices) + + api.ResponseStreams(w, streams) +} diff --git a/cmd/ffmpeg/ffmpeg.go b/internal/ffmpeg/ffmpeg.go similarity index 61% rename from cmd/ffmpeg/ffmpeg.go rename to internal/ffmpeg/ffmpeg.go index 1e61122f..d185a5f6 100644 --- a/cmd/ffmpeg/ffmpeg.go +++ b/internal/ffmpeg/ffmpeg.go @@ -1,16 +1,15 @@ package ffmpeg import ( - "bytes" "errors" - "github.com/AlexxIT/go2rtc/cmd/app" - "github.com/AlexxIT/go2rtc/cmd/exec" - "github.com/AlexxIT/go2rtc/cmd/ffmpeg/device" - "github.com/AlexxIT/go2rtc/cmd/rtsp" - "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/ffmpeg/device" + "github.com/AlexxIT/go2rtc/internal/ffmpeg/hardware" + "github.com/AlexxIT/go2rtc/internal/rtsp" + "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/ffmpeg" "net/url" - "strconv" "strings" ) @@ -32,11 +31,11 @@ func Init() { if args == nil { return nil, errors.New("can't generate ffmpeg command") } - return exec.Handle("exec:" + args.String()) + return streams.GetProducer("exec:" + args.String()) }) - device.Bin = defaults["bin"] - device.Init() + device.Init(defaults["bin"]) + hardware.Init(defaults["bin"]) } var defaults = map[string]string{ @@ -46,21 +45,24 @@ var defaults = map[string]string{ // inputs "file": "-re -i {input}", "http": "-fflags nobuffer -flags low_delay -i {input}", - "rtsp": "-fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_transport tcp -i {input}", + "rtsp": "-fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i {input}", "rtsp/udp": "-fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -i {input}", // output - "output": "-user_agent ffmpeg/go2rtc -rtsp_transport tcp -bufsize 8192k -f rtsp {output}", + "output": "-user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}", + "output/mjpeg": "-f mjpeg -", // `-preset superfast` - we can't use ultrafast because it doesn't support `-profile main -level 4.1` // `-tune zerolatency` - for minimal latency // `-profile high -level 4.1` - most used streaming profile - "h264": "-c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency", - "h265": "-c:v libx265 -g 50 -profile:v high -level:v 5.1 -preset:v superfast -tune:v zerolatency", - "mjpeg": "-c:v mjpeg -force_duplicated_matrix:v 1 -huffman:v 0 -pix_fmt:v yuvj420p", + "h264": "-c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuvj420p", + "h265": "-c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency", + "mjpeg": "-c:v mjpeg", + //"mjpeg": "-c:v mjpeg -force_duplicated_matrix:v 1 -huffman:v 0 -pix_fmt:v yuvj420p", - "opus": "-c:a libopus -ar:a 48000 -ac:a 2", + // https://ffmpeg.org/ffmpeg-codecs.html#libopus-1 + "opus": "-c:a libopus -ar:a 48000 -ac:a 2 -application:a voip -compression_level:a 0", "pcmu": "-c:a pcm_mulaw -ar:a 8000 -ac:a 1", "pcmu/16000": "-c:a pcm_mulaw -ar:a 16000 -ac:a 1", "pcmu/48000": "-c:a pcm_mulaw -ar:a 48000 -ac:a 1", @@ -70,8 +72,7 @@ var defaults = map[string]string{ "aac": "-c:a aac", // keep sample rate and channels "aac/16000": "-c:a aac -ar:a 16000 -ac:a 1", "mp3": "-c:a libmp3lame -q:a 8", - "pcm": "-c:a pcm_s16be", - "pcm/8000": "-c:a pcm_s16be -ar:a 8000 -ac:a 1", + "pcm": "-c:a pcm_s16be -ar:a 8000 -ac:a 1", "pcm/16000": "-c:a pcm_s16be -ar:a 16000 -ac:a 1", "pcm/48000": "-c:a pcm_s16be -ar:a 48000 -ac:a 1", @@ -116,19 +117,19 @@ func inputTemplate(name, s string, query url.Values) string { return strings.Replace(template, "{input}", s, 1) } -func parseArgs(s string) *Args { +func parseArgs(s string) *ffmpeg.Args { // init FFmpeg arguments - args := &Args{ - bin: defaults["bin"], - global: defaults["global"], - output: defaults["output"], + args := &ffmpeg.Args{ + Bin: defaults["bin"], + Global: defaults["global"], + Output: defaults["output"], } var query url.Values if i := strings.IndexByte(s, '#'); i > 0 { - query = parseQuery(s[i+1:]) - args.video = len(query["video"]) - args.audio = len(query["audio"]) + query = streams.ParseQuery(s[i+1:]) + args.Video = len(query["video"]) + args.Audio = len(query["audio"]) s = s[:i] } @@ -139,46 +140,46 @@ func parseArgs(s string) *Args { if i := strings.Index(s, "://"); i > 0 { switch s[:i] { case "http", "https", "rtmp": - args.input = inputTemplate("http", s, query) + args.Input = inputTemplate("http", s, query) case "rtsp", "rtsps": // https://ffmpeg.org/ffmpeg-protocols.html#rtsp // skip unnecessary input tracks switch { - case (args.video > 0 && args.audio > 0) || (args.video == 0 && args.audio == 0): - args.input = "-allowed_media_types video+audio " - case args.video > 0: - args.input = "-allowed_media_types video " - case args.audio > 0: - args.input = "-allowed_media_types audio " + case (args.Video > 0 && args.Audio > 0) || (args.Video == 0 && args.Audio == 0): + args.Input = "-allowed_media_types video+audio " + case args.Video > 0: + args.Input = "-allowed_media_types video " + case args.Audio > 0: + args.Input = "-allowed_media_types audio " } - args.input += inputTemplate("rtsp", s, query) + args.Input += inputTemplate("rtsp", s, query) default: - args.input = "-i " + s + args.Input = "-i " + s } } else if streams.Get(s) != nil { - s = "rtsp://localhost:" + rtsp.Port + "/" + s + s = "rtsp://127.0.0.1:" + rtsp.Port + "/" + s switch { - case args.video > 0 && args.audio == 0: + case args.Video > 0 && args.Audio == 0: s += "?video" - case args.audio > 0 && args.video == 0: + case args.Audio > 0 && args.Video == 0: s += "?audio" default: s += "?video&audio" } - args.input = inputTemplate("rtsp", s, query) + args.Input = inputTemplate("rtsp", s, query) } else if strings.HasPrefix(s, "device?") { var err error - args.input, err = device.GetInput(s) + args.Input, err = device.GetInput(s) if err != nil { return nil } } else { - args.input = inputTemplate("file", s, query) + args.Input = inputTemplate("file", s, query) } if query["async"] != nil { - args.input = "-use_wallclock_as_timestamps 1 -async 1 " + args.input + args.Input = "-use_wallclock_as_timestamps 1 -async 1 " + args.Input } // Parse query params: @@ -226,7 +227,7 @@ func parseArgs(s string) *Args { } // 3. Process video codecs - if args.video > 0 { + if args.Video > 0 { for _, video := range query["video"] { if video != "copy" { if codec := defaults[video]; codec != "" { @@ -243,7 +244,7 @@ func parseArgs(s string) *Args { } // 4. Process audio codecs - if args.audio > 0 { + if args.Audio > 0 { for _, audio := range query["audio"] { if audio != "copy" { if codec := defaults[audio]; codec != "" { @@ -260,99 +261,20 @@ func parseArgs(s string) *Args { } if query["hardware"] != nil { - MakeHardware(args, query["hardware"][0]) + hardware.MakeHardware(args, query["hardware"][0], defaults) } } - if args.codecs == nil { + if args.Codecs == nil { args.AddCodec("-c copy") } + // transcoding to only mjpeg + if (args.Video == 1 && args.Audio == 0 && query.Get("video") == "mjpeg") || + // no transcoding from mjpeg input + (args.Video == 0 && args.Audio == 0 && strings.Contains(args.Input, " mjpeg ")) { + args.Output = defaults["output/mjpeg"] + } + return args } - -func parseQuery(s string) map[string][]string { - query := map[string][]string{} - for _, key := range strings.Split(s, "#") { - var value string - i := strings.IndexByte(key, '=') - if i > 0 { - key, value = key[:i], key[i+1:] - } - query[key] = append(query[key], value) - } - return query -} - -type Args struct { - bin string // ffmpeg - global string // -hide_banner -v error - input string // -re -stream_loop -1 -i /media/bunny.mp4 - codecs []string // -c:v libx264 -g:v 30 -preset:v ultrafast -tune:v zerolatency - filters []string // scale=1920:1080 - output string // -f rtsp {output} - - video, audio int // count of video and audio params -} - -func (a *Args) AddCodec(codec string) { - a.codecs = append(a.codecs, codec) -} - -func (a *Args) AddFilter(filter string) { - a.filters = append(a.filters, filter) -} - -func (a *Args) InsertFilter(filter string) { - a.filters = append([]string{filter}, a.filters...) -} - -func (a *Args) String() string { - b := bytes.NewBuffer(make([]byte, 0, 512)) - - b.WriteString(a.bin) - - if a.global != "" { - b.WriteByte(' ') - b.WriteString(a.global) - } - - b.WriteByte(' ') - b.WriteString(a.input) - - multimode := a.video > 1 || a.audio > 1 - var iv, ia int - - for _, codec := range a.codecs { - // support multiple video and/or audio codecs - if multimode && len(codec) >= 5 { - switch codec[:5] { - case "-c:v ": - codec = "-map 0:v:0? " + strings.ReplaceAll(codec, ":v ", ":v:"+strconv.Itoa(iv)+" ") - iv++ - case "-c:a ": - codec = "-map 0:a:0? " + strings.ReplaceAll(codec, ":a ", ":a:"+strconv.Itoa(ia)+" ") - ia++ - } - } - - b.WriteByte(' ') - b.WriteString(codec) - } - - if a.filters != nil { - for i, filter := range a.filters { - if i == 0 { - b.WriteString(" -vf ") - } else { - b.WriteByte(',') - } - b.WriteString(filter) - } - } - - b.WriteByte(' ') - b.WriteString(a.output) - - return b.String() -} diff --git a/cmd/ffmpeg/ffmpeg_test.go b/internal/ffmpeg/ffmpeg_test.go similarity index 55% rename from cmd/ffmpeg/ffmpeg_test.go rename to internal/ffmpeg/ffmpeg_test.go index 6fdd9a91..c778babf 100644 --- a/cmd/ffmpeg/ffmpeg_test.go +++ b/internal/ffmpeg/ffmpeg_test.go @@ -7,8 +7,17 @@ import ( func TestParseArgs(t *testing.T) { args := parseArgs("rtsp://example.com#video=h264#rotate=180") - assert.Equal(t, "ffmpeg -hide_banner -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_transport tcp -i rtsp://example.com -c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -an -vf transpose=1,transpose=1 -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}", args.String()) + assert.Equal(t, "ffmpeg -hide_banner -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_transport tcp -i rtsp://example.com -c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuvj420p -an -vf transpose=1,transpose=1 -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}", args.String()) args = parseArgs("rtsp://example.com#video=h264#rotate=180#hardware=vaapi") assert.Equal(t, "ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_transport tcp -i rtsp://example.com -c:v h264_vaapi -g 50 -bf 0 -profile:v high -level:v 4.1 -sei:v 0 -an -vf format=vaapi|nv12,hwupload,transpose_vaapi=4 -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}", args.String()) + + args = parseArgs("/media/bbb.mp4#video=mjpeg") + assert.Equal(t, "ffmpeg -hide_banner -re -i /media/bbb.mp4 -c:v mjpeg -an -f mjpeg -", args.String()) + + args = parseArgs("/media/bbb.mp4#video=mjpeg#hardware=vaapi") + assert.Equal(t, "ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -re -i /media/bbb.mp4 -c:v mjpeg_vaapi -an -vf format=vaapi|nv12,hwupload -f mjpeg -", args.String()) + + args = parseArgs("device?video=0&input_format=mjpeg&video_size=1920x1080") + assert.Equal(t, `ffmpeg -hide_banner -f dshow -input_format mjpeg -video_size 1920x1080 -i video="0" -c copy -f mjpeg -`, args.String()) } diff --git a/internal/ffmpeg/hardware/hardware.go b/internal/ffmpeg/hardware/hardware.go new file mode 100644 index 00000000..bb8f7174 --- /dev/null +++ b/internal/ffmpeg/hardware/hardware.go @@ -0,0 +1,137 @@ +package hardware + +import ( + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/pkg/ffmpeg" + "net/http" + "os/exec" + "strings" + + "github.com/rs/zerolog/log" +) + +const ( + EngineSoftware = "software" + EngineVAAPI = "vaapi" // Intel iGPU and AMD GPU + EngineV4L2M2M = "v4l2m2m" // Raspberry Pi 3 and 4 + EngineCUDA = "cuda" // NVidia on Windows and Linux + EngineDXVA2 = "dxva2" // Intel on Windows + EngineVideoToolbox = "videotoolbox" // macOS +) + +func Init(bin string) { + api.HandleFunc("api/ffmpeg/hardware", func(w http.ResponseWriter, r *http.Request) { + api.ResponseStreams(w, ProbeAll(bin)) + }) +} + +// MakeHardware converts software FFmpeg args to hardware args +// empty engine for autoselect +func MakeHardware(args *ffmpeg.Args, engine string, defaults map[string]string) { + for i, codec := range args.Codecs { + if len(codec) < 10 { + continue // skip short line (-c:v mjpeg...) + } + + // get current codec name + name := cut(codec, ' ', 1) + switch name { + case "libx264": + name = "h264" + case "libx265": + name = "h265" + case "mjpeg": + default: + continue // skip unsupported codec + } + + // temporary disable probe for H265 + if engine == "" && name != "h265" { + if engine = cache[name]; engine == "" { + engine = ProbeHardware(args.Bin, name) + cache[name] = engine + } + } + + switch engine { + case EngineVAAPI: + args.Input = "-hwaccel vaapi -hwaccel_output_format vaapi " + args.Input + args.Codecs[i] = defaults[name+"/"+engine] + + for i, filter := range args.Filters { + if strings.HasPrefix(filter, "scale=") { + args.Filters[i] = "scale_vaapi=" + filter[6:] + } + if strings.HasPrefix(filter, "transpose=") { + if filter == "transpose=1,transpose=1" { // 180 degrees half-turn + args.Filters[i] = "transpose_vaapi=4" // reversal + } else { + args.Filters[i] = "transpose_vaapi=" + filter[10:] + } + } + } + + // fix if input doesn't support hwaccel, do nothing when support + args.InsertFilter("format=vaapi|nv12,hwupload") + + case EngineCUDA: + args.Input = "-hwaccel cuda -hwaccel_output_format cuda -extra_hw_frames 2 " + args.Input + args.Codecs[i] = defaults[name+"/"+engine] + + for i, filter := range args.Filters { + if strings.HasPrefix(filter, "scale=") { + args.Filters[i] = "scale_cuda=" + filter[6:] + } + } + + case EngineDXVA2: + args.Input = "-hwaccel dxva2 -hwaccel_output_format dxva2_vld " + args.Input + args.Codecs[i] = defaults[name+"/"+engine] + + for i, filter := range args.Filters { + if strings.HasPrefix(filter, "scale=") { + args.Filters[i] = "scale_qsv=" + filter[6:] + } + } + + args.InsertFilter("hwmap=derive_device=qsv,format=qsv") + + case EngineVideoToolbox: + args.Input = "-hwaccel videotoolbox -hwaccel_output_format videotoolbox_vld " + args.Input + args.Codecs[i] = defaults[name+"/"+engine] + + case EngineV4L2M2M: + args.Codecs[i] = defaults[name+"/"+engine] + } + } +} + +var cache = map[string]string{} + +func run(bin string, args string) bool { + err := exec.Command(bin, strings.Split(args, " ")...).Run() + log.Printf("%v %v", args, err) + return err == nil +} + +func runToString(bin string, args string) string { + if run(bin, args) { + return "OK" + } else { + return "ERROR" + } +} + +func cut(s string, sep byte, pos int) string { + for n := 0; n < pos; n++ { + if i := strings.IndexByte(s, sep); i > 0 { + s = s[i+1:] + } else { + return "" + } + } + if i := strings.IndexByte(s, sep); i > 0 { + return s[:i] + } + return s +} diff --git a/internal/ffmpeg/hardware/hardware_darwin.go b/internal/ffmpeg/hardware/hardware_darwin.go new file mode 100644 index 00000000..923f4159 --- /dev/null +++ b/internal/ffmpeg/hardware/hardware_darwin.go @@ -0,0 +1,37 @@ +package hardware + +import ( + "github.com/AlexxIT/go2rtc/internal/api" +) + +const ProbeVideoToolboxH264 = "-f lavfi -i testsrc2 -t 1 -c h264_videotoolbox -f null -" +const ProbeVideoToolboxH265 = "-f lavfi -i testsrc2 -t 1 -c hevc_videotoolbox -f null -" + +func ProbeAll(bin string) []api.Stream { + return []api.Stream{ + { + Name: runToString(bin, ProbeVideoToolboxH264), + URL: "ffmpeg:...#video=h264#hardware=" + EngineVideoToolbox, + }, + { + Name: runToString(bin, ProbeVideoToolboxH265), + URL: "ffmpeg:...#video=h265#hardware=" + EngineVideoToolbox, + }, + } +} + +func ProbeHardware(bin, name string) string { + switch name { + case "h264": + if run(bin, ProbeVideoToolboxH264) { + return EngineVideoToolbox + } + + case "h265": + if run(bin, ProbeVideoToolboxH265) { + return EngineVideoToolbox + } + } + + return EngineSoftware +} diff --git a/internal/ffmpeg/hardware/hardware_linux.go b/internal/ffmpeg/hardware/hardware_linux.go new file mode 100644 index 00000000..cacfb10e --- /dev/null +++ b/internal/ffmpeg/hardware/hardware_linux.go @@ -0,0 +1,94 @@ +package hardware + +import ( + "github.com/AlexxIT/go2rtc/internal/api" + "runtime" +) + +const ProbeV4L2M2MH264 = "-f lavfi -i testsrc2 -t 1 -c h264_v4l2m2m -f null -" +const ProbeV4L2M2MH265 = "-f lavfi -i testsrc2 -t 1 -c hevc_v4l2m2m -f null -" +const ProbeVAAPIH264 = "-init_hw_device vaapi -f lavfi -i testsrc2 -t 1 -vf format=nv12,hwupload -c h264_vaapi -f null -" +const ProbeVAAPIH265 = "-init_hw_device vaapi -f lavfi -i testsrc2 -t 1 -vf format=nv12,hwupload -c hevc_vaapi -f null -" +const ProbeVAAPIJPEG = "-init_hw_device vaapi -f lavfi -i testsrc2 -t 1 -vf format=nv12,hwupload -c mjpeg_vaapi -f null -" +const ProbeCUDAH264 = "-init_hw_device cuda -f lavfi -i testsrc2 -t 1 -c h264_nvenc -f null -" +const ProbeCUDAH265 = "-init_hw_device cuda -f lavfi -i testsrc2 -t 1 -c hevc_nvenc -f null -" + +func ProbeAll(bin string) []api.Stream { + if runtime.GOARCH == "arm64" || runtime.GOARCH == "arm" { + return []api.Stream{ + { + Name: runToString(bin, ProbeV4L2M2MH264), + URL: "ffmpeg:...#video=h264#hardware=" + EngineV4L2M2M, + }, + { + Name: runToString(bin, ProbeV4L2M2MH265), + URL: "ffmpeg:...#video=h265#hardware=" + EngineV4L2M2M, + }, + } + } + + return []api.Stream{ + { + Name: runToString(bin, ProbeVAAPIH264), + URL: "ffmpeg:...#video=h264#hardware=" + EngineVAAPI, + }, + { + Name: runToString(bin, ProbeVAAPIH265), + URL: "ffmpeg:...#video=h265#hardware=" + EngineVAAPI, + }, + { + Name: runToString(bin, ProbeVAAPIJPEG), + URL: "ffmpeg:...#video=mjpeg#hardware=" + EngineVAAPI, + }, + { + Name: runToString(bin, ProbeCUDAH264), + URL: "ffmpeg:...#video=h264#hardware=" + EngineCUDA, + }, + { + Name: runToString(bin, ProbeCUDAH265), + URL: "ffmpeg:...#video=h265#hardware=" + EngineCUDA, + }, + } +} + +func ProbeHardware(bin, name string) string { + if runtime.GOARCH == "arm64" || runtime.GOARCH == "arm" { + switch name { + case "h264": + if run(bin, ProbeV4L2M2MH264) { + return EngineV4L2M2M + } + case "h265": + if run(bin, ProbeV4L2M2MH265) { + return EngineV4L2M2M + } + } + + return EngineSoftware + } + + switch name { + case "h264": + if run(bin, ProbeCUDAH264) { + return EngineCUDA + } + if run(bin, ProbeVAAPIH264) { + return EngineVAAPI + } + + case "h265": + if run(bin, ProbeCUDAH265) { + return EngineCUDA + } + if run(bin, ProbeVAAPIH265) { + return EngineVAAPI + } + + case "mjpeg": + if run(bin, ProbeVAAPIJPEG) { + return EngineVAAPI + } + } + + return EngineSoftware +} diff --git a/internal/ffmpeg/hardware/hardware_windows.go b/internal/ffmpeg/hardware/hardware_windows.go new file mode 100644 index 00000000..6a8898f2 --- /dev/null +++ b/internal/ffmpeg/hardware/hardware_windows.go @@ -0,0 +1,61 @@ +package hardware + +import "github.com/AlexxIT/go2rtc/internal/api" + +const ProbeDXVA2H264 = "-init_hw_device dxva2 -f lavfi -i testsrc2 -t 1 -c h264_qsv -f null -" +const ProbeDXVA2H265 = "-init_hw_device dxva2 -f lavfi -i testsrc2 -t 1 -c hevc_qsv -f null -" +const ProbeDXVA2JPEG = "-init_hw_device dxva2 -f lavfi -i testsrc2 -t 1 -c mjpeg_qsv -f null -" +const ProbeCUDAH264 = "-init_hw_device cuda -f lavfi -i testsrc2 -t 1 -c h264_nvenc -f null -" +const ProbeCUDAH265 = "-init_hw_device cuda -f lavfi -i testsrc2 -t 1 -c hevc_nvenc -f null -" + +func ProbeAll(bin string) []api.Stream { + return []api.Stream{ + { + Name: runToString(bin, ProbeDXVA2H264), + URL: "ffmpeg:...#video=h264#hardware=" + EngineDXVA2, + }, + { + Name: runToString(bin, ProbeDXVA2H265), + URL: "ffmpeg:...#video=h265#hardware=" + EngineDXVA2, + }, + { + Name: runToString(bin, ProbeDXVA2JPEG), + URL: "ffmpeg:...#video=mjpeg#hardware=" + EngineDXVA2, + }, + { + Name: runToString(bin, ProbeCUDAH264), + URL: "ffmpeg:...#video=h264#hardware=" + EngineCUDA, + }, + { + Name: runToString(bin, ProbeCUDAH265), + URL: "ffmpeg:...#video=h265#hardware=" + EngineCUDA, + }, + } +} + +func ProbeHardware(bin, name string) string { + switch name { + case "h264": + if run(bin, ProbeCUDAH264) { + return EngineCUDA + } + if run(bin, ProbeDXVA2H264) { + return EngineDXVA2 + } + + case "h265": + if run(bin, ProbeCUDAH265) { + return EngineCUDA + } + if run(bin, ProbeDXVA2H265) { + return EngineDXVA2 + } + + case "mjpeg": + if run(bin, ProbeDXVA2JPEG) { + return EngineDXVA2 + } + } + + return EngineSoftware +} diff --git a/internal/ffmpeg/helpers.go b/internal/ffmpeg/helpers.go new file mode 100644 index 00000000..30c3520d --- /dev/null +++ b/internal/ffmpeg/helpers.go @@ -0,0 +1,12 @@ +package ffmpeg + +import ( + "bytes" + "os/exec" +) + +func TranscodeToJPEG(b []byte) ([]byte, error) { + cmd := exec.Command("ffmpeg", "-hide_banner", "-i", "-", "-f", "mjpeg", "-") + cmd.Stdin = bytes.NewBuffer(b) + return cmd.Output() +} diff --git a/internal/hass/api.go b/internal/hass/api.go new file mode 100644 index 00000000..8824d47c --- /dev/null +++ b/internal/hass/api.go @@ -0,0 +1,105 @@ +package hass + +import ( + "encoding/base64" + "encoding/json" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/internal/webrtc" + "net" + "net/http" + "strings" +) + +func apiOK(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":1,"payload":{}}`)) +} + +func apiStream(w http.ResponseWriter, r *http.Request) { + switch { + // /stream/{id}/add + case strings.HasSuffix(r.RequestURI, "/add"): + var v addJSON + if err := json.NewDecoder(r.Body).Decode(&v); err != nil { + return + } + + // we can get three types of links: + // 1. link to go2rtc stream: rtsp://...:8554/{stream_name} + // 2. static link to Hass camera + // 3. dynamic link to Hass camera + stream := streams.Get(v.Name) + if stream == nil { + stream = streams.NewTemplate(v.Name, v.Channels.First.Url) + } + + stream.SetSource(v.Channels.First.Url) + + apiOK(w, r) + + // /stream/{id}/channel/0/webrtc + default: + i := strings.IndexByte(r.RequestURI[8:], '/') + if i <= 0 { + log.Warn().Msgf("wrong request: %s", r.RequestURI) + return + } + name := r.RequestURI[8 : 8+i] + + stream := streams.Get(name) + if stream == nil { + w.WriteHeader(http.StatusNotFound) + return + } + + if err := r.ParseForm(); err != nil { + log.Error().Err(err).Msg("[api.hass] parse form") + return + } + + s := r.FormValue("data") + offer, err := base64.StdEncoding.DecodeString(s) + if err != nil { + log.Error().Err(err).Msg("[api.hass] sdp64 decode") + return + } + + s, err = webrtc.ExchangeSDP(stream, string(offer), "WebRTC/Hass sync", r.UserAgent()) + if err != nil { + log.Error().Err(err).Msg("[api.hass] exchange SDP") + return + } + + s = base64.StdEncoding.EncodeToString([]byte(s)) + _, _ = w.Write([]byte(s)) + } +} + +func HassioAddr() string { + ints, _ := net.Interfaces() + + for _, i := range ints { + if i.Name != "hassio" { + continue + } + + addrs, _ := i.Addrs() + for _, addr := range addrs { + if addr, ok := addr.(*net.IPNet); ok { + return addr.IP.String() + } + } + } + + return "" +} + +type addJSON struct { + Name string `json:"name"` + Channels struct { + First struct { + //Name string `json:"name"` + Url string `json:"url"` + } `json:"0"` + } `json:"channels"` +} diff --git a/cmd/hass/hass.go b/internal/hass/hass.go similarity index 53% rename from cmd/hass/hass.go rename to internal/hass/hass.go index ddbfe995..67dbbf41 100644 --- a/cmd/hass/hass.go +++ b/internal/hass/hass.go @@ -4,15 +4,17 @@ import ( "bytes" "encoding/json" "fmt" - "github.com/AlexxIT/go2rtc/cmd/api" - "github.com/AlexxIT/go2rtc/cmd/app" - "github.com/AlexxIT/go2rtc/cmd/roborock" - "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/roborock" + "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/hass" "github.com/rs/zerolog" "net/http" "os" "path" + "sync" ) func Init() { @@ -29,10 +31,15 @@ func Init() { log = app.GetLogger("hass") - initAPI() + // support API for https://www.home-assistant.io/integrations/rtsp_to_webrtc/ + api.HandleFunc("/static", apiOK) + api.HandleFunc("/streams", apiOK) + api.HandleFunc("/stream/", apiStream) + + // load static entries from Hass config + if err := importConfig(conf.Mod.Config); err != nil { + log.Debug().Msgf("[hass] can't import config: %s", err) - entries := importEntries(conf.Mod.Config) - if entries == nil { api.HandleFunc("api/hass", func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "no hass config", http.StatusNotFound) }) @@ -40,18 +47,35 @@ func Init() { } api.HandleFunc("api/hass", func(w http.ResponseWriter, _ *http.Request) { + once.Do(func() { + // load WebRTC entities from Hass API, works only for add-on version + if token := hass.SupervisorToken(); token != "" { + if err := importWebRTC(token); err != nil { + log.Warn().Err(err).Caller().Send() + } + } + }) + var items []api.Stream - for name, url := range entries { + for name, url := range entities { items = append(items, api.Stream{Name: name, URL: url}) } api.ResponseStreams(w, items) }) streams.HandleFunc("hass", func(url string) (core.Producer, error) { - if hurl := entries[url[5:]]; hurl != "" { - return streams.GetProducer(hurl) + // check entity by name + if url2 := entities[url[5:]]; url2 != "" { + return streams.GetProducer(url2) } - return nil, fmt.Errorf("can't get url: %s", url) + + // support hass://supervisor?entity_id=camera.driveway_doorbell + client, err := hass.NewClient(url) + if err != nil { + return nil, err + } + + return client, nil }) // for Addon listen on hassio interface, so WebUI feature will work @@ -68,12 +92,12 @@ func Init() { } } -func importEntries(config string) map[string]string { +func importConfig(config string) error { // support load cameras from Hass config file filename := path.Join(config, ".storage/core.config_entries") b, err := os.ReadFile(filename) if err != nil { - return nil + return err } var storage struct { @@ -88,11 +112,9 @@ func importEntries(config string) map[string]string { } if err = json.Unmarshal(b, &storage); err != nil { - return nil + return err } - urls := map[string]string{} - for _, entrie := range storage.Data.Entries { switch entrie.Domain { case "generic": @@ -102,7 +124,7 @@ func importEntries(config string) map[string]string { if err = json.Unmarshal(entrie.Options, &options); err != nil { continue } - urls[entrie.Title] = options.StreamSource + entities[entrie.Title] = options.StreamSource case "homekit_controller": if !bytes.Contains(entrie.Data, []byte("iOSPairingId")) { @@ -121,7 +143,7 @@ func importEntries(config string) map[string]string { if err = json.Unmarshal(entrie.Data, &data); err != nil { continue } - urls[entrie.Title] = fmt.Sprintf( + entities[entrie.Title] = fmt.Sprintf( "homekit://%s:%d?client_id=%s&client_private=%s%s&device_id=%s&device_public=%s", data.DeviceHost, data.DevicePort, data.ClientID, data.ClientPrivate, data.ClientPublic, @@ -131,15 +153,60 @@ func importEntries(config string) map[string]string { case "roborock": _ = json.Unmarshal(entrie.Data, &roborock.Auth) + case "onvif": + var data struct { + Host string `json:"host" json:"host"` + Port uint16 `json:"port" json:"port"` + Username string `json:"username" json:"username"` + Password string `json:"password" json:"password"` + } + if err = json.Unmarshal(entrie.Data, &data); err != nil { + continue + } + + if data.Username != "" && data.Password != "" { + entities[entrie.Title] = fmt.Sprintf( + "onvif://%s:%s@%s:%d", data.Username, data.Password, data.Host, data.Port, + ) + } else { + entities[entrie.Title] = fmt.Sprintf("onvif://%s:%d", data.Host, data.Port) + } + default: continue } - log.Info().Str("url", "hass:"+entrie.Title).Msg("[hass] load stream") + log.Debug().Str("url", "hass:"+entrie.Title).Msg("[hass] load config") //streams.Get("hass:" + entrie.Title) } - return urls + return nil } +func importWebRTC(token string) error { + hassAPI, err := hass.NewAPI("ws://supervisor/core/websocket", token) + if err != nil { + return err + } + + webrtcEntities, err := hassAPI.GetWebRTCEntities() + if err != nil { + return err + } + + if len(webrtcEntities) == 0 { + log.Debug().Msg("[hass] webrtc cameras not found") + } + + for name, entityID := range webrtcEntities { + entities[name] = "hass://supervisor?entity_id=" + entityID + + log.Debug().Msgf("[hass] load webrtc name=%s entity_id=%d", name, entityID) + } + + return nil +} + +var entities = map[string]string{} var log zerolog.Logger +var once sync.Once diff --git a/cmd/hls/hls.go b/internal/hls/hls.go similarity index 90% rename from cmd/hls/hls.go rename to internal/hls/hls.go index e9658538..06e841cd 100644 --- a/cmd/hls/hls.go +++ b/internal/hls/hls.go @@ -2,14 +2,15 @@ package hls import ( "fmt" - "github.com/AlexxIT/go2rtc/cmd/api" - "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/mp4" "github.com/AlexxIT/go2rtc/pkg/mpegts" "github.com/AlexxIT/go2rtc/pkg/tcp" "github.com/rs/zerolog/log" "net/http" + "strings" "sync" "time" ) @@ -48,6 +49,9 @@ const keepalive = 5 * time.Second var sessions = map[string]*Session{} +// once I saw 404 on MP4 segment, so better to use mutex +var sessionsMu sync.RWMutex + func handlerStream(w http.ResponseWriter, r *http.Request) { // CORS important for Chromecast w.Header().Set("Access-Control-Allow-Origin", "*") @@ -128,11 +132,16 @@ segment.ts?id=` + sid + `&n=%d segment.ts?id=` + sid + `&n=%d` } + sessionsMu.Lock() sessions[sid] = session + sessionsMu.Unlock() + + // Apple Safari can play FLAC codec, but fail it it in m3u8 playlist + codecs := strings.Replace(cons.MimeCodecs(), mp4.MimeFlac, mp4.MimeAAC, 1) // bandwidth important for Safari, codecs useful for smooth playback data := []byte(`#EXTM3U -#EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS="` + cons.MimeCodecs() + `" +#EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS="` + codecs + `" hls/playlist.m3u8?id=` + sid) if _, err := w.Write(data); err != nil { @@ -150,7 +159,9 @@ func handlerPlaylist(w http.ResponseWriter, r *http.Request) { } sid := r.URL.Query().Get("id") + sessionsMu.RLock() session := sessions[sid] + sessionsMu.RUnlock() if session == nil { http.NotFound(w, r) return @@ -173,7 +184,9 @@ func handlerSegmentTS(w http.ResponseWriter, r *http.Request) { } sid := r.URL.Query().Get("id") + sessionsMu.RLock() session := sessions[sid] + sessionsMu.RUnlock() if session == nil { http.NotFound(w, r) return @@ -212,7 +225,9 @@ func handlerInit(w http.ResponseWriter, r *http.Request) { } sid := r.URL.Query().Get("id") + sessionsMu.RLock() session := sessions[sid] + sessionsMu.RUnlock() if session == nil { http.NotFound(w, r) return @@ -233,7 +248,9 @@ func handlerSegmentMP4(w http.ResponseWriter, r *http.Request) { } sid := r.URL.Query().Get("id") + sessionsMu.RLock() session := sessions[sid] + sessionsMu.RUnlock() if session == nil { http.NotFound(w, r) return diff --git a/cmd/homekit/api.go b/internal/homekit/api.go similarity index 96% rename from cmd/homekit/api.go rename to internal/homekit/api.go index a055871a..39fdaa43 100644 --- a/cmd/homekit/api.go +++ b/internal/homekit/api.go @@ -3,8 +3,8 @@ package homekit import ( "encoding/json" "fmt" - "github.com/AlexxIT/go2rtc/cmd/app/store" - "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/internal/app/store" + "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/hap" "github.com/AlexxIT/go2rtc/pkg/hap/mdns" "net/http" diff --git a/cmd/homekit/homekit.go b/internal/homekit/homekit.go similarity index 74% rename from cmd/homekit/homekit.go rename to internal/homekit/homekit.go index 1e7d0756..8376fd2e 100644 --- a/cmd/homekit/homekit.go +++ b/internal/homekit/homekit.go @@ -1,10 +1,10 @@ package homekit import ( - "github.com/AlexxIT/go2rtc/cmd/api" - "github.com/AlexxIT/go2rtc/cmd/app" - "github.com/AlexxIT/go2rtc/cmd/srtp" - "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/srtp" + "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/homekit" "github.com/rs/zerolog" diff --git a/internal/http/http.go b/internal/http/http.go new file mode 100644 index 00000000..6c158196 --- /dev/null +++ b/internal/http/http.go @@ -0,0 +1,95 @@ +package http + +import ( + "errors" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/magic" + "github.com/AlexxIT/go2rtc/pkg/mjpeg" + "github.com/AlexxIT/go2rtc/pkg/rtmp" + "github.com/AlexxIT/go2rtc/pkg/tcp" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +func Init() { + streams.HandleFunc("http", handleHTTP) + streams.HandleFunc("https", handleHTTP) + streams.HandleFunc("httpx", handleHTTP) + + streams.HandleFunc("tcp", handleTCP) +} + +func handleHTTP(url string) (core.Producer, error) { + // first we get the Content-Type to define supported producer + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + res, err := tcp.Do(req) + if err != nil { + return nil, err + } + + if res.StatusCode != http.StatusOK { + return nil, errors.New(res.Status) + } + + ct := res.Header.Get("Content-Type") + if i := strings.IndexByte(ct, ';'); i > 0 { + ct = ct[:i] + } + + switch ct { + case "image/jpeg", "multipart/x-mixed-replace": + return mjpeg.NewClient(res), nil + + case "video/x-flv": + var conn *rtmp.Client + if conn, err = rtmp.Accept(res); err != nil { + return nil, err + } + if err = conn.Describe(); err != nil { + return nil, err + } + return conn, nil + + default: // "video/mpeg": + } + + client := magic.NewClient(res.Body) + if err = client.Probe(); err != nil { + return nil, err + } + + client.Desc = "HTTP active producer" + client.URL = url + + return client, nil +} + +func handleTCP(rawURL string) (core.Producer, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + conn, err := net.DialTimeout("tcp", u.Host, time.Second*3) + if err != nil { + return nil, err + } + + client := magic.NewClient(conn) + if err = client.Probe(); err != nil { + return nil, err + } + + client.Desc = "TCP active producer" + client.URL = rawURL + + return client, nil +} diff --git a/cmd/isapi/init.go b/internal/isapi/init.go similarity index 88% rename from cmd/isapi/init.go rename to internal/isapi/init.go index 33bb85a7..a37afa23 100644 --- a/cmd/isapi/init.go +++ b/internal/isapi/init.go @@ -1,7 +1,7 @@ package isapi import ( - "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/isapi" ) diff --git a/cmd/ivideon/ivideon.go b/internal/ivideon/ivideon.go similarity index 88% rename from cmd/ivideon/ivideon.go rename to internal/ivideon/ivideon.go index d63edfd5..0ae5dc9f 100644 --- a/cmd/ivideon/ivideon.go +++ b/internal/ivideon/ivideon.go @@ -1,7 +1,7 @@ package ivideon import ( - "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/ivideon" "strings" diff --git a/cmd/mjpeg/mjpeg.go b/internal/mjpeg/init.go similarity index 85% rename from cmd/mjpeg/mjpeg.go rename to internal/mjpeg/init.go index 06d89023..598aae62 100644 --- a/cmd/mjpeg/mjpeg.go +++ b/internal/mjpeg/init.go @@ -2,14 +2,18 @@ package mjpeg import ( "errors" - "github.com/AlexxIT/go2rtc/cmd/api" - "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/ffmpeg" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/magic" "github.com/AlexxIT/go2rtc/pkg/mjpeg" "github.com/AlexxIT/go2rtc/pkg/tcp" "github.com/rs/zerolog/log" "io" "net/http" "strconv" + "time" ) func Init() { @@ -29,14 +33,16 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) { exit := make(chan []byte) - cons := &mjpeg.Consumer{ + cons := &magic.Keyframe{ RemoteAddr: tcp.RemoteAddr(r), UserAgent: r.UserAgent(), } cons.Listen(func(msg any) { - switch msg := msg.(type) { - case []byte: - exit <- msg + if b, ok := msg.([]byte); ok { + select { + case exit <- b: + default: + } } }) @@ -49,6 +55,17 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) { stream.RemoveConsumer(cons) + switch cons.CodecName() { + case core.CodecH264, core.CodecH265: + ts := time.Now() + var err error + if data, err = ffmpeg.TranscodeToJPEG(data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + log.Debug().Msgf("[mjpeg] transcoding time=%s", time.Since(ts)) + } + h := w.Header() h.Set("Content-Type", "image/jpeg") h.Set("Content-Length", strconv.Itoa(len(data))) diff --git a/cmd/mp4/mp4.go b/internal/mp4/mp4.go similarity index 82% rename from cmd/mp4/mp4.go rename to internal/mp4/mp4.go index 6f3fa1f7..e6791ee7 100644 --- a/cmd/mp4/mp4.go +++ b/internal/mp4/mp4.go @@ -4,13 +4,11 @@ import ( "net/http" "strconv" "strings" - "sync" "time" - "github.com/AlexxIT/go2rtc/cmd/api" - "github.com/AlexxIT/go2rtc/cmd/app" - "github.com/AlexxIT/go2rtc/cmd/streams" - "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/mp4" "github.com/AlexxIT/go2rtc/pkg/tcp" "github.com/rs/zerolog" @@ -61,6 +59,7 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) { if err := stream.AddConsumer(cons); err != nil { log.Error().Err(err).Caller().Send() + http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -82,15 +81,8 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() - // Chrome has Safari in UA, so check first Chrome and later Safari ua := r.UserAgent() - if strings.Contains(ua, " Chrome/") { - if r.Header.Values("Range") == nil { - w.Header().Set("Content-Type", "video/mp4") - w.WriteHeader(http.StatusOK) - return - } - } else if strings.Contains(ua, " Safari/") && !query.Has("duration") { + if strings.Contains(ua, " Safari/") && !strings.Contains(ua, " Chrome/") && !query.Has("duration") { // auto redirect to HLS/fMP4 format, because Safari not support MP4 stream url := "stream.m3u8?" + r.URL.RawQuery if !query.Has("mp4") { @@ -113,15 +105,15 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) { cons := &mp4.Consumer{ RemoteAddr: tcp.RemoteAddr(r), UserAgent: r.UserAgent(), - Medias: core.ParseQuery(r.URL.Query()), + Medias: mp4.ParseQuery(r.URL.Query()), } - mu := &sync.Mutex{} cons.Listen(func(msg any) { + if exit == nil { + return + } if data, ok := msg.([]byte); ok { - mu.Lock() - defer mu.Unlock() - if _, err := w.Write(data); err != nil && exit != nil { + if _, err := w.Write(data); err != nil { select { case exit <- err: default: @@ -133,6 +125,7 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) { if err := stream.AddConsumer(cons); err != nil { log.Error().Err(err).Caller().Send() + http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -143,11 +136,13 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) { data, err := cons.Init() if err != nil { log.Error().Err(err).Caller().Send() + http.Error(w, err.Error(), http.StatusInternalServerError) return } if _, err = w.Write(data); err != nil { log.Error().Err(err).Caller().Send() + http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -158,7 +153,10 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) { if i, _ := strconv.Atoi(s); i > 0 { duration = time.AfterFunc(time.Second*time.Duration(i), func() { if exit != nil { - exit <- nil + select { + case exit <- nil: + default: + } exit = nil } }) @@ -166,6 +164,7 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) { } err = <-exit + exit = nil log.Trace().Err(err).Caller().Send() diff --git a/cmd/mp4/ws.go b/internal/mp4/ws.go similarity index 91% rename from cmd/mp4/ws.go rename to internal/mp4/ws.go index 1ea4d235..0cd7e4fe 100644 --- a/cmd/mp4/ws.go +++ b/internal/mp4/ws.go @@ -2,8 +2,8 @@ package mp4 import ( "errors" - "github.com/AlexxIT/go2rtc/cmd/api" - "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/mp4" "github.com/AlexxIT/go2rtc/pkg/tcp" @@ -110,6 +110,12 @@ func parseMedias(codecs string, parseAudio bool) (medias []*core.Media) { case mp4.MimeAAC: codec := &core.Codec{Name: core.CodecAAC} audios = append(audios, codec) + case mp4.MimeFlac: + audios = append(audios, + &core.Codec{Name: core.CodecPCMA}, + &core.Codec{Name: core.CodecPCMU}, + &core.Codec{Name: core.CodecPCM}, + ) case mp4.MimeOpus: codec := &core.Codec{Name: core.CodecOpus} audios = append(audios, codec) diff --git a/cmd/mpegts/mpegts.go b/internal/mpegts/mpegts.go similarity index 90% rename from cmd/mpegts/mpegts.go rename to internal/mpegts/mpegts.go index 7f76da92..fad0f11e 100644 --- a/cmd/mpegts/mpegts.go +++ b/internal/mpegts/mpegts.go @@ -1,8 +1,8 @@ package mpegts import ( - "github.com/AlexxIT/go2rtc/cmd/api" - "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/mpegts" "net/http" ) diff --git a/internal/nest/init.go b/internal/nest/init.go new file mode 100644 index 00000000..e48224fb --- /dev/null +++ b/internal/nest/init.go @@ -0,0 +1,55 @@ +package nest + +import ( + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/nest" + "net/http" +) + +func Init() { + streams.HandleFunc("nest", streamNest) + + api.HandleFunc("api/nest", apiNest) +} + +func streamNest(url string) (core.Producer, error) { + client, err := nest.NewClient(url) + if err != nil { + return nil, err + } + return client, nil +} + +func apiNest(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + cliendID := query.Get("client_id") + cliendSecret := query.Get("client_secret") + refreshToken := query.Get("refresh_token") + projectID := query.Get("project_id") + + nestAPI, err := nest.NewAPI(cliendID, cliendSecret, refreshToken) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + devices, err := nestAPI.GetDevices(projectID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var items []api.Stream + + for name, deviceID := range devices { + query.Set("device_id", deviceID) + + items = append(items, api.Stream{ + Name: name, URL: "nest:?" + query.Encode(), + }) + } + + api.ResponseStreams(w, items) +} diff --git a/cmd/ngrok/ngrok.go b/internal/ngrok/ngrok.go similarity index 94% rename from cmd/ngrok/ngrok.go rename to internal/ngrok/ngrok.go index 25266b08..67668829 100644 --- a/cmd/ngrok/ngrok.go +++ b/internal/ngrok/ngrok.go @@ -2,8 +2,8 @@ package ngrok import ( "fmt" - "github.com/AlexxIT/go2rtc/cmd/app" - "github.com/AlexxIT/go2rtc/cmd/webrtc" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/webrtc" "github.com/AlexxIT/go2rtc/pkg/ngrok" "github.com/rs/zerolog" "net" diff --git a/internal/onvif/init.go b/internal/onvif/init.go new file mode 100644 index 00000000..01b33702 --- /dev/null +++ b/internal/onvif/init.go @@ -0,0 +1,194 @@ +package onvif + +import ( + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/rtsp" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/onvif" + "github.com/rs/zerolog" + "io" + "net" + "net/http" + "net/url" + "os" + "strconv" + "time" +) + +func Init() { + log = app.GetLogger("onvif") + + streams.HandleFunc("onvif", streamOnvif) + + // ONVIF server on all suburls + api.HandleFunc("/onvif/", onvifDeviceService) + + // ONVIF client autodiscovery + api.HandleFunc("api/onvif", apiOnvif) +} + +var log zerolog.Logger + +func streamOnvif(rawURL string) (core.Producer, error) { + client, err := onvif.NewClient(rawURL) + if err != nil { + return nil, err + } + + uri, err := client.GetURI() + if err != nil { + return nil, err + } + + log.Debug().Msgf("[onvif] new uri=%s", uri) + + return streams.GetProducer(uri) +} + +func onvifDeviceService(w http.ResponseWriter, r *http.Request) { + b, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + action := onvif.GetRequestAction(b) + if action == "" { + http.Error(w, "malformed request body", http.StatusBadRequest) + return + } + + log.Trace().Msgf("[onvif] %s", action) + + var res string + + switch action { + case onvif.ActionGetCapabilities: + // important for Hass: Media section + res = onvif.GetCapabilitiesResponse(r.Host) + + case onvif.ActionGetSystemDateAndTime: + // important for Hass + res = onvif.GetSystemDateAndTimeResponse() + + case onvif.ActionGetNetworkInterfaces: + // important for Hass: none + res = onvif.GetNetworkInterfacesResponse() + + case onvif.ActionGetDeviceInformation: + // important for Hass: SerialNumber (unique server ID) + res = onvif.GetDeviceInformationResponse("", "go2rtc", app.Version, r.Host) + + case onvif.ActionGetServiceCapabilities: + // important for Hass + res = onvif.GetServiceCapabilitiesResponse() + + case onvif.ActionSystemReboot: + res = onvif.SystemRebootResponse() + + time.AfterFunc(time.Second, func() { + os.Exit(0) + }) + + case onvif.ActionGetProfiles: + // important for Hass: H264 codec, width, height + res = onvif.GetProfilesResponse(streams.GetAll()) + + case onvif.ActionGetStreamUri: + host, _, err := net.SplitHostPort(r.Host) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + uri := "rtsp://" + host + ":" + rtsp.Port + "/" + onvif.FindTagValue(b, "ProfileToken") + res = onvif.GetStreamUriResponse(uri) + + default: + http.Error(w, "unsupported action", http.StatusBadRequest) + log.Debug().Msgf("[onvif] unsupported request:\n%s", b) + return + } + + w.Header().Set("Content-Type", "application/soap+xml; charset=utf-8") + if _, err = w.Write([]byte(res)); err != nil { + log.Error().Err(err).Caller().Send() + } +} + +func apiOnvif(w http.ResponseWriter, r *http.Request) { + src := r.URL.Query().Get("src") + + var items []api.Stream + + if src == "" { + urls, err := onvif.DiscoveryStreamingURLs() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + for _, rawURL := range urls { + u, err := url.Parse(rawURL) + if err != nil { + log.Warn().Str("url", rawURL).Msg("[onvif] broken") + continue + } + + if u.Scheme != "http" { + log.Warn().Str("url", rawURL).Msg("[onvif] unsupported") + continue + } + + u.Scheme = "onvif" + u.User = url.UserPassword("user", "pass") + + if u.Path == onvif.PathDevice { + u.Path = "" + } + + items = append(items, api.Stream{Name: u.Host, URL: u.String()}) + } + } else { + client, err := onvif.NewClient(src) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if l := log.Trace(); l.Enabled() { + b, _ := client.GetProfiles() + l.Msgf("[onvif] src=%s profiles:\n%s", src, b) + } + + name, err := client.GetName() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + tokens, err := client.GetProfilesTokens() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + for i, token := range tokens { + items = append(items, api.Stream{ + Name: name + " stream" + strconv.Itoa(i), + URL: src + "?subtype=" + token, + }) + } + + if len(tokens) > 0 && client.HasSnapshots() { + items = append(items, api.Stream{ + Name: name + " snapshot", + URL: src + "?subtype=" + tokens[0] + "&snapshot", + }) + } + } + + api.ResponseStreams(w, items) +} diff --git a/cmd/roborock/roborock.go b/internal/roborock/roborock.go similarity index 96% rename from cmd/roborock/roborock.go rename to internal/roborock/roborock.go index 6e3457b7..e99586d9 100644 --- a/cmd/roborock/roborock.go +++ b/internal/roborock/roborock.go @@ -2,8 +2,8 @@ package roborock import ( "fmt" - "github.com/AlexxIT/go2rtc/cmd/api" - "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/roborock" "net/http" diff --git a/cmd/rtmp/rtmp.go b/internal/rtmp/rtmp.go similarity index 93% rename from cmd/rtmp/rtmp.go rename to internal/rtmp/rtmp.go index e3042981..f84c16be 100644 --- a/cmd/rtmp/rtmp.go +++ b/internal/rtmp/rtmp.go @@ -1,8 +1,8 @@ package rtmp import ( - "github.com/AlexxIT/go2rtc/cmd/api" - "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/rtmp" "github.com/rs/zerolog/log" diff --git a/cmd/rtsp/rtsp.go b/internal/rtsp/rtsp.go similarity index 77% rename from cmd/rtsp/rtsp.go rename to internal/rtsp/rtsp.go index 31ab985b..9d1234d5 100644 --- a/cmd/rtsp/rtsp.go +++ b/internal/rtsp/rtsp.go @@ -6,10 +6,9 @@ import ( "net/url" "strings" - "github.com/AlexxIT/go2rtc/cmd/app" - "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" - "github.com/AlexxIT/go2rtc/pkg/mp4" "github.com/AlexxIT/go2rtc/pkg/rtsp" "github.com/AlexxIT/go2rtc/pkg/tcp" "github.com/rs/zerolog" @@ -22,6 +21,7 @@ func Init() { Username string `yaml:"username" json:"-"` Password string `yaml:"password" json:"-"` DefaultQuery string `yaml:"default_query" json:"default_query"` + PacketSize uint16 `yaml:"pkt_size"` } `yaml:"rtsp"` } @@ -56,7 +56,7 @@ func Init() { log.Info().Str("addr", address).Msg("[rtsp] listen") if query, err := url.ParseQuery(conf.Mod.DefaultQuery); err == nil { - defaultMedias = mp4.ParseQuery(query) + defaultMedias = ParseQuery(query) } go func() { @@ -67,6 +67,7 @@ func Init() { } c := rtsp.NewServer(conn) + c.PacketSize = conf.Mod.PacketSize // skip check auth for localhost if conf.Mod.Username != "" && !conn.RemoteAddr().(*net.TCPAddr).IP.IsLoopback() { c.Auth(conf.Mod.Username, conf.Mod.Password) @@ -90,19 +91,19 @@ var log zerolog.Logger var handlers []Handler var defaultMedias []*core.Media -func rtspHandler(url string) (core.Producer, error) { - backchannel := true +func rtspHandler(rawURL string) (core.Producer, error) { + rawURL, rawQuery, _ := strings.Cut(rawURL, "#") - if i := strings.IndexByte(url, '#'); i > 0 { - if url[i+1:] == "backchannel=0" { - backchannel = false - } - url = url[:i] - } - - conn := rtsp.NewClient(url) + conn := rtsp.NewClient(rawURL) + conn.Backchannel = true conn.UserAgent = app.UserAgent + if rawQuery != "" { + query := streams.ParseQuery(rawQuery) + conn.Backchannel = query.Get("backchannel") == "1" + conn.Transport = query.Get("transport") + } + if log.Trace().Enabled() { conn.Listen(func(msg any) { switch msg := msg.(type) { @@ -120,12 +121,11 @@ func rtspHandler(url string) (core.Producer, error) { return nil, err } - conn.Backchannel = backchannel if err := conn.Describe(); err != nil { - if !backchannel { + if !conn.Backchannel { return nil, err } - log.Trace().Msgf("[rtsp] describe (backchannel=%t) err: %v", backchannel, err) + log.Trace().Msgf("[rtsp] describe (backchannel=%t) err: %v", conn.Backchannel, err) // second try without backchannel, we need to reconnect conn.Backchannel = false @@ -174,13 +174,18 @@ func tcpHandler(conn *rtsp.Conn) { conn.SessionName = app.UserAgent - conn.Medias = mp4.ParseQuery(conn.URL.Query()) + query := conn.URL.Query() + conn.Medias = ParseQuery(query) if conn.Medias == nil { for _, media := range defaultMedias { conn.Medias = append(conn.Medias, media.Clone()) } } + if s := query.Get("pkt_size"); s != "" { + conn.PacketSize = uint16(core.Atoi(s)) + } + if err := stream.AddConsumer(conn); err != nil { log.Warn().Err(err).Str("stream", name).Msg("[rtsp]") return @@ -242,3 +247,27 @@ func tcpHandler(conn *rtsp.Conn) { _ = conn.Close() } + +func ParseQuery(query map[string][]string) []*core.Media { + if v := query["mp4"]; v != nil { + return []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecH264}, + {Name: core.CodecH265}, + }, + }, + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecAAC}, + }, + }, + } + } + + return core.ParseQuery(query) +} diff --git a/cmd/srtp/srtp.go b/internal/srtp/srtp.go similarity index 94% rename from cmd/srtp/srtp.go rename to internal/srtp/srtp.go index a8e2c8b6..6cb2b546 100644 --- a/cmd/srtp/srtp.go +++ b/internal/srtp/srtp.go @@ -1,7 +1,7 @@ package srtp import ( - "github.com/AlexxIT/go2rtc/cmd/app" + "github.com/AlexxIT/go2rtc/internal/app" "github.com/AlexxIT/go2rtc/pkg/srtp" "net" ) diff --git a/cmd/streams/handlers.go b/internal/streams/handlers.go similarity index 100% rename from cmd/streams/handlers.go rename to internal/streams/handlers.go diff --git a/internal/streams/helpers.go b/internal/streams/helpers.go new file mode 100644 index 00000000..e59dab77 --- /dev/null +++ b/internal/streams/helpers.go @@ -0,0 +1,19 @@ +package streams + +import ( + "net/url" + "strings" +) + +func ParseQuery(s string) url.Values { + params := url.Values{} + for _, key := range strings.Split(s, "#") { + var value string + i := strings.IndexByte(key, '=') + if i > 0 { + key, value = key[:i], key[i+1:] + } + params[key] = append(params[key], value) + } + return params +} diff --git a/cmd/streams/init.go b/internal/streams/init.go similarity index 71% rename from cmd/streams/init.go rename to internal/streams/init.go index 8c5a454a..697ce9d2 100644 --- a/cmd/streams/init.go +++ b/internal/streams/init.go @@ -2,11 +2,12 @@ package streams import ( "encoding/json" - "github.com/AlexxIT/go2rtc/cmd/api" - "github.com/AlexxIT/go2rtc/cmd/app" - "github.com/AlexxIT/go2rtc/cmd/app/store" + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/app/store" "github.com/rs/zerolog" "net/http" + "net/url" ) func Init() { @@ -39,6 +40,20 @@ func New(name string, source any) *Stream { return stream } +func NewTemplate(name string, source any) *Stream { + // check if source links to some stream name from go2rtc + if rawURL, ok := source.(string); ok { + if u, err := url.Parse(rawURL); err == nil && u.Scheme == "rtsp" { + if stream, ok := streams[u.Path[1:]]; ok { + streams[name] = stream + return stream + } + } + } + + return New(name, "{input}") +} + func GetOrNew(src string) *Stream { if stream, ok := streams[src]; ok { return stream @@ -53,6 +68,13 @@ func GetOrNew(src string) *Stream { return New(src, src) } +func GetAll() (names []string) { + for name := range streams { + names = append(names, name) + } + return +} + func streamsHandler(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() src := query.Get("src") @@ -85,11 +107,12 @@ func streamsHandler(w http.ResponseWriter, r *http.Request) { return } - if stream := Get(name); stream != nil { - stream.SetSource(src) - } else { - New(name, src) + // support {input} templates: https://github.com/AlexxIT/go2rtc#module-hass + stream := Get(name) + if stream == nil { + stream = NewTemplate(name, src) } + stream.SetSource(src) case "POST": // with dst - redirect source to dst diff --git a/cmd/streams/play.go b/internal/streams/play.go similarity index 100% rename from cmd/streams/play.go rename to internal/streams/play.go diff --git a/cmd/streams/producer.go b/internal/streams/producer.go similarity index 99% rename from cmd/streams/producer.go rename to internal/streams/producer.go index 95f2ac65..6306e0af 100644 --- a/cmd/streams/producer.go +++ b/internal/streams/producer.go @@ -30,8 +30,6 @@ type Producer struct { receivers []*core.Receiver senders []*core.Receiver - lastErr error - state state mu sync.Mutex workerID int @@ -58,6 +56,10 @@ func (p *Producer) GetMedias() []*core.Media { p.mu.Lock() defer p.mu.Unlock() + if p.conn == nil { + return nil + } + return p.conn.GetMedias() } diff --git a/cmd/streams/stream.go b/internal/streams/stream.go similarity index 73% rename from cmd/streams/stream.go rename to internal/streams/stream.go index 3f60ad37..468f8889 100644 --- a/cmd/streams/stream.go +++ b/internal/streams/stream.go @@ -3,7 +3,6 @@ package streams import ( "encoding/json" "errors" - "fmt" "github.com/AlexxIT/go2rtc/pkg/core" "strings" "sync" @@ -31,8 +30,6 @@ func NewStream(source any) *Stream { s.producers = append(s.producers, prod) } return s - case *Stream: - return source case map[string]any: return NewStream(source["url"]) case nil: @@ -50,24 +47,28 @@ func (s *Stream) SetSource(source string) { func (s *Stream) AddConsumer(cons core.Consumer) (err error) { // support for multiple simultaneous requests from different consumers - atomic.AddInt32(&s.requests, 1) + consN := atomic.AddInt32(&s.requests, 1) - 1 - var producers []*Producer // matched producers for consumer - - var codecs string + var statErrors []error + var statMedias []*core.Media + var statProds []*Producer // matched producers for consumer // Step 1. Get consumer medias for _, consMedia := range cons.GetMedias() { + log.Trace().Msgf("[streams] check cons=%d media=%s", consN, consMedia) producers: - for _, prod := range s.producers { + for prodN, prod := range s.producers { if err = prod.Dial(); err != nil { + log.Trace().Err(err).Msgf("[streams] skip prod=%s", prod.url) + statErrors = append(statErrors, err) continue } // Step 2. Get producer medias (not tracks yet) for _, prodMedia := range prod.GetMedias() { - collectCodecs(prodMedia, &codecs) + log.Trace().Msgf("[streams] check prod=%d media=%s", prodN, prodMedia) + statMedias = append(statMedias, prodMedia) // Step 3. Match consumer/producer codecs list prodCodec, consCodec := prodMedia.MatchMedia(consMedia) @@ -79,6 +80,8 @@ func (s *Stream) AddConsumer(cons core.Consumer) (err error) { switch prodMedia.Direction { case core.DirectionRecvonly: + log.Trace().Msgf("[streams] match prod=%d => cons=%d", prodN, consN) + // Step 4. Get recvonly track from producer if track, err = prod.GetTrack(prodMedia, prodCodec); err != nil { log.Info().Err(err).Msg("[streams] can't get track") @@ -91,6 +94,8 @@ func (s *Stream) AddConsumer(cons core.Consumer) (err error) { } case core.DirectionSendonly: + log.Trace().Msgf("[streams] match cons=%d => prod=%d", consN, prodN) + // Step 4. Get recvonly track from consumer (backchannel) if track, err = cons.(core.Producer).GetTrack(consMedia, consCodec); err != nil { log.Info().Err(err).Msg("[streams] can't get track") @@ -103,7 +108,7 @@ func (s *Stream) AddConsumer(cons core.Consumer) (err error) { } } - producers = append(producers, prod) + statProds = append(statProds, prod) if !consMedia.MatchAll() { break producers @@ -117,18 +122,8 @@ func (s *Stream) AddConsumer(cons core.Consumer) (err error) { s.stopProducers() } - if len(producers) == 0 { - if len(codecs) > 0 { - return errors.New("codecs not match: " + codecs) - } - - for i, producer := range s.producers { - if producer.lastErr != nil { - return fmt.Errorf("source %d error: %w", i, producer.lastErr) - } - } - - return fmt.Errorf("sources unavailable: %d", len(s.producers)) + if len(statProds) == 0 { + return formatError(statMedias, statErrors) } s.mu.Lock() @@ -136,7 +131,7 @@ func (s *Stream) AddConsumer(cons core.Consumer) (err error) { s.mu.Unlock() // there may be duplicates, but that's not a problem - for _, prod := range producers { + for _, prod := range statProds { prod.start() } @@ -213,22 +208,47 @@ func (s *Stream) MarshalJSON() ([]byte, error) { return json.Marshal(info) } -func collectCodecs(media *core.Media, codecs *string) { - if media.Direction == core.DirectionRecvonly { - return - } +func formatError(statMedias []*core.Media, statErrors []error) error { + var text string - for _, codec := range media.Codecs { - name := codec.Name - if name == core.CodecAAC { - name = "AAC" - } - if strings.Contains(*codecs, name) { + for _, media := range statMedias { + if media.Direction == core.DirectionRecvonly { continue } - if len(*codecs) > 0 { - *codecs += "," + + for _, codec := range media.Codecs { + name := codec.Name + if name == core.CodecAAC { + name = "AAC" + } + if strings.Contains(text, name) { + continue + } + if len(text) > 0 { + text += "," + } + text += name } - *codecs += name } + + if text != "" { + return errors.New(text) + } + + for _, err := range statErrors { + s := err.Error() + if strings.Contains(text, s) { + continue + } + if len(text) > 0 { + text += "," + } + text += s + } + + if text != "" { + return errors.New(text) + } + + return errors.New("unknown error") } diff --git a/internal/streams/stream_test.go b/internal/streams/stream_test.go new file mode 100644 index 00000000..86dc92c2 --- /dev/null +++ b/internal/streams/stream_test.go @@ -0,0 +1,19 @@ +package streams + +import ( + "github.com/stretchr/testify/require" + "testing" +) + +func TestTemplate(t *testing.T) { + source1 := "does not matter" + + stream1 := New("from_yaml", source1) + require.Len(t, streams, 1) + + stream2 := NewTemplate("camera.from_hass", "rtsp://localhost:8554/from_yaml?video") + + require.Equal(t, stream1, stream2) + require.Equal(t, stream2.producers[0].url, source1) + require.Len(t, streams, 2) +} diff --git a/cmd/tapo/tapo.go b/internal/tapo/tapo.go similarity index 87% rename from cmd/tapo/tapo.go rename to internal/tapo/tapo.go index 07c278a5..971928c7 100644 --- a/cmd/tapo/tapo.go +++ b/internal/tapo/tapo.go @@ -1,7 +1,7 @@ package tapo import ( - "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/tapo" ) diff --git a/cmd/webrtc/README.md b/internal/webrtc/README.md similarity index 100% rename from cmd/webrtc/README.md rename to internal/webrtc/README.md diff --git a/cmd/webrtc/candidates.go b/internal/webrtc/candidates.go similarity index 98% rename from cmd/webrtc/candidates.go rename to internal/webrtc/candidates.go index 7825a5c5..51f97040 100644 --- a/cmd/webrtc/candidates.go +++ b/internal/webrtc/candidates.go @@ -1,7 +1,7 @@ package webrtc import ( - "github.com/AlexxIT/go2rtc/cmd/api" + "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/pkg/webrtc" "github.com/pion/sdp/v3" "strconv" diff --git a/cmd/webrtc/client.go b/internal/webrtc/client.go similarity index 98% rename from cmd/webrtc/client.go rename to internal/webrtc/client.go index bad44da8..9db347e9 100644 --- a/cmd/webrtc/client.go +++ b/internal/webrtc/client.go @@ -2,7 +2,7 @@ package webrtc import ( "errors" - "github.com/AlexxIT/go2rtc/cmd/api" + "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/webrtc" "github.com/gorilla/websocket" diff --git a/cmd/webrtc/init.go b/internal/webrtc/init.go similarity index 97% rename from cmd/webrtc/init.go rename to internal/webrtc/init.go index d760d688..6855e1bd 100644 --- a/cmd/webrtc/init.go +++ b/internal/webrtc/init.go @@ -2,9 +2,9 @@ package webrtc import ( "errors" - "github.com/AlexxIT/go2rtc/cmd/api" - "github.com/AlexxIT/go2rtc/cmd/app" - "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/webrtc" pion "github.com/pion/webrtc/v3" diff --git a/cmd/webrtc/server.go b/internal/webrtc/server.go similarity index 99% rename from cmd/webrtc/server.go rename to internal/webrtc/server.go index f66cab00..0bdd8341 100644 --- a/cmd/webrtc/server.go +++ b/internal/webrtc/server.go @@ -2,7 +2,7 @@ package webrtc import ( "encoding/json" - "github.com/AlexxIT/go2rtc/cmd/streams" + "github.com/AlexxIT/go2rtc/internal/streams" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/webrtc" pion "github.com/pion/webrtc/v3" diff --git a/cmd/webtorrent/init.go b/internal/webtorrent/init.go similarity index 95% rename from cmd/webtorrent/init.go rename to internal/webtorrent/init.go index e5de0da5..ede46e0e 100644 --- a/cmd/webtorrent/init.go +++ b/internal/webtorrent/init.go @@ -3,10 +3,10 @@ package webtorrent import ( "errors" "fmt" - "github.com/AlexxIT/go2rtc/cmd/api" - "github.com/AlexxIT/go2rtc/cmd/app" - "github.com/AlexxIT/go2rtc/cmd/streams" - "github.com/AlexxIT/go2rtc/cmd/webrtc" + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/internal/webrtc" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/webtorrent" "github.com/rs/zerolog" diff --git a/cmd/webtorrent/tracker.go b/internal/webtorrent/tracker.go similarity index 100% rename from cmd/webtorrent/tracker.go rename to internal/webtorrent/tracker.go diff --git a/main.go b/main.go index 4f91f4e6..97476cd1 100644 --- a/main.go +++ b/main.go @@ -1,41 +1,41 @@ package main import ( - "github.com/AlexxIT/go2rtc/cmd/api" - "github.com/AlexxIT/go2rtc/cmd/app" - "github.com/AlexxIT/go2rtc/cmd/debug" - "github.com/AlexxIT/go2rtc/cmd/dvrip" - "github.com/AlexxIT/go2rtc/cmd/echo" - "github.com/AlexxIT/go2rtc/cmd/exec" - "github.com/AlexxIT/go2rtc/cmd/ffmpeg" - "github.com/AlexxIT/go2rtc/cmd/hass" - "github.com/AlexxIT/go2rtc/cmd/hls" - "github.com/AlexxIT/go2rtc/cmd/homekit" - "github.com/AlexxIT/go2rtc/cmd/http" - "github.com/AlexxIT/go2rtc/cmd/isapi" - "github.com/AlexxIT/go2rtc/cmd/ivideon" - "github.com/AlexxIT/go2rtc/cmd/mjpeg" - "github.com/AlexxIT/go2rtc/cmd/mp4" - "github.com/AlexxIT/go2rtc/cmd/mpegts" - "github.com/AlexxIT/go2rtc/cmd/ngrok" - "github.com/AlexxIT/go2rtc/cmd/roborock" - "github.com/AlexxIT/go2rtc/cmd/rtmp" - "github.com/AlexxIT/go2rtc/cmd/rtsp" - "github.com/AlexxIT/go2rtc/cmd/srtp" - "github.com/AlexxIT/go2rtc/cmd/streams" - "github.com/AlexxIT/go2rtc/cmd/tapo" - "github.com/AlexxIT/go2rtc/cmd/tcp" - "github.com/AlexxIT/go2rtc/cmd/webrtc" - "github.com/AlexxIT/go2rtc/cmd/webtorrent" - "os" - "os/signal" - "syscall" + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/debug" + "github.com/AlexxIT/go2rtc/internal/dvrip" + "github.com/AlexxIT/go2rtc/internal/echo" + "github.com/AlexxIT/go2rtc/internal/exec" + "github.com/AlexxIT/go2rtc/internal/ffmpeg" + "github.com/AlexxIT/go2rtc/internal/hass" + "github.com/AlexxIT/go2rtc/internal/hls" + "github.com/AlexxIT/go2rtc/internal/homekit" + "github.com/AlexxIT/go2rtc/internal/http" + "github.com/AlexxIT/go2rtc/internal/isapi" + "github.com/AlexxIT/go2rtc/internal/ivideon" + "github.com/AlexxIT/go2rtc/internal/mjpeg" + "github.com/AlexxIT/go2rtc/internal/mp4" + "github.com/AlexxIT/go2rtc/internal/mpegts" + "github.com/AlexxIT/go2rtc/internal/nest" + "github.com/AlexxIT/go2rtc/internal/ngrok" + "github.com/AlexxIT/go2rtc/internal/onvif" + "github.com/AlexxIT/go2rtc/internal/roborock" + "github.com/AlexxIT/go2rtc/internal/rtmp" + "github.com/AlexxIT/go2rtc/internal/rtsp" + "github.com/AlexxIT/go2rtc/internal/srtp" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/internal/tapo" + "github.com/AlexxIT/go2rtc/internal/webrtc" + "github.com/AlexxIT/go2rtc/internal/webtorrent" + "github.com/AlexxIT/go2rtc/pkg/shell" ) func main() { app.Init() // init config and logs api.Init() // init HTTP API server streams.Init() // load streams list + onvif.Init() rtsp.Init() // add support RTSP client and RTSP server rtmp.Init() // add support RTMP client @@ -50,7 +50,7 @@ func main() { isapi.Init() mpegts.Init() roborock.Init() - tcp.Init() + nest.Init() srtp.Init() homekit.Init() @@ -64,9 +64,5 @@ func main() { ngrok.Init() debug.Init() - sigs := make(chan os.Signal, 1) - signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) - <-sigs - - println("exit OK") + shell.RunUntilSignal() } diff --git a/pkg/core/codec.go b/pkg/core/codec.go index 67c6d2cb..5f346739 100644 --- a/pkg/core/codec.go +++ b/pkg/core/codec.go @@ -68,7 +68,7 @@ func (c *Codec) Match(remote *Codec) bool { } func UnmarshalCodec(md *sdp.MediaDescription, payloadType string) *Codec { - c := &Codec{PayloadType: byte(atoi(payloadType))} + c := &Codec{PayloadType: byte(Atoi(payloadType))} for _, attr := range md.Attributes { switch { @@ -78,7 +78,7 @@ func UnmarshalCodec(md *sdp.MediaDescription, payloadType string) *Codec { c.Name = strings.ToUpper(ss[0]) // fix tailing space: `a=rtpmap:96 H264/90000 ` - c.ClockRate = uint32(atoi(strings.TrimRightFunc(ss[1], unicode.IsSpace))) + c.ClockRate = uint32(Atoi(strings.TrimRightFunc(ss[1], unicode.IsSpace))) if len(ss) == 3 && ss[2] == "2" { c.Channels = 2 @@ -99,9 +99,16 @@ func UnmarshalCodec(md *sdp.MediaDescription, payloadType string) *Codec { case "8": c.Name = CodecPCMA c.ClockRate = 8000 + case "10": + c.Name = CodecPCM + c.ClockRate = 44100 + c.Channels = 2 + case "11": + c.Name = CodecPCM + c.ClockRate = 44100 case "14": c.Name = CodecMP3 - c.ClockRate = 44100 + c.ClockRate = 90000 // it's not real sample rate case "26": c.Name = CodecJPEG c.ClockRate = 90000 @@ -113,11 +120,6 @@ func UnmarshalCodec(md *sdp.MediaDescription, payloadType string) *Codec { return c } -func atoi(s string) (i int) { - i, _ = strconv.Atoi(s) - return -} - func DecodeH264(fmtp string) string { if ps := Between(fmtp, "sprop-parameter-sets=", ","); ps != "" { if sps, _ := base64.StdEncoding.DecodeString(ps); len(sps) >= 4 { diff --git a/pkg/core/core.go b/pkg/core/core.go index 1a429d98..72d32b78 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -27,7 +27,8 @@ const ( CodecMP3 = "MPA" // payload: 14, aka MPEG-1 Layer III CodecPCM = "L16" // Linear PCM - CodecELD = "ELD" // AAC-ELD + CodecELD = "ELD" // AAC-ELD + CodecFLAC = "FLAC" CodecAll = "ALL" CodecAny = "ANY" diff --git a/pkg/core/helpers.go b/pkg/core/helpers.go index 060894df..b3871a6d 100644 --- a/pkg/core/helpers.go +++ b/pkg/core/helpers.go @@ -6,8 +6,14 @@ import ( "runtime" "strconv" "strings" + "time" ) +// Now90000 - timestamp for Video (clock rate = 90000 samples per second) +func Now90000() uint32 { + return uint32(time.Duration(time.Now().UnixNano()) * 90000 / time.Second) +} + const symbols = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_" // RandString base10 - numbers, base16 - hex, base36 - digits+letters, base64 - URL safe symbols @@ -22,6 +28,15 @@ func RandString(size, base byte) string { return string(b) } +func Any(errs ...error) error { + for _, err := range errs { + if err != nil { + return err + } + } + return nil +} + func Between(s, sub1, sub2 string) string { i := strings.Index(s, sub1) if i < 0 { @@ -41,6 +56,11 @@ func Between(s, sub1, sub2 string) string { return s } +func Atoi(s string) (i int) { + i, _ = strconv.Atoi(s) + return +} + func Assert(ok bool) { if !ok { _, file, line, _ := runtime.Caller(1) diff --git a/pkg/core/media.go b/pkg/core/media.go index 1e972350..5d73dc6b 100644 --- a/pkg/core/media.go +++ b/pkg/core/media.go @@ -82,11 +82,18 @@ func (m *Media) MatchAll() bool { return false } +func (m *Media) Equal(media *Media) bool { + if media.ID != "" { + return m.ID == media.ID + } + return m.String() == media.String() +} + func GetKind(name string) string { switch name { case CodecH264, CodecH265, CodecVP8, CodecVP9, CodecAV1, CodecJPEG: return KindVideo - case CodecPCMU, CodecPCMA, CodecAAC, CodecOpus, CodecG722, CodecMP3, CodecELD: + case CodecPCMU, CodecPCMA, CodecAAC, CodecOpus, CodecG722, CodecMP3, CodecPCM, CodecELD, CodecFLAC: return KindAudio } return "" @@ -129,6 +136,10 @@ func MarshalSDP(name string, medias []*Media) ([]byte, error) { } md.WithCodec(codec.PayloadType, name, codec.ClockRate, codec.Channels, codec.FmtpLine) + if media.ID != "" { + md.WithValueAttribute("control", media.ID) + } + sd.MediaDescriptions = append(sd.MediaDescriptions, md) } diff --git a/pkg/ffmpeg/ffmpeg.go b/pkg/ffmpeg/ffmpeg.go new file mode 100644 index 00000000..10fb5bed --- /dev/null +++ b/pkg/ffmpeg/ffmpeg.go @@ -0,0 +1,80 @@ +package ffmpeg + +import ( + "bytes" + "strconv" + "strings" +) + +type Args struct { + Bin string // ffmpeg + Global string // -hide_banner -v error + Input string // -re -stream_loop -1 -i /media/bunny.mp4 + Codecs []string // -c:v libx264 -g:v 30 -preset:v ultrafast -tune:v zerolatency + Filters []string // scale=1920:1080 + Output string // -f rtsp {output} + + Video, Audio int // count of Video and Audio params +} + +func (a *Args) AddCodec(codec string) { + a.Codecs = append(a.Codecs, codec) +} + +func (a *Args) AddFilter(filter string) { + a.Filters = append(a.Filters, filter) +} + +func (a *Args) InsertFilter(filter string) { + a.Filters = append([]string{filter}, a.Filters...) +} + +func (a *Args) String() string { + b := bytes.NewBuffer(make([]byte, 0, 512)) + + b.WriteString(a.Bin) + + if a.Global != "" { + b.WriteByte(' ') + b.WriteString(a.Global) + } + + b.WriteByte(' ') + b.WriteString(a.Input) + + multimode := a.Video > 1 || a.Audio > 1 + var iv, ia int + + for _, codec := range a.Codecs { + // support multiple video and/or audio codecs + if multimode && len(codec) >= 5 { + switch codec[:5] { + case "-c:v ": + codec = "-map 0:v:0? " + strings.ReplaceAll(codec, ":v ", ":v:"+strconv.Itoa(iv)+" ") + iv++ + case "-c:a ": + codec = "-map 0:a:0? " + strings.ReplaceAll(codec, ":a ", ":a:"+strconv.Itoa(ia)+" ") + ia++ + } + } + + b.WriteByte(' ') + b.WriteString(codec) + } + + if a.Filters != nil { + for i, filter := range a.Filters { + if i == 0 { + b.WriteString(" -vf ") + } else { + b.WriteByte(',') + } + b.WriteString(filter) + } + } + + b.WriteByte(' ') + b.WriteString(a.Output) + + return b.String() +} diff --git a/pkg/h264/avc.go b/pkg/h264/avc.go index 99fd4598..c21b2f11 100644 --- a/pkg/h264/avc.go +++ b/pkg/h264/avc.go @@ -26,6 +26,19 @@ func AnnexB2AVC(b []byte) []byte { return b } +func AVCtoAnnexB(b []byte) []byte { + b = bytes.Clone(b) + for i := 0; i < len(b); { + size := int(binary.BigEndian.Uint32(b[i:])) + b[i] = 0 + b[i+1] = 0 + b[i+2] = 0 + b[i+3] = 1 + i += 4 + size + } + return b +} + const forbiddenZeroBit = 0x80 const nalUnitType = 0x1F diff --git a/pkg/h264/rtp.go b/pkg/h264/rtp.go index 8cccb637..a1bc93ea 100644 --- a/pkg/h264/rtp.go +++ b/pkg/h264/rtp.go @@ -94,6 +94,10 @@ func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { } func RTPPay(mtu uint16, handler core.HandlerFunc) core.HandlerFunc { + if mtu == 0 { + mtu = 1472 + } + payloader := &Payloader{IsAVC: true} sequencer := rtp.NewRandomSequencer() mtu -= 12 // rtp.Header size diff --git a/pkg/h265/avc.go b/pkg/h265/avc.go new file mode 100644 index 00000000..f6d68559 --- /dev/null +++ b/pkg/h265/avc.go @@ -0,0 +1,54 @@ +package h265 + +import "github.com/AlexxIT/go2rtc/pkg/h264" + +const forbiddenZeroBit = 0x80 +const nalUnitType = 0x3F + +// DecodeStream - find and return first AU in AVC format +// useful for processing live streams with unknown separator size +func DecodeStream(annexb []byte) ([]byte, int) { + startPos := -1 + + i := 0 + for { + // search next separator + if i = h264.IndexFrom(annexb, []byte{0, 0, 1}, i); i < 0 { + break + } + + // move i to next AU + if i += 3; i >= len(annexb) { + break + } + + // check if AU type valid + octet := annexb[i] + if octet&forbiddenZeroBit != 0 { + continue + } + + nalType := (octet >> 1) & nalUnitType + if startPos >= 0 { + switch nalType { + case NALUTypeVPS, NALUTypePFrame: + if annexb[i-4] == 0 { + return h264.DecodeAnnexB(annexb[startPos : i-4]), i - 4 + } else { + return h264.DecodeAnnexB(annexb[startPos : i-3]), i - 3 + } + } + } else { + switch nalType { + case NALUTypeVPS, NALUTypePFrame: + if i >= 4 && annexb[i-4] == 0 { + startPos = i - 4 + } else { + startPos = i - 3 + } + } + } + } + + return nil, 0 +} diff --git a/pkg/h265/rtp.go b/pkg/h265/rtp.go index 333ca6d4..3e027b83 100644 --- a/pkg/h265/rtp.go +++ b/pkg/h265/rtp.go @@ -76,6 +76,10 @@ func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { } func RTPPay(mtu uint16, handler core.HandlerFunc) core.HandlerFunc { + if mtu == 0 { + mtu = 1472 + } + payloader := &Payloader{} sequencer := rtp.NewRandomSequencer() mtu -= 12 // rtp.Header size diff --git a/pkg/hass/api.go b/pkg/hass/api.go new file mode 100644 index 00000000..6d5a9204 --- /dev/null +++ b/pkg/hass/api.go @@ -0,0 +1,143 @@ +package hass + +import ( + "errors" + "github.com/gorilla/websocket" + "os" +) + +type API struct { + ws *websocket.Conn +} + +func NewAPI(url, token string) (*API, error) { + ws, _, err := websocket.DefaultDialer.Dial(url, nil) + if err != nil { + return nil, err + } + + api := &API{ws: ws} + if err = api.Auth(token); err != nil { + _ = ws.Close() + return nil, err + } + + return api, nil +} + +func (a *API) Auth(token string) error { + var res ResponseAuth + + if err := a.ws.ReadJSON(&res); err != nil { + return err + } + if res.Type != "auth_required" { + return errors.New("hass: wrong type: " + res.Type) + } + + s := `{"type":"auth","access_token":"` + token + `"}` + if err := a.ws.WriteMessage(websocket.TextMessage, []byte(s)); err != nil { + return err + } + if err := a.ws.ReadJSON(&res); err != nil { + return err + } + if res.Type != "auth_ok" { + return errors.New("hass: wrong type: " + res.Type) + } + + return nil +} + +func (a *API) Close() error { + return a.ws.Close() +} + +func (a *API) ExchangeSDP(entityID, offer string) (string, error) { + var msg = map[string]any{ + "id": 1, + "type": "camera/web_rtc_offer", + "entity_id": entityID, + "offer": offer, + } + if err := a.ws.WriteJSON(msg); err != nil { + return "", err + } + + var res ResponseOffer + if err := a.ws.ReadJSON(&res); err != nil { + return "", err + } + + if res.Type != "result" || !res.Success { + return "", errors.New("hass: wrong response") + } + + return res.Result.Answer, nil +} + +func (a *API) GetWebRTCEntities() (map[string]string, error) { + s := `{"id":1,"type":"get_states"}` + if err := a.ws.WriteMessage(websocket.TextMessage, []byte(s)); err != nil { + return nil, err + } + + var res ResponseStates + if err := a.ws.ReadJSON(&res); err != nil { + return nil, err + } + if res.Type != "result" || !res.Success { + return nil, errors.New("hass: wrong response") + } + + entities := map[string]string{} + + for _, entity := range res.Result { + if entity.Attributes.FrontendStreamType == "web_rtc" { + entities[entity.Attributes.FriendlyName] = entity.EntityId + } + } + + return entities, nil +} + +type ResponseAuth struct { + Type string `json:"type"` +} + +type ResponseStates struct { + //Id int `json:"id"` + Type string `json:"type"` + Success bool `json:"success"` + Result []struct { + EntityId string `json:"entity_id"` + //State string `json:"state"` + Attributes struct { + //ModelName string `json:"model_name"` + //Brand string `json:"brand"` + FrontendStreamType string `json:"frontend_stream_type"` + FriendlyName string `json:"friendly_name"` + //SupportedFeatures int `json:"supported_features"` + } `json:"attributes"` + //LastChanged time.Time `json:"last_changed"` + //LastUpdated time.Time `json:"last_updated"` + //Context struct { + // Id string `json:"id"` + // ParentId interface{} `json:"parent_id"` + // UserId interface{} `json:"user_id"` + //} `json:"context"` + } `json:"result"` +} + +type ResponseOffer struct { + //Id int `json:"id"` + Type string `json:"type"` + Success bool `json:"success"` + Result struct { + Answer string `json:"answer"` + } `json:"result"` +} + +func SupervisorToken() string { + return os.Getenv("SUPERVISOR_TOKEN") +} diff --git a/pkg/hass/client.go b/pkg/hass/client.go new file mode 100644 index 00000000..5b9a227a --- /dev/null +++ b/pkg/hass/client.go @@ -0,0 +1,115 @@ +package hass + +import ( + "errors" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/webrtc" + pion "github.com/pion/webrtc/v3" + "net/url" +) + +type Client struct { + conn *webrtc.Conn +} + +func NewClient(rawURL string) (*Client, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + query := u.Query() + + entityID := query.Get("entity_id") + if entityID == "" { + return nil, errors.New("hass: no entity_id") + } + + var uri, token string + + if u.Host == "supervisor" { + uri = "ws://supervisor/core/websocket" + token = SupervisorToken() + } else { + uri = "ws://" + u.Host + "/api/websocket" + token = query.Get("token") + } + + if token == "" { + return nil, errors.New("hass: no token") + } + + // 1. Check connection to Hass + hassAPI, err := NewAPI(uri, token) + if err != nil { + return nil, err + } + + defer hassAPI.Close() + + // 2. Create WebRTC client + rtcAPI, err := webrtc.NewAPI("") + if err != nil { + return nil, err + } + + conf := pion.Configuration{} + pc, err := rtcAPI.NewPeerConnection(conf) + if err != nil { + return nil, err + } + + conn := webrtc.NewConn(pc) + conn.Desc = "Hass" + conn.Mode = core.ModeActiveProducer + + // https://developers.google.com/nest/device-access/traits/device/camera-live-stream#generatewebrtcstream-request-fields + medias := []*core.Media{ + {Kind: core.KindAudio, Direction: core.DirectionRecvonly}, + {Kind: core.KindVideo, Direction: core.DirectionRecvonly}, + {Kind: "app"}, // important for Nest + } + + // 3. Create offer with candidates + offer, err := conn.CreateCompleteOffer(medias) + if err != nil { + return nil, err + } + + // 4. Exchange SDP via Hass + answer, err := hassAPI.ExchangeSDP(entityID, offer) + if err != nil { + return nil, err + } + + // 5. Set answer with remote medias + if err = conn.SetAnswer(answer); err != nil { + return nil, err + } + + return &Client{conn: conn}, nil +} + +func (c *Client) GetMedias() []*core.Media { + return c.conn.GetMedias() +} + +func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return c.conn.GetTrack(media, codec) +} + +func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + return c.conn.AddTrack(media, codec, track) +} + +func (c *Client) Start() error { + return c.conn.Start() +} + +func (c *Client) Stop() error { + return c.conn.Stop() +} + +func (c *Client) MarshalJSON() ([]byte, error) { + return c.conn.MarshalJSON() +} diff --git a/pkg/iso/atoms.go b/pkg/iso/atoms.go index 919e6c22..6a4c9fe7 100644 --- a/pkg/iso/atoms.go +++ b/pkg/iso/atoms.go @@ -32,6 +32,16 @@ const ( Mdat = "mdat" ) +const ( + sampleIsNonSync = 0x10000 + sampleDependsOn1 = 0x1000000 + sampleDependsOn2 = 0x2000000 + + SampleVideoIFrame = sampleDependsOn2 + SampleVideoNonIFrame = sampleDependsOn1 | sampleIsNonSync + SampleAudio = sampleIsNonSync +) + func (m *Movie) WriteFileType() { m.StartAtom(Ftyp) m.WriteString("iso5") @@ -250,7 +260,7 @@ func (m *Movie) WriteAudioTrack(id uint32, codec string, timescale uint32, chann m.EndAtom() // TRAK } -func (m *Movie) WriteMovieFragment(seq, tid, duration, size uint32, time uint64) { +func (m *Movie) WriteMovieFragment(seq, tid, duration, size, flags uint32, time uint64) { m.StartAtom(Moof) m.StartAtom(MoofMfhd) @@ -276,10 +286,10 @@ func (m *Movie) WriteMovieFragment(seq, tid, duration, size uint32, time uint64) TfhdDefaultSampleFlags | TfhdDefaultBaseIsMoof, ) - m.WriteUint32(tid) // track id - m.WriteUint32(duration) // default sample duration - m.WriteUint32(size) // default sample size - m.WriteUint32(0x2000000) // default sample flags + m.WriteUint32(tid) // track id + m.WriteUint32(duration) // default sample duration + m.WriteUint32(size) // default sample size + m.WriteUint32(flags) // default sample flags m.EndAtom() m.StartAtom(MoofTrafTfdt) @@ -314,5 +324,4 @@ func (m *Movie) WriteData(b []byte) { m.StartAtom(Mdat) m.Write(b) m.EndAtom() - } diff --git a/pkg/iso/codecs.go b/pkg/iso/codecs.go index fe1d6093..9f11428b 100644 --- a/pkg/iso/codecs.go +++ b/pkg/iso/codecs.go @@ -2,6 +2,7 @@ package iso import ( "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/pcm" ) func (m *Movie) WriteVideo(codec string, width, height uint16, conf []byte) { @@ -46,9 +47,11 @@ func (m *Movie) WriteVideo(codec string, width, height uint16, conf []byte) { func (m *Movie) WriteAudio(codec string, channels uint16, sampleRate uint32, conf []byte) { switch codec { case core.CodecAAC, core.CodecMP3: - m.StartAtom("mp4a") + m.StartAtom("mp4a") // supported in all players and browsers + case core.CodecFLAC: + m.StartAtom("fLaC") // supported in all players and browsers case core.CodecOpus: - m.StartAtom("Opus") + m.StartAtom("Opus") // supported in Chrome and Firefox case core.CodecPCMU: m.StartAtom("ulaw") case core.CodecPCMA: @@ -56,6 +59,11 @@ func (m *Movie) WriteAudio(codec string, channels uint16, sampleRate uint32, con default: panic("unsupported iso audio: " + codec) } + + if channels == 0 { + channels = 1 + } + m.Skip(6) m.WriteUint16(1) // data_reference_index m.Skip(2) // version @@ -72,6 +80,10 @@ func (m *Movie) WriteAudio(codec string, channels uint16, sampleRate uint32, con m.WriteEsdsAAC(conf) case core.CodecMP3: m.WriteEsdsMP3() + case core.CodecFLAC: + m.StartAtom("dfLa") + m.Write(pcm.FLACHeader(false, sampleRate)) + m.EndAtom() case core.CodecOpus: // don't know what means this magic m.StartAtom("dOps") @@ -106,6 +118,7 @@ func (m *Movie) WriteEsdsAAC(conf []byte) { m.Skip(2) // es id m.Skip(1) // es flags + // https://learn.microsoft.com/en-us/windows/win32/medfound/mpeg-4-file-sink#aac-audio m.WriteBytes(4, 0x80, 0x80, 0x80, size4+header+size5) m.WriteBytes(0x40) // object id m.WriteBytes(0x15) // stream type @@ -139,6 +152,7 @@ func (m *Movie) WriteEsdsMP3() { m.Skip(2) // es id m.Skip(1) // es flags + // https://learn.microsoft.com/en-us/windows/win32/medfound/mpeg-4-file-sink#mp3-audio m.WriteBytes(4, 0x80, 0x80, 0x80, size4) m.WriteBytes(0x6B) // object id m.WriteBytes(0x15) // stream type diff --git a/pkg/magic/client.go b/pkg/magic/client.go new file mode 100644 index 00000000..640f794c --- /dev/null +++ b/pkg/magic/client.go @@ -0,0 +1,214 @@ +package magic + +import ( + "bytes" + "encoding/hex" + "errors" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/AlexxIT/go2rtc/pkg/mpegts" + "github.com/pion/rtp" + "io" +) + +// Client - can read unknown bytestream and autodetect format +type Client struct { + Desc string + URL string + + Handle func() error + + r io.ReadCloser + sniff []byte + + medias []*core.Media + receiver *core.Receiver + + recv int +} + +func NewClient(r io.ReadCloser) *Client { + return &Client{r: r} +} + +func (c *Client) Probe() (err error) { + c.sniff = make([]byte, mpegts.PacketSize*3) // MPEG-TS: SDT+PAT+PMT + c.recv, err = io.ReadFull(c.r, c.sniff) + if err != nil { + _ = c.Close() + return + } + + var codec *core.Codec + + if bytes.HasPrefix(c.sniff, []byte{0, 0, 0, 1}) { + switch { + case h264.NALUType(c.sniff) == h264.NALUTypeSPS: + codec = &core.Codec{ + Name: core.CodecH264, + ClockRate: 90000, + PayloadType: core.PayloadTypeRAW, + } + c.Handle = c.ReadBitstreams + + case h265.NALUType(c.sniff) == h265.NALUTypeVPS: + codec = &core.Codec{ + Name: core.CodecH265, + ClockRate: 90000, + PayloadType: core.PayloadTypeRAW, + } + c.Handle = c.ReadBitstreams + } + + } else if bytes.HasPrefix(c.sniff, []byte{0xFF, 0xD8}) { + codec = &core.Codec{ + Name: core.CodecJPEG, + ClockRate: 90000, + PayloadType: core.PayloadTypeRAW, + } + c.Handle = c.ReadMJPEG + + } else if c.sniff[0] == mpegts.SyncByte { + ts := mpegts.NewReader() + ts.AppendBuffer(c.sniff) + _ = ts.GetPacket() + for _, streamType := range ts.GetStreamTypes() { + switch streamType { + case mpegts.StreamTypeH264: + codec = &core.Codec{ + Name: core.CodecH264, + ClockRate: 90000, + PayloadType: core.PayloadTypeRAW, + } + c.Handle = c.ReadMPEGTS + + case mpegts.StreamTypeH265: + codec = &core.Codec{ + Name: core.CodecH265, + ClockRate: 90000, + PayloadType: core.PayloadTypeRAW, + } + c.Handle = c.ReadMPEGTS + } + } + } + + if codec == nil { + _ = c.Close() + return errors.New("unknown format: " + hex.EncodeToString(c.sniff[:8])) + } + + c.medias = append(c.medias, &core.Media{ + Kind: core.KindVideo, + Direction: core.DirectionRecvonly, + Codecs: []*core.Codec{codec}, + }) + + return +} + +func (c *Client) ReadBitstreams() error { + buf := c.sniff // total bufer + b := make([]byte, 1024*1024) // reading buffer + + var decodeStream func([]byte) ([]byte, int) + switch c.receiver.Codec.Name { + case core.CodecH264: + decodeStream = h264.DecodeStream + case core.CodecH265: + decodeStream = h265.DecodeStream + } + + for { + payload, n := decodeStream(buf) + if payload == nil { + n, err := c.r.Read(b) + if err != nil { + return err + } + + buf = append(buf, b[:n]...) + c.recv += n + continue + } + + buf = buf[n:] + + //log.Printf("[AVC] %v, len: %d", h264.Types(payload), len(payload)) + + pkt := &rtp.Packet{ + Header: rtp.Header{Timestamp: core.Now90000()}, + Payload: payload, + } + c.receiver.WriteRTP(pkt) + } +} + +func (c *Client) ReadMJPEG() error { + buf := c.sniff // total bufer + b := make([]byte, 1024*1024) // reading buffer + + for { + // one JPEG end and next start + i := bytes.Index(buf, []byte{0xFF, 0xD9, 0xFF, 0xD8}) + if i < 0 { + n, err := c.r.Read(b) + if err != nil { + return err + } + + buf = append(buf, b[:n]...) + c.recv += n + + // if we receive frame + if n >= 2 && b[n-2] == 0xFF && b[n-1] == 0xD9 { + i = len(buf) + } else { + continue + } + } else { + i += 2 + } + + pkt := &rtp.Packet{ + Header: rtp.Header{Timestamp: core.Now90000()}, + Payload: buf[:i], + } + c.receiver.WriteRTP(pkt) + + buf = buf[i:] + } +} + +func (c *Client) ReadMPEGTS() error { + b := make([]byte, 1024*1024) // reading buffer + + ts := mpegts.NewReader() + ts.AppendBuffer(c.sniff) + + for { + packet := ts.GetPacket() + if packet == nil { + n, err := c.r.Read(b) + if err != nil { + return err + } + + ts.AppendBuffer(b[:n]) + c.recv += n + continue + } + + //log.Printf("[AVC] %v, len: %d, ts: %10d", h264.Types(packet.Payload), len(packet.Payload), packet.Timestamp) + + switch packet.PayloadType { + case mpegts.StreamTypeH264, mpegts.StreamTypeH265: + c.receiver.WriteRTP(packet) + } + } +} + +func (c *Client) Close() error { + return c.r.Close() +} diff --git a/pkg/magic/keyframe.go b/pkg/magic/keyframe.go new file mode 100644 index 00000000..abe83e59 --- /dev/null +++ b/pkg/magic/keyframe.go @@ -0,0 +1,91 @@ +package magic + +import ( + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/AlexxIT/go2rtc/pkg/mjpeg" + "github.com/pion/rtp" +) + +type Keyframe struct { + core.Listener + + UserAgent string + RemoteAddr string + + medias []*core.Media + sender *core.Sender +} + +func (k *Keyframe) GetMedias() []*core.Media { + if k.medias == nil { + k.medias = append(k.medias, &core.Media{ + Kind: core.KindVideo, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecH264}, + {Name: core.CodecH265}, + {Name: core.CodecJPEG}, + }, + }) + } + return k.medias +} + +func (k *Keyframe) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { + var handler core.HandlerFunc + + switch track.Codec.Name { + case core.CodecH264: + handler = func(packet *rtp.Packet) { + if !h264.IsKeyframe(packet.Payload) { + return + } + b := h264.AVCtoAnnexB(packet.Payload) + k.Fire(b) + } + + if track.Codec.IsRTP() { + handler = h264.RTPDepay(track.Codec, handler) + } + case core.CodecH265: + handler = func(packet *rtp.Packet) { + if !h265.IsKeyframe(packet.Payload) { + return + } + k.Fire(packet.Payload) + } + + if track.Codec.IsRTP() { + handler = h265.RTPDepay(track.Codec, handler) + } + case core.CodecJPEG: + handler = func(packet *rtp.Packet) { + k.Fire(packet.Payload) + } + + if track.Codec.IsRTP() { + handler = mjpeg.RTPDepay(handler) + } + } + + k.sender = core.NewSender(media, track.Codec) + k.sender.Handler = handler + k.sender.HandleRTP(track) + return nil +} + +func (k *Keyframe) CodecName() string { + if k.sender != nil { + return k.sender.Codec.Name + } + return "" +} + +func (k *Keyframe) Stop() error { + if k.sender != nil { + k.sender.Close() + } + return nil +} diff --git a/pkg/magic/producer.go b/pkg/magic/producer.go new file mode 100644 index 00000000..716a1eec --- /dev/null +++ b/pkg/magic/producer.go @@ -0,0 +1,41 @@ +package magic + +import ( + "encoding/json" + "github.com/AlexxIT/go2rtc/pkg/core" +) + +func (c *Client) GetMedias() []*core.Media { + return c.medias +} + +func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + if c.receiver == nil { + c.receiver = core.NewReceiver(media, codec) + } + return c.receiver, nil +} + +func (c *Client) Start() error { + return c.Handle() +} + +func (c *Client) Stop() (err error) { + if c.receiver != nil { + c.receiver.Close() + } + return c.Close() +} + +func (c *Client) MarshalJSON() ([]byte, error) { + info := &core.Info{ + Type: c.Desc, + URL: c.URL, + Medias: c.medias, + Recv: c.recv, + } + if c.receiver != nil { + info.Receivers = append(info.Receivers, c.receiver) + } + return json.Marshal(info) +} diff --git a/pkg/mp4/consumer.go b/pkg/mp4/consumer.go index 47555540..069016cb 100644 --- a/pkg/mp4/consumer.go +++ b/pkg/mp4/consumer.go @@ -6,7 +6,9 @@ import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/h264" "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/AlexxIT/go2rtc/pkg/pcm" "github.com/pion/rtp" + "sync" ) type Consumer struct { @@ -19,6 +21,7 @@ type Consumer struct { senders []*core.Sender muxer *Muxer + mu sync.Mutex wait byte send int @@ -52,7 +55,8 @@ func (c *Consumer) GetMedias() []*core.Media { func (c *Consumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error { trackID := byte(len(c.senders)) - handler := core.NewSender(media, track.Codec) + codec := track.Codec.Clone() + handler := core.NewSender(media, codec) switch track.Codec.Name { case core.CodecH264: @@ -70,10 +74,12 @@ func (c *Consumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv c.wait = waitNone } + // important to use Mutex because right fragment order + c.mu.Lock() buf := c.muxer.Marshal(trackID, packet) c.Fire(buf) - c.send += len(buf) + c.mu.Unlock() } if track.Codec.IsRTP() { @@ -97,46 +103,48 @@ func (c *Consumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv c.wait = waitNone } + c.mu.Lock() buf := c.muxer.Marshal(trackID, packet) c.Fire(buf) - c.send += len(buf) + c.mu.Unlock() } if track.Codec.IsRTP() { handler.Handler = h265.RTPDepay(track.Codec, handler.Handler) } - case core.CodecAAC: - handler.Handler = func(packet *rtp.Packet) { - if c.wait != waitNone { - return - } - - buf := c.muxer.Marshal(trackID, packet) - c.Fire(buf) - - c.send += len(buf) - } - - if track.Codec.IsRTP() { - handler.Handler = aac.RTPDepay(handler.Handler) - } - - case core.CodecOpus, core.CodecMP3, core.CodecPCMU, core.CodecPCMA: - handler.Handler = func(packet *rtp.Packet) { - if c.wait != waitNone { - return - } - - buf := c.muxer.Marshal(trackID, packet) - c.Fire(buf) - - c.send += len(buf) - } - default: - panic("unsupported codec") + handler.Handler = func(packet *rtp.Packet) { + if c.wait != waitNone { + return + } + + c.mu.Lock() + buf := c.muxer.Marshal(trackID, packet) + c.Fire(buf) + c.send += len(buf) + c.mu.Unlock() + } + + switch track.Codec.Name { + case core.CodecAAC: + if track.Codec.IsRTP() { + handler.Handler = aac.RTPDepay(handler.Handler) + } + case core.CodecOpus, core.CodecMP3: // no changes + case core.CodecPCMA, core.CodecPCMU, core.CodecPCM: + handler.Handler = pcm.FLACEncoder(track.Codec, handler.Handler) + codec.Name = core.CodecFLAC + + default: + handler.Handler = nil + } + } + + if handler.Handler == nil { + println("ERROR: MP4 unsupported codec: " + track.Codec.String()) + return nil } handler.HandleRTP(track) diff --git a/pkg/mp4/helpers.go b/pkg/mp4/helpers.go index 909b59cb..c22f1220 100644 --- a/pkg/mp4/helpers.go +++ b/pkg/mp4/helpers.go @@ -4,9 +4,45 @@ import "github.com/AlexxIT/go2rtc/pkg/core" // ParseQuery - like usual parse, but with mp4 param handler func ParseQuery(query map[string][]string) []*core.Media { - if query["mp4"] != nil { - cons := Consumer{} - return cons.GetMedias() + if v := query["mp4"]; len(v) != 0 { + medias := []*core.Media{ + { + Kind: core.KindVideo, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecH264}, + {Name: core.CodecH265}, + }, + }, + { + Kind: core.KindAudio, + Direction: core.DirectionSendonly, + Codecs: []*core.Codec{ + {Name: core.CodecAAC}, + }, + }, + } + + if v[0] == "" { + return medias // legacy + } + + medias[1].Codecs = append(medias[1].Codecs, + &core.Codec{Name: core.CodecPCMA}, + &core.Codec{Name: core.CodecPCMU}, + &core.Codec{Name: core.CodecPCM}, + ) + + if v[0] == "flac" { + return medias // modern browsers + } + + medias[1].Codecs = append(medias[1].Codecs, + &core.Codec{Name: core.CodecOpus}, + &core.Codec{Name: core.CodecMP3}, + ) + + return medias // Chrome, FFmpeg, VLC } return core.ParseQuery(query) diff --git a/pkg/mp4/muxer.go b/pkg/mp4/muxer.go index 067902a8..b1ec37f4 100644 --- a/pkg/mp4/muxer.go +++ b/pkg/mp4/muxer.go @@ -15,12 +15,14 @@ type Muxer struct { fragIndex uint32 dts []uint64 pts []uint32 + codecs []*core.Codec } const ( MimeH264 = "avc1.640029" MimeH265 = "hvc1.1.6.L153.B0" MimeAAC = "mp4a.40.2" + MimeFlac = "flac" MimeOpus = "opus" ) @@ -43,6 +45,8 @@ func (m *Muxer) MimeCodecs(codecs []*core.Codec) string { s += MimeAAC case core.CodecOpus: s += MimeOpus + case core.CodecFLAC: + s += MimeFlac } } @@ -60,9 +64,11 @@ func (m *Muxer) GetInit(codecs []*core.Codec) ([]byte, error) { switch codec.Name { case core.CodecH264: sps, pps := h264.GetParameterSet(codec.FmtpLine) - if sps == nil { - // some dummy SPS and PPS not a problem + // some dummy SPS and PPS not a problem + if len(sps) == 0 { sps = []byte{0x67, 0x42, 0x00, 0x0a, 0xf8, 0x41, 0xa2} + } + if len(pps) == 0 { pps = []byte{0x68, 0xce, 0x38, 0x80} } @@ -79,10 +85,14 @@ func (m *Muxer) GetInit(codecs []*core.Codec) ([]byte, error) { case core.CodecH265: vps, sps, pps := h265.GetParameterSet(codec.FmtpLine) - if sps == nil { - // some dummy SPS and PPS not a problem + // some dummy SPS and PPS not a problem + if len(vps) == 0 { vps = []byte{0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x01, 0x40, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x99, 0xac, 0x09} + } + if len(sps) == 0 { sps = []byte{0x42, 0x01, 0x01, 0x01, 0x40, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x99, 0xa0, 0x01, 0x40, 0x20, 0x05, 0xa1, 0xfe, 0x5a, 0xee, 0x46, 0xc1, 0xae, 0x55, 0x04} + } + if len(pps) == 0 { pps = []byte{0x44, 0x01, 0xc0, 0x73, 0xc0, 0x4c, 0x90} } @@ -108,14 +118,15 @@ func (m *Muxer) GetInit(codecs []*core.Codec) ([]byte, error) { uint32(i+1), codec.Name, codec.ClockRate, codec.Channels, b, ) - case core.CodecOpus, core.CodecMP3, core.CodecPCMU, core.CodecPCMA: + case core.CodecOpus, core.CodecMP3, core.CodecPCMA, core.CodecPCMU, core.CodecPCM, core.CodecFLAC: mv.WriteAudioTrack( uint32(i+1), codec.Name, codec.ClockRate, codec.Channels, nil, ) } - m.pts = append(m.pts, 0) m.dts = append(m.dts, 0) + m.pts = append(m.pts, 0) + m.codecs = append(m.codecs, codec) } mv.StartAtom(iso.MoovMvex) @@ -138,28 +149,49 @@ func (m *Muxer) Reset() { } func (m *Muxer) Marshal(trackID byte, packet *rtp.Packet) []byte { - // important before increment - time := m.dts[trackID] + codec := m.codecs[trackID] + + duration := packet.Timestamp - m.pts[trackID] + m.pts[trackID] = packet.Timestamp + + // minumum duration important for MSE in Apple Safari + if duration == 0 || duration > codec.ClockRate { + duration = codec.ClockRate/1000 + 1 + m.pts[trackID] += duration + } + + size := len(packet.Payload) + + // flags important for Apple Finder video preview + var flags uint32 + switch codec.Name { + case core.CodecH264: + if h264.IsKeyframe(packet.Payload) { + flags = iso.SampleVideoIFrame + } else { + flags = iso.SampleVideoNonIFrame + } + case core.CodecH265: + if h265.IsKeyframe(packet.Payload) { + flags = iso.SampleVideoIFrame + } else { + flags = iso.SampleVideoNonIFrame + } + default: + flags = iso.SampleAudio // not important + } m.fragIndex++ - var duration uint32 - newTime := packet.Timestamp - if m.pts[trackID] > 0 { - duration = newTime - m.pts[trackID] - m.dts[trackID] += uint64(duration) - } else { - // important, or Safari will fail with first frame - duration = 1 - } - m.pts[trackID] = newTime - - mv := iso.NewMovie(1024 + len(packet.Payload)) + mv := iso.NewMovie(1024 + size) mv.WriteMovieFragment( - m.fragIndex, uint32(trackID+1), duration, - uint32(len(packet.Payload)), time, + m.fragIndex, uint32(trackID+1), duration, uint32(size), flags, m.dts[trackID], ) mv.WriteData(packet.Payload) + //log.Printf("[MP4] track=%d ts=%6d dur=%5d idx=%3d len=%d", trackID+1, m.dts[trackID], duration, m.fragIndex, len(packet.Payload)) + + m.dts[trackID] += uint64(duration) + return mv.Bytes() } diff --git a/pkg/mpegts/client.go b/pkg/mpegts/client.go index 4ef41813..16bc5420 100644 --- a/pkg/mpegts/client.go +++ b/pkg/mpegts/client.go @@ -23,7 +23,7 @@ func NewClient(res *http.Response) *Client { func (c *Client) Handle() error { reader := NewReader() - b := make([]byte, 1024*1024*256) // 256K + b := make([]byte, 1024*256) // 256K probe := core.NewProbe(c.medias == nil) for probe == nil || probe.Active() { diff --git a/pkg/mpegts/helpers.go b/pkg/mpegts/helpers.go index 037078a7..5407f70b 100644 --- a/pkg/mpegts/helpers.go +++ b/pkg/mpegts/helpers.go @@ -3,6 +3,7 @@ package mpegts import ( "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/h264" + "github.com/AlexxIT/go2rtc/pkg/h265" "github.com/pion/rtp" "time" ) @@ -16,6 +17,7 @@ const ( StreamTypePrivate = 0x06 // PCMU or PCMA or FLAC from FFmpeg StreamTypeAAC = 0x0F StreamTypeH264 = 0x1B + StreamTypeH265 = 0x24 StreamTypePCMATapo = 0x90 ) @@ -36,6 +38,8 @@ type PES struct { Sequence uint16 Timestamp uint32 + + decodeStream func([]byte) ([]byte, int) } const ( @@ -52,9 +56,14 @@ func (p *PES) SetBuffer(size uint16, b []byte) { optSize := b[2] // optional fields b = b[minHeaderSize+optSize:] - if p.StreamType == StreamTypeH264 { + switch p.StreamType { + case StreamTypeH264: p.Mode = ModeStream - } else { + p.decodeStream = h264.DecodeStream + case StreamTypeH265: + p.Mode = ModeStream + p.decodeStream = h265.DecodeStream + default: println("WARNING: mpegts: unknown zero-size stream") } } else { @@ -91,7 +100,7 @@ func (p *PES) GetPacket() (pkt *rtp.Packet) { payload := p.Payload[minHeaderSize+optSize:] switch p.StreamType { - case StreamTypeH264: + case StreamTypeH264, StreamTypeH265: var ts uint32 const hasPTS = 0b1000_0000 @@ -125,7 +134,7 @@ func (p *PES) GetPacket() (pkt *rtp.Packet) { p.Payload = nil case ModeStream: - payload, i := h264.DecodeStream(p.Payload) + payload, i := p.decodeStream(p.Payload) if payload == nil { return } @@ -137,7 +146,7 @@ func (p *PES) GetPacket() (pkt *rtp.Packet) { pkt = &rtp.Packet{ Header: rtp.Header{ PayloadType: p.StreamType, - Timestamp: uint32(time.Duration(time.Now().UnixNano()) * 90000 / time.Second), + Timestamp: core.Now90000(), }, Payload: payload, } diff --git a/pkg/mpegts/reader.go b/pkg/mpegts/reader.go index 66607992..c38b35dd 100644 --- a/pkg/mpegts/reader.go +++ b/pkg/mpegts/reader.go @@ -129,6 +129,14 @@ func (r *Reader) GetPacket() *rtp.Packet { return nil } +func (r *Reader) GetStreamTypes() []byte { + types := make([]byte, 0, len(r.pes)) + for _, pes := range r.pes { + types = append(types, pes.StreamType) + } + return types +} + // Sync - search sync byte func (r *Reader) Sync() bool { // drop previous readed packet diff --git a/pkg/nest/api.go b/pkg/nest/api.go new file mode 100644 index 00000000..9c7f4546 --- /dev/null +++ b/pkg/nest/api.go @@ -0,0 +1,205 @@ +package nest + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +type API struct { + Token string + ExpiresAt time.Time +} + +type Auth struct { + AccessToken string +} + +var cache = map[string]*API{} +var cacheMu sync.Mutex + +func NewAPI(clientID, clientSecret, refreshToken string) (*API, error) { + cacheMu.Lock() + defer cacheMu.Unlock() + + key := clientID + ":" + clientSecret + ":" + refreshToken + now := time.Now() + + if api := cache[key]; api != nil && now.Before(api.ExpiresAt) { + return api, nil + } + + data := url.Values{ + "grant_type": []string{"refresh_token"}, + "client_id": []string{clientID}, + "client_secret": []string{clientSecret}, + "refresh_token": []string{refreshToken}, + } + + client := &http.Client{Timeout: time.Second * 5000} + res, err := client.PostForm("https://www.googleapis.com/oauth2/v4/token", data) + if err != nil { + return nil, err + } + if res.StatusCode != 200 { + return nil, errors.New("nest: wrong status: " + res.Status) + } + + var resv struct { + AccessToken string `json:"access_token"` + ExpiresIn time.Duration `json:"expires_in"` + Scope string `json:"scope"` + TokenType string `json:"token_type"` + } + + if err = json.NewDecoder(res.Body).Decode(&resv); err != nil { + return nil, err + } + + api := &API{ + Token: resv.AccessToken, + ExpiresAt: now.Add(resv.ExpiresIn * time.Second), + } + + cache[key] = api + + return api, nil +} + +func (a *API) GetDevices(projectID string) (map[string]string, error) { + uri := "https://smartdevicemanagement.googleapis.com/v1/enterprises/" + projectID + "/devices" + req, err := http.NewRequest("GET", uri, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+a.Token) + + client := &http.Client{Timeout: time.Second * 5000} + res, err := client.Do(req) + if err != nil { + return nil, err + } + + if res.StatusCode != 200 { + return nil, errors.New("nest: wrong status: " + res.Status) + } + + var resv struct { + Devices []Device + } + + if err = json.NewDecoder(res.Body).Decode(&resv); err != nil { + return nil, err + } + + devices := map[string]string{} + + for _, device := range resv.Devices { + if len(device.Traits.SdmDevicesTraitsCameraLiveStream.SupportedProtocols) == 0 { + continue + } + + if device.Traits.SdmDevicesTraitsCameraLiveStream.SupportedProtocols[0] != "WEB_RTC" { + continue + } + + i := strings.LastIndexByte(device.Name, '/') + if i <= 0 { + continue + } + + name := device.Traits.SdmDevicesTraitsInfo.CustomName + devices[name] = device.Name[i+1:] + } + + return devices, nil +} + +func (a *API) ExchangeSDP(projectID, deviceID, offer string) (string, error) { + var reqv struct { + Command string `json:"command"` + Params struct { + Offer string `json:"offerSdp"` + } `json:"params"` + } + reqv.Command = "sdm.devices.commands.CameraLiveStream.GenerateWebRtcStream" + reqv.Params.Offer = offer + + b, err := json.Marshal(reqv) + if err != nil { + return "", err + } + + uri := "https://smartdevicemanagement.googleapis.com/v1/enterprises/" + + projectID + "/devices/" + deviceID + ":executeCommand" + req, err := http.NewRequest("POST", uri, bytes.NewReader(b)) + if err != nil { + return "", err + } + + req.Header.Set("Authorization", "Bearer "+a.Token) + + client := &http.Client{Timeout: time.Second * 5000} + res, err := client.Do(req) + if err != nil { + return "", err + } + + if res.StatusCode != 200 { + return "", errors.New("nest: wrong status: " + res.Status) + } + + var resv struct { + Results struct { + Answer string `json:"answerSdp"` + ExpiresAt time.Time `json:"expiresAt"` + MediaSessionId string `json:"mediaSessionId"` + } `json:"results"` + } + + if err = json.NewDecoder(res.Body).Decode(&resv); err != nil { + return "", err + } + + return resv.Results.Answer, nil +} + +type Device struct { + Name string `json:"name"` + Type string `json:"type"` + //Assignee string `json:"assignee"` + Traits struct { + SdmDevicesTraitsInfo struct { + CustomName string `json:"customName"` + } `json:"sdm.devices.traits.Info"` + SdmDevicesTraitsCameraLiveStream struct { + VideoCodecs []string `json:"videoCodecs"` + AudioCodecs []string `json:"audioCodecs"` + SupportedProtocols []string `json:"supportedProtocols"` + } `json:"sdm.devices.traits.CameraLiveStream"` + //SdmDevicesTraitsCameraImage struct { + // MaxImageResolution struct { + // Width int `json:"width"` + // Height int `json:"height"` + // } `json:"maxImageResolution"` + //} `json:"sdm.devices.traits.CameraImage"` + //SdmDevicesTraitsCameraPerson struct { + //} `json:"sdm.devices.traits.CameraPerson"` + //SdmDevicesTraitsCameraMotion struct { + //} `json:"sdm.devices.traits.CameraMotion"` + //SdmDevicesTraitsDoorbellChime struct { + //} `json:"sdm.devices.traits.DoorbellChime"` + //SdmDevicesTraitsCameraClipPreview struct { + //} `json:"sdm.devices.traits.CameraClipPreview"` + } `json:"traits"` + //ParentRelations []struct { + // Parent string `json:"parent"` + // DisplayName string `json:"displayName"` + //} `json:"parentRelations"` +} diff --git a/pkg/nest/client.go b/pkg/nest/client.go new file mode 100644 index 00000000..5e8cad3a --- /dev/null +++ b/pkg/nest/client.go @@ -0,0 +1,101 @@ +package nest + +import ( + "errors" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/webrtc" + pion "github.com/pion/webrtc/v3" + "net/url" +) + +type Client struct { + conn *webrtc.Conn +} + +func NewClient(rawURL string) (*Client, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + query := u.Query() + cliendID := query.Get("client_id") + cliendSecret := query.Get("client_secret") + refreshToken := query.Get("refresh_token") + projectID := query.Get("project_id") + deviceID := query.Get("device_id") + + if cliendID == "" || cliendSecret == "" || refreshToken == "" || projectID == "" || deviceID == "" { + return nil, errors.New("nest: wrong query") + } + + nestAPI, err := NewAPI(cliendID, cliendSecret, refreshToken) + if err != nil { + return nil, err + } + + rtcAPI, err := webrtc.NewAPI("") + if err != nil { + return nil, err + } + + conf := pion.Configuration{} + pc, err := rtcAPI.NewPeerConnection(conf) + if err != nil { + return nil, err + } + + conn := webrtc.NewConn(pc) + conn.Desc = "Nest" + conn.Mode = core.ModeActiveProducer + + // https://developers.google.com/nest/device-access/traits/device/camera-live-stream#generatewebrtcstream-request-fields + medias := []*core.Media{ + {Kind: core.KindAudio, Direction: core.DirectionRecvonly}, + {Kind: core.KindVideo, Direction: core.DirectionRecvonly}, + {Kind: "app"}, // important for Nest + } + + // 3. Create offer with candidates + offer, err := conn.CreateCompleteOffer(medias) + if err != nil { + return nil, err + } + + // 4. Exchange SDP via Hass + answer, err := nestAPI.ExchangeSDP(projectID, deviceID, offer) + if err != nil { + return nil, err + } + + // 5. Set answer with remote medias + if err = conn.SetAnswer(answer); err != nil { + return nil, err + } + + return &Client{conn: conn}, nil +} + +func (c *Client) GetMedias() []*core.Media { + return c.conn.GetMedias() +} + +func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) { + return c.conn.GetTrack(media, codec) +} + +func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { + return c.conn.AddTrack(media, codec, track) +} + +func (c *Client) Start() error { + return c.conn.Start() +} + +func (c *Client) Stop() error { + return c.conn.Stop() +} + +func (c *Client) MarshalJSON() ([]byte, error) { + return c.conn.MarshalJSON() +} diff --git a/pkg/onvif/client.go b/pkg/onvif/client.go new file mode 100644 index 00000000..090e9ef2 --- /dev/null +++ b/pkg/onvif/client.go @@ -0,0 +1,257 @@ +package onvif + +import ( + "bytes" + "crypto/sha1" + "encoding/base64" + "errors" + "github.com/AlexxIT/go2rtc/pkg/core" + "html" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" +) + +const PathDevice = "/onvif/device_service" + +type Client struct { + url *url.URL + + deviceURL string + mediaURL string + imaginURL string +} + +func NewClient(rawURL string) (*Client, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + baseURL := "http://" + u.Host + + client := &Client{url: u} + if u.Path == "" { + client.deviceURL = baseURL + PathDevice + } else { + client.deviceURL = baseURL + u.Path + } + + b, err := client.GetCapabilities() + if err != nil { + return nil, err + } + + client.mediaURL = FindTagValue(b, "Media.+?XAddr") + client.imaginURL = FindTagValue(b, "Imaging.+?XAddr") + + return client, nil +} + +func (c *Client) GetURI() (string, error) { + query := c.url.Query() + + token := query.Get("subtype") + + // support empty + if i := atoi(token); i >= 0 { + tokens, err := c.GetProfilesTokens() + if err != nil { + return "", err + } + if i >= len(tokens) { + return "", errors.New("onvif: wrong subtype") + } + token = tokens[i] + } + + getUri := c.GetStreamUri + if query.Has("snapshot") { + getUri = c.GetSnapshotUri + } + + b, err := getUri(token) + if err != nil { + return "", err + } + + uri := FindTagValue(b, "Uri") + uri = html.UnescapeString(uri) + + u, err := url.Parse(uri) + if err != nil { + return "", err + } + + if u.User == nil && c.url.User != nil { + u.User = c.url.User + } + + return u.String(), nil +} + +func (c *Client) GetName() (string, error) { + b, err := c.GetDeviceInformation() + if err != nil { + return "", err + } + + return FindTagValue(b, "Manufacturer") + " " + FindTagValue(b, "Model"), nil +} + +func (c *Client) GetProfilesTokens() ([]string, error) { + b, err := c.GetProfiles() + if err != nil { + return nil, err + } + + var tokens []string + + re := regexp.MustCompile(`Profiles.+?token="([^"]+)`) + for _, s := range re.FindAllStringSubmatch(string(b), 10) { + tokens = append(tokens, s[1]) + } + + return tokens, nil +} + +func (c *Client) HasSnapshots() bool { + b, err := c.GetServiceCapabilities() + if err != nil { + return false + } + return strings.Contains(string(b), `SnapshotUri="true"`) +} + +func (c *Client) GetCapabilities() ([]byte, error) { + return c.Request( + c.deviceURL, + ` + All +`, + ) +} + +func (c *Client) GetNetworkInterfaces() ([]byte, error) { + return c.Request( + c.deviceURL, ``, + ) +} + +func (c *Client) GetDeviceInformation() ([]byte, error) { + return c.Request( + c.deviceURL, ``, + ) +} + +func (c *Client) GetProfiles() ([]byte, error) { + return c.Request( + c.mediaURL, ``, + ) +} + +func (c *Client) GetStreamUri(token string) ([]byte, error) { + return c.Request( + c.mediaURL, + ` + + RTP-Unicast + RTSP + + `+token+` +`, + ) +} + +func (c *Client) GetSnapshotUri(token string) ([]byte, error) { + return c.Request( + c.imaginURL, + ` + `+token+` +`, + ) +} + +func (c *Client) GetSystemDateAndTime() ([]byte, error) { + return c.Request( + c.deviceURL, ``, + ) +} + +func (c *Client) GetServiceCapabilities() ([]byte, error) { + // some cameras answer GetServiceCapabilities for media only for path = "/onvif/media" + return c.Request( + c.mediaURL, ``, + ) +} + +func (c *Client) SystemReboot() ([]byte, error) { + return c.Request( + c.deviceURL, ``, + ) +} + +func (c *Client) GetServices() ([]byte, error) { + return c.Request( + c.deviceURL, ` + true +`, + ) +} + +func (c *Client) GetScopes() ([]byte, error) { + return c.Request( + c.deviceURL, ``, + ) +} + +func (c *Client) Request(url, body string) ([]byte, error) { + if url == "" { + return nil, errors.New("onvif: unsupported service") + } + + buf := bytes.NewBuffer(nil) + buf.WriteString( + ``, + ) + + if user := c.url.User; user != nil { + nonce := core.RandString(16, 36) + created := time.Now().UTC().Format(time.RFC3339Nano) + pass, _ := user.Password() + + h := sha1.New() + h.Write([]byte(nonce + created + pass)) + + buf.WriteString(` + + +` + user.Username() + ` +` + base64.StdEncoding.EncodeToString(h.Sum(nil)) + ` +` + base64.StdEncoding.EncodeToString([]byte(nonce)) + ` +` + created + ` + + +`) + } + + buf.WriteString(`` + body + ``) + + client := &http.Client{Timeout: time.Second * 5000} + res, err := client.Post(url, `application/soap+xml;charset=utf-8`, buf) + if err != nil { + return nil, err + } + + // need to close body with eny response status + b, err := io.ReadAll(res.Body) + + if err == nil && res.StatusCode != http.StatusOK { + err = errors.New("onvif: " + res.Status + " for " + url) + } + + return b, err +} diff --git a/pkg/onvif/helpers.go b/pkg/onvif/helpers.go new file mode 100644 index 00000000..7925e1ec --- /dev/null +++ b/pkg/onvif/helpers.go @@ -0,0 +1,105 @@ +package onvif + +import ( + "github.com/AlexxIT/go2rtc/pkg/core" + "net" + "regexp" + "strconv" + "strings" + "time" +) + +func FindTagValue(b []byte, tag string) string { + re := regexp.MustCompile(`<[^/>]*` + tag + `[^>]*>([^<]+)`) + m := re.FindSubmatch(b) + if len(m) != 2 { + return "" + } + return string(m[1]) +} + +// UUID - generate something like 44302cbf-0d18-4feb-79b3-33b575263da3 +func UUID() string { + s := core.RandString(32, 16) + return s[:8] + "-" + s[8:12] + "-" + s[12:16] + "-" + s[16:20] + "-" + s[20:] +} + +func DiscoveryStreamingURLs() ([]string, error) { + conn, err := net.ListenUDP("udp4", nil) + if err != nil { + return nil, err + } + + // https://www.onvif.org/wp-content/uploads/2016/12/ONVIF_Feature_Discovery_Specification_16.07.pdf + // 5.3 Discovery Procedure: + msg := ` + + + http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe + urn:uuid:` + UUID() + ` + urn:schemas-xmlsoap-org:ws:2005:04:discovery + + + + + + + +` + + addr := &net.UDPAddr{ + IP: net.IP{239, 255, 255, 250}, + Port: 3702, + } + + if _, err = conn.WriteTo([]byte(msg), addr); err != nil { + return nil, err + } + + if err = conn.SetReadDeadline(time.Now().Add(time.Second * 3)); err != nil { + return nil, err + } + + var urls []string + + b := make([]byte, 8192) + for { + n, addr, err := conn.ReadFromUDP(b) + if err != nil { + break + } + + //log.Printf("[onvif] discovery response addr=%s:\n%s", addr, b[:n]) + + // ignore printers, etc + if !strings.Contains(string(b[:n]), "onvif") { + continue + } + + url := FindTagValue(b[:n], "XAddrs") + if url == "" { + continue + } + + // fix some buggy cameras + // http://0.0.0.0:8080/onvif/device_service + if strings.HasPrefix(url, "http://0.0.0.0") { + url = "http://" + addr.IP.String() + url[14:] + } + + urls = append(urls, url) + } + + return urls, nil +} + +func atoi(s string) int { + if s == "" { + return 0 + } + i, err := strconv.Atoi(s) + if err != nil { + return -1 + } + return i +} diff --git a/pkg/onvif/server.go b/pkg/onvif/server.go new file mode 100644 index 00000000..f8f2883c --- /dev/null +++ b/pkg/onvif/server.go @@ -0,0 +1,204 @@ +package onvif + +import ( + "bytes" + "fmt" + "regexp" + "strconv" + "time" +) + +const ( + ActionGetCapabilities = "GetCapabilities" + ActionGetSystemDateAndTime = "GetSystemDateAndTime" + ActionGetNetworkInterfaces = "GetNetworkInterfaces" + ActionGetDeviceInformation = "GetDeviceInformation" + ActionGetServiceCapabilities = "GetServiceCapabilities" + ActionGetProfiles = "GetProfiles" + ActionGetStreamUri = "GetStreamUri" + ActionSystemReboot = "SystemReboot" + + ActionGetServices = "GetServices" + ActionGetScopes = "GetScopes" + ActionGetVideoSources = "GetVideoSources" + ActionGetAudioSources = "GetAudioSources" + ActionGetVideoSourceConfigurations = "GetVideoSourceConfigurations" + ActionGetAudioSourceConfigurations = "GetAudioSourceConfigurations" + ActionGetVideoEncoderConfigurations = "GetVideoEncoderConfigurations" + ActionGetAudioEncoderConfigurations = "GetAudioEncoderConfigurations" +) + +func GetRequestAction(b []byte) string { + // + // + re := regexp.MustCompile(`Body[^<]+<([^ />]+)`) + m := re.FindSubmatch(b) + if len(m) != 2 { + return "" + } + if i := bytes.IndexByte(m[1], ':'); i > 0 { + return string(m[1][i+1:]) + } + return string(m[1]) +} + +func GetCapabilitiesResponse(host string) string { + return ` + + + + + + http://` + host + `/onvif/device_service + + + http://` + host + `/onvif/media_service + + false + false + true + + + + + +` +} + +func GetSystemDateAndTimeResponse() string { + loc := time.Now() + utc := loc.UTC() + + return fmt.Sprintf(` + + + + + NTP + false + + GMT%s + + + + %d + %d + %d + + + %d + %d + %d + + + + + %d + %d + %d + + + %d + %d + %d + + + + + +`, + loc.Format("-07:00"), + utc.Hour(), utc.Minute(), utc.Second(), utc.Year(), utc.Month(), utc.Day(), + loc.Hour(), loc.Minute(), loc.Second(), loc.Year(), loc.Month(), loc.Day(), + ) +} + +func GetNetworkInterfacesResponse() string { + return ` + + + + +` +} + +func GetDeviceInformationResponse(manuf, model, firmware, serial string) string { + return ` + + + + ` + manuf + ` + ` + model + ` + ` + firmware + ` + ` + serial + ` + 1.00 + + +` +} + +func GetServiceCapabilitiesResponse() string { + return ` + + + + + + + + +` +} + +func SystemRebootResponse() string { + return ` + + + + system reboot in 1 second... + + +` +} + +func GetProfilesResponse(names []string) string { + buf := bytes.NewBuffer(nil) + buf.WriteString(` + + + `) + + for i, name := range names { + buf.WriteString(` + + ` + name + ` + + H264 + + 1920 + 1080 + + + `) + } + + buf.WriteString(` + + +`) + + return buf.String() +} + +func GetStreamUriResponse(uri string) string { + return ` + + + + + ` + uri + ` + + + +` +} diff --git a/pkg/pcm/flac.go b/pkg/pcm/flac.go new file mode 100644 index 00000000..054746d1 --- /dev/null +++ b/pkg/pcm/flac.go @@ -0,0 +1,146 @@ +// Package pcm - support raw (verbatim) PCM 16 bit in the FLAC container: +// - only 1 channel +// - only 16 bit per sample +// - only 8000, 16000, 24000, 48000 sample rate +package pcm + +import ( + "encoding/binary" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" + "github.com/sigurn/crc16" + "github.com/sigurn/crc8" + "unicode/utf8" +) + +func FLACHeader(magic bool, sampleRate uint32) []byte { + b := make([]byte, 42) + + if magic { + copy(b, "fLaC") // [0..3] + } + + // https://xiph.org/flac/format.html#metadata_block_header + b[4] = 0x80 // [4] lastMetadata=1 (1 bit), blockType=0 - STREAMINFO (7 bit) + b[7] = 0x22 // [5..7] blockLength=34 (24 bit) + + // Important for Apple QuickTime player: + // 1. Both values should be same + // 2. Maximum value = 32768 + binary.BigEndian.PutUint16(b[8:], 32768) // [8..9] info.BlockSizeMin=16 (16 bit) + binary.BigEndian.PutUint16(b[10:], 32768) // [10..11] info.BlockSizeMin=65535 (16 bit) + + // [12..14] info.FrameSizeMin=0 (24 bit) + // [15..17] info.FrameSizeMax=0 (24 bit) + + b[18] = byte(sampleRate >> 12) + b[19] = byte(sampleRate >> 4) + b[20] = byte(sampleRate << 4) // [18..20] info.SampleRate=8000 (20 bit), info.NChannels=1-1 (3 bit) + + b[21] = 0xF0 // [21..25] info.BitsPerSample=16-1 (5 bit), info.NSamples (36 bit) + + // [26..41] MD5sum (16 bytes) + + return b +} + +var table8 *crc8.Table +var table16 *crc16.Table + +func FLACEncoder(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { + if codec.Channels >= 2 { + return nil + } + + var sr byte + switch codec.ClockRate { + case 8000: + sr = 0b0100 + case 16000: + sr = 0b0101 + case 22050: + sr = 0b0110 + case 24000: + sr = 0b0111 + case 32000: + sr = 0b1000 + case 44100: + sr = 0b1001 + case 48000: + sr = 0b1010 + case 96000: + sr = 0b1011 + default: + return nil + } + + if table8 == nil { + table8 = crc8.MakeTable(crc8.CRC8) + } + if table16 == nil { + table16 = crc16.MakeTable(crc16.CRC16_BUYPASS) + } + + var sampleNumber int32 + + return func(packet *rtp.Packet) { + samples := uint16(len(packet.Payload)) + + if codec.Name == core.CodecPCM { + samples /= 2 + } + + // https://xiph.org/flac/format.html#frame_header + buf := make([]byte, samples*2+30) + + // 1. Frame header + buf[0] = 0xFF + buf[1] = 0xF9 // [0..1] syncCode=0xFFF8 - reserved (15 bit), blockStrategy=1 - variable-blocksize (1 bit) + buf[2] = 0x70 | sr // blockSizeType=7 (4 bit), sampleRate=4 - 8000 (4 bit) + buf[3] = 0x08 // channels=1-1 (4 bit), sampleSize=4 - 16 (3 bit), reserved=0 (1 bit) + + n := 4 + utf8.EncodeRune(buf[4:], sampleNumber) // 4 bytes max + sampleNumber += int32(samples) + + // this is wrong but very simple frame block size value + binary.BigEndian.PutUint16(buf[n:], samples-1) + n += 2 + + buf[n] = crc8.Checksum(buf[:n], table8) + n += 1 + + // 2. Subframe header + buf[n] = 0x02 // padding=0 (1 bit), subframeType=1 - verbatim (6 bit), wastedFlag=0 (1 bit) + n += 1 + + // 3. Subframe + switch codec.Name { + case core.CodecPCMA: + for _, b := range packet.Payload { + s16 := PCMAtoPCM(b) + buf[n] = byte(s16 >> 8) + buf[n+1] = byte(s16) + n += 2 + } + case core.CodecPCMU: + for _, b := range packet.Payload { + s16 := PCMUtoPCM(b) + buf[n] = byte(s16 >> 8) + buf[n+1] = byte(s16) + n += 2 + } + case core.CodecPCM: + n += copy(buf[n:], packet.Payload) + } + + // 4. Frame footer + crc := crc16.Checksum(buf[:n], table16) + binary.BigEndian.PutUint16(buf[n:], crc) + n += 2 + + clone := *packet + clone.Payload = buf[:n] + + handler(&clone) + } +} diff --git a/pkg/pcm/helpers.go b/pkg/pcm/helpers.go new file mode 100644 index 00000000..d873f7e3 --- /dev/null +++ b/pkg/pcm/helpers.go @@ -0,0 +1,35 @@ +package pcm + +import ( + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +func RepackBackchannel(handler core.HandlerFunc) core.HandlerFunc { + var buf []byte + var seq uint16 + + return func(packet *rtp.Packet) { + buf = append(buf, packet.Payload...) + if len(buf) < 1024 { + return + } + + pkt := &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + Marker: true, // should be true + PayloadType: packet.PayloadType, // will be owerwriten + SequenceNumber: seq, + Timestamp: 0, // should be always zero + SSRC: packet.SSRC, + }, + Payload: buf[:1024], + } + + handler(pkt) + + buf = buf[1024:] + seq++ + } +} diff --git a/pkg/pcm/pcm.go b/pkg/pcm/pcm.go new file mode 100644 index 00000000..717a1450 --- /dev/null +++ b/pkg/pcm/pcm.go @@ -0,0 +1,116 @@ +package pcm + +import ( + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" +) + +func Resample(codec *core.Codec, sampleRate uint32, handler core.HandlerFunc) core.HandlerFunc { + n := float32(codec.ClockRate) / float32(sampleRate) + + switch codec.Name { + case core.CodecPCMA: + return DownsampleByte(PCMAtoPCM, PCMtoPCMA, n, handler) + case core.CodecPCMU: + return DownsampleByte(PCMUtoPCM, PCMtoPCMU, n, handler) + case core.CodecPCM: + if n == 1 { + return ResamplePCM(PCMtoPCMA, handler) + } + return DownsamplePCM(PCMtoPCMA, n, handler) + } + + panic(core.Caller()) +} + +func DownsampleByte( + toPCM func(byte) int16, fromPCM func(int16) byte, n float32, handler core.HandlerFunc, +) core.HandlerFunc { + var sampleN, sampleSum float32 + var ts uint32 + + return func(packet *rtp.Packet) { + samples := len(packet.Payload) + newLen := uint32((float32(samples) + sampleN) / n) + + oldSamples := packet.Payload + newSamples := make([]byte, newLen) + + var i int + for _, sample := range oldSamples { + sampleSum += float32(toPCM(sample)) + if sampleN++; sampleN >= n { + newSamples[i] = fromPCM(int16(sampleSum / n)) + i++ + + sampleSum = 0 + sampleN -= n + } + } + + ts += newLen + + clone := *packet + clone.Payload = newSamples + clone.Timestamp = ts + handler(&clone) + } +} + +func ResamplePCM(fromPCM func(int16) byte, handler core.HandlerFunc) core.HandlerFunc { + var ts uint32 + + return func(packet *rtp.Packet) { + len1 := len(packet.Payload) + len2 := len1 / 2 + + oldSamples := packet.Payload + newSamples := make([]byte, len2) + + var i2 int + for i1 := 0; i1 < len1; i1 += 2 { + sample := int16(uint16(oldSamples[i1])<<8 | uint16(oldSamples[i1+1])) + newSamples[i2] = fromPCM(sample) + i2++ + } + + ts += uint32(len2) + + clone := *packet + clone.Payload = newSamples + clone.Timestamp = ts + handler(&clone) + } +} + +func DownsamplePCM(fromPCM func(int16) byte, n float32, handler core.HandlerFunc) core.HandlerFunc { + var sampleN, sampleSum float32 + var ts uint32 + + return func(packet *rtp.Packet) { + samples := len(packet.Payload) / 2 + newLen := uint32((float32(samples) + sampleN) / n) + + oldSamples := packet.Payload + newSamples := make([]byte, newLen) + + var i2 int + for i1 := 0; i1 < len(packet.Payload); i1 += 2 { + sampleSum += float32(int16(uint16(oldSamples[i1])<<8 | uint16(oldSamples[i1+1]))) + if sampleN++; sampleN >= n { + newSamples[i2] = fromPCM(int16(sampleSum / n)) + i2++ + + sampleSum = 0 + sampleN -= n + } + } + + ts += newLen + + clone := *packet + clone.Payload = newSamples + clone.Timestamp = ts + handler(&clone) + } +} diff --git a/pkg/pcm/pcma.go b/pkg/pcm/pcma.go new file mode 100644 index 00000000..3e1ef112 --- /dev/null +++ b/pkg/pcm/pcma.go @@ -0,0 +1,53 @@ +// Package pcm +// https://www.codeproject.com/Articles/14237/Using-the-G711-standard +package pcm + +const alawMax = 0x7FFF + +func PCMAtoPCM(alaw byte) int16 { + alaw ^= 0xD5 + + data := int16(((alaw & 0x0F) << 4) + 8) + exponent := (alaw & 0x70) >> 4 + + if exponent != 0 { + data |= 0x100 + } + + if exponent > 1 { + data <<= exponent - 1 + } + + // sign + if alaw&0x80 == 0 { + return data + } else { + return -data + } +} + +func PCMtoPCMA(pcm int16) byte { + var alaw byte + + if pcm < 0 { + pcm = -pcm + alaw = 0x80 + } + + if pcm > alawMax { + pcm = alawMax + } + + exponent := byte(7) + for expMask := int16(0x4000); (pcm&expMask) == 0 && exponent > 0; expMask >>= 1 { + exponent-- + } + + if exponent == 0 { + alaw |= byte(pcm>>4) & 0x0F + } else { + alaw |= (exponent << 4) | (byte(pcm>>(exponent+3)) & 0x0F) + } + + return alaw ^ 0xD5 +} diff --git a/pkg/pcm/pcmu.go b/pkg/pcm/pcmu.go new file mode 100644 index 00000000..954d8a99 --- /dev/null +++ b/pkg/pcm/pcmu.go @@ -0,0 +1,51 @@ +// Package pcm +// https://www.codeproject.com/Articles/14237/Using-the-G711-standard +package pcm + +const bias = 0x84 // 132 or 1000 0100 +const ulawMax = alawMax - bias + +func PCMUtoPCM(ulaw byte) int16 { + ulaw = ^ulaw + + exponent := (ulaw & 0x70) >> 4 + data := (int16((((ulaw&0x0F)|0x10)<<1)+1) << (exponent + 2)) - bias + + // sign + if ulaw&0x80 == 0 { + return data + } else if data == 0 { + return -1 + } else { + return -data + } +} + +func PCMtoPCMU(pcm int16) byte { + var ulaw byte + + if pcm < 0 { + pcm = -pcm + ulaw = 0x80 + } + + if pcm > ulawMax { + pcm = ulawMax + } + + pcm += bias + + exponent := byte(7) + for expMask := int16(0x4000); (pcm & expMask) == 0; expMask >>= 1 { + exponent-- + } + + // mantisa + ulaw |= byte(pcm>>(exponent+3)) & 0x0F + + if exponent > 0 { + ulaw |= exponent << 4 + } + + return ^ulaw +} diff --git a/pkg/pcm/v1/pcm.go b/pkg/pcm/v1/pcm.go new file mode 100644 index 00000000..e1652350 --- /dev/null +++ b/pkg/pcm/v1/pcm.go @@ -0,0 +1,155 @@ +// Package v1 +// http://web.archive.org/web/20110719132013/http://hazelware.luggle.com/tutorials/mulawcompression.html +package v1 + +const cBias = 0x84 +const cClip = 32635 + +var MuLawCompressTable = [256]byte{ + 0, 0, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, + 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, + 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, + 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, + 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, +} + +func LinearToMuLawSample(sample int16) byte { + sign := byte(sample>>8) & 0x80 + if sign != 0 { + sample = -sample + } + + if sample > cClip { + sample = cClip + } + sample = sample + cBias + + exponent := MuLawCompressTable[(sample>>7)&0xFF] + mantissa := byte(sample>>(exponent+3)) & 0x0F + + compressedByte := ^(sign | (exponent << 4) | mantissa) + + return compressedByte +} + +var ALawCompressTable = [128]byte{ + 1, 1, 2, 2, 3, 3, 3, 3, + 4, 4, 4, 4, 4, 4, 4, 4, + 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, + 6, 6, 6, 6, 6, 6, 6, 6, + 6, 6, 6, 6, 6, 6, 6, 6, + 6, 6, 6, 6, 6, 6, 6, 6, + 6, 6, 6, 6, 6, 6, 6, 6, + 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, +} + +func LinearToALawSample(sample int16) byte { + sign := byte((^sample)>>8) & 0x80 + if sign == 0 { + sample = -sample + } + + if sample > cClip { + sample = cClip + } + + var compressedByte byte + if sample >= 256 { + exponent := ALawCompressTable[(sample>>8)&0x7F] + mantissa := byte(sample>>(exponent+3)) & 0x0F + compressedByte = (exponent << 4) | mantissa + } else { + compressedByte = byte(sample >> 4) + } + compressedByte ^= sign ^ 0x55 + return compressedByte +} + +var MuLawDecompressTable = [256]int16{ + -32124, -31100, -30076, -29052, -28028, -27004, -25980, -24956, + -23932, -22908, -21884, -20860, -19836, -18812, -17788, -16764, + -15996, -15484, -14972, -14460, -13948, -13436, -12924, -12412, + -11900, -11388, -10876, -10364, -9852, -9340, -8828, -8316, + -7932, -7676, -7420, -7164, -6908, -6652, -6396, -6140, + -5884, -5628, -5372, -5116, -4860, -4604, -4348, -4092, + -3900, -3772, -3644, -3516, -3388, -3260, -3132, -3004, + -2876, -2748, -2620, -2492, -2364, -2236, -2108, -1980, + -1884, -1820, -1756, -1692, -1628, -1564, -1500, -1436, + -1372, -1308, -1244, -1180, -1116, -1052, -988, -924, + -876, -844, -812, -780, -748, -716, -684, -652, + -620, -588, -556, -524, -492, -460, -428, -396, + -372, -356, -340, -324, -308, -292, -276, -260, + -244, -228, -212, -196, -180, -164, -148, -132, + -120, -112, -104, -96, -88, -80, -72, -64, + -56, -48, -40, -32, -24, -16, -8, -1, + 32124, 31100, 30076, 29052, 28028, 27004, 25980, 24956, + 23932, 22908, 21884, 20860, 19836, 18812, 17788, 16764, + 15996, 15484, 14972, 14460, 13948, 13436, 12924, 12412, + 11900, 11388, 10876, 10364, 9852, 9340, 8828, 8316, + 7932, 7676, 7420, 7164, 6908, 6652, 6396, 6140, + 5884, 5628, 5372, 5116, 4860, 4604, 4348, 4092, + 3900, 3772, 3644, 3516, 3388, 3260, 3132, 3004, + 2876, 2748, 2620, 2492, 2364, 2236, 2108, 1980, + 1884, 1820, 1756, 1692, 1628, 1564, 1500, 1436, + 1372, 1308, 1244, 1180, 1116, 1052, 988, 924, + 876, 844, 812, 780, 748, 716, 684, 652, + 620, 588, 556, 524, 492, 460, 428, 396, + 372, 356, 340, 324, 308, 292, 276, 260, + 244, 228, 212, 196, 180, 164, 148, 132, + 120, 112, 104, 96, 88, 80, 72, 64, + 56, 48, 40, 32, 24, 16, 8, 0, +} + +var ALawDecompressTable = [256]int16{ + -5504, -5248, -6016, -5760, -4480, -4224, -4992, -4736, + -7552, -7296, -8064, -7808, -6528, -6272, -7040, -6784, + -2752, -2624, -3008, -2880, -2240, -2112, -2496, -2368, + -3776, -3648, -4032, -3904, -3264, -3136, -3520, -3392, + -22016, -20992, -24064, -23040, -17920, -16896, -19968, -18944, + -30208, -29184, -32256, -31232, -26112, -25088, -28160, -27136, + -11008, -10496, -12032, -11520, -8960, -8448, -9984, -9472, + -15104, -14592, -16128, -15616, -13056, -12544, -14080, -13568, + -344, -328, -376, -360, -280, -264, -312, -296, + -472, -456, -504, -488, -408, -392, -440, -424, + -88, -72, -120, -104, -24, -8, -56, -40, + -216, -200, -248, -232, -152, -136, -184, -168, + -1376, -1312, -1504, -1440, -1120, -1056, -1248, -1184, + -1888, -1824, -2016, -1952, -1632, -1568, -1760, -1696, + -688, -656, -752, -720, -560, -528, -624, -592, + -944, -912, -1008, -976, -816, -784, -880, -848, + 5504, 5248, 6016, 5760, 4480, 4224, 4992, 4736, + 7552, 7296, 8064, 7808, 6528, 6272, 7040, 6784, + 2752, 2624, 3008, 2880, 2240, 2112, 2496, 2368, + 3776, 3648, 4032, 3904, 3264, 3136, 3520, 3392, + 22016, 20992, 24064, 23040, 17920, 16896, 19968, 18944, + 30208, 29184, 32256, 31232, 26112, 25088, 28160, 27136, + 11008, 10496, 12032, 11520, 8960, 8448, 9984, 9472, + 15104, 14592, 16128, 15616, 13056, 12544, 14080, 13568, + 344, 328, 376, 360, 280, 264, 312, 296, + 472, 456, 504, 488, 408, 392, 440, 424, + 88, 72, 120, 104, 24, 8, 56, 40, + 216, 200, 248, 232, 152, 136, 184, 168, + 1376, 1312, 1504, 1440, 1120, 1056, 1248, 1184, + 1888, 1824, 2016, 1952, 1632, 1568, 1760, 1696, + 688, 656, 752, 720, 560, 528, 624, 592, + 944, 912, 1008, 976, 816, 784, 880, 848, +} diff --git a/pkg/pcm/v1/pcm_test.go b/pkg/pcm/v1/pcm_test.go new file mode 100644 index 00000000..2db5d95c --- /dev/null +++ b/pkg/pcm/v1/pcm_test.go @@ -0,0 +1,39 @@ +package v1 + +import ( + v2 "github.com/AlexxIT/go2rtc/pkg/pcm" + "github.com/stretchr/testify/require" + "testing" +) + +func TestPCMUtoPCM(t *testing.T) { + for pcmu := byte(0); pcmu < 255; pcmu++ { + pcm1 := MuLawDecompressTable[pcmu] + pcm2 := v2.PCMUtoPCM(pcmu) + require.Equal(t, pcm1, pcm2) + } +} + +func TestPCMAtoPCM(t *testing.T) { + for pcma := byte(0); pcma < 255; pcma++ { + pcm1 := ALawDecompressTable[pcma] + pcm2 := v2.PCMAtoPCM(pcma) + require.Equal(t, pcm1, pcm2) + } +} + +func TestPCMtoPCMU(t *testing.T) { + for pcm := int16(-32768); pcm < 32767; pcm++ { + pcmu1 := LinearToMuLawSample(pcm) + pcmu2 := v2.PCMtoPCMU(pcm) + require.Equal(t, pcmu1, pcmu2) + } +} + +func TestPCMtoPCMA(t *testing.T) { + for pcm := int16(-32768); pcm < 32767; pcm++ { + pcma1 := LinearToALawSample(pcm) + pcma2 := v2.PCMtoPCMA(pcm) + require.Equal(t, pcma1, pcma2) + } +} diff --git a/pkg/rtsp/client.go b/pkg/rtsp/client.go index 29757046..a4a3b656 100644 --- a/pkg/rtsp/client.go +++ b/pkg/rtsp/client.go @@ -2,10 +2,9 @@ package rtsp import ( "bufio" - "crypto/tls" "errors" "fmt" - "net" + "github.com/AlexxIT/go2rtc/pkg/tcp/websocket" "net/http" "net/url" "strconv" @@ -16,88 +15,45 @@ import ( "github.com/AlexxIT/go2rtc/pkg/tcp" ) +var Timeout = time.Second * 5 + func NewClient(uri string) *Conn { return &Conn{uri: uri} } func (c *Conn) Dial() (err error) { - if c.URL, err = url.Parse(c.uri); err != nil { + if c.Transport == "" { + c.conn, err = Dial(c.uri) + } else { + c.conn, err = websocket.Dial(c.Transport) + } + + if err != nil { return } - if strings.IndexByte(c.URL.Host, ':') < 0 { - c.URL.Host += ":554" + if c.URL, err = url.Parse(c.uri); err != nil { + return } // remove UserInfo from URL c.auth = tcp.NewAuth(c.URL.User) c.URL.User = nil - c.conn, err = net.DialTimeout("tcp", c.URL.Host, time.Second*5) - if err != nil { - return - } - - var tlsConf *tls.Config - switch c.URL.Scheme { - case "rtsps": - tlsConf = &tls.Config{ServerName: c.URL.Hostname()} - case "rtspx": - c.URL.Scheme = "rtsps" - tlsConf = &tls.Config{InsecureSkipVerify: true} - } - if tlsConf != nil { - tlsConn := tls.Client(c.conn, tlsConf) - if err = tlsConn.Handshake(); err != nil { - return err - } - c.conn = tlsConn - } - c.reader = bufio.NewReader(c.conn) + c.session = "" c.state = StateConn return nil } -// Request sends only Request -func (c *Conn) Request(req *tcp.Request) error { - if req.Proto == "" { - req.Proto = ProtoRTSP - } - - if req.Header == nil { - req.Header = make(map[string][]string) - } - - c.sequence++ - // important to send case sensitive CSeq - // https://github.com/AlexxIT/go2rtc/issues/7 - req.Header["CSeq"] = []string{strconv.Itoa(c.sequence)} - - c.auth.Write(req) - - if c.Session != "" { - req.Header.Set("Session", c.Session) - } - - if req.Body != nil { - val := strconv.Itoa(len(req.Body)) - req.Header.Set("Content-Length", val) - } - - c.Fire(req) - - return req.Write(c.conn) -} - -// Do send Request and receive and process Response +// Do send WriteRequest and receive and process WriteResponse func (c *Conn) Do(req *tcp.Request) (*tcp.Response, error) { - if err := c.Request(req); err != nil { + if err := c.WriteRequest(req); err != nil { return nil, err } - res, err := tcp.ReadResponse(c.reader) + res, err := c.ReadResponse() if err != nil { return nil, err } @@ -127,40 +83,6 @@ func (c *Conn) Do(req *tcp.Request) (*tcp.Response, error) { return res, nil } -func (c *Conn) Response(res *tcp.Response) error { - if res.Proto == "" { - res.Proto = ProtoRTSP - } - - if res.Status == "" { - res.Status = "200 OK" - } - - if res.Header == nil { - res.Header = make(map[string][]string) - } - - if res.Request != nil && res.Request.Header != nil { - seq := res.Request.Header.Get("CSeq") - if seq != "" { - res.Header.Set("CSeq", seq) - } - } - - if c.Session != "" { - res.Header.Set("Session", c.Session) - } - - if res.Body != nil { - val := strconv.Itoa(len(res.Body)) - res.Header.Set("Content-Length", val) - } - - c.Fire(res) - - return res.Write(c.conn) -} - func (c *Conn) Options() error { req := &tcp.Request{Method: MethodOptions, URL: c.URL} @@ -212,11 +134,18 @@ func (c *Conn) Describe() error { } } - c.Medias, err = UnmarshalSDP(res.Body) + medias, err := UnmarshalSDP(res.Body) if err != nil { return err } + // TODO: rewrite more smart + if c.Medias == nil { + c.Medias = medias + } else if len(c.Medias) > len(medias) { + c.Medias = c.Medias[:len(medias)] + } + c.mode = core.ModeActiveProducer return nil @@ -243,33 +172,12 @@ func (c *Conn) Announce() (err error) { return } -func (c *Conn) Setup() error { - for _, media := range c.Medias { - _, err := c.SetupMedia(media, true) - if err != nil { - return err - } - } - - return nil -} - -func (c *Conn) SetupMedia(media *core.Media, first bool) (byte, error) { - // TODO: rewrite recoonection and first flag - if first { - c.stateMu.Lock() - defer c.stateMu.Unlock() - } - - if c.state != StateConn && c.state != StateSetup { - return 0, fmt.Errorf("RTSP SETUP from wrong state: %s", c.state) - } - +func (c *Conn) SetupMedia(media *core.Media) (byte, error) { var transport string // try to use media position as channel number for i, m := range c.Medias { - if m.ID == media.ID { + if m.Equal(media) { transport = fmt.Sprintf( // i - RTP (data channel) // i+1 - RTCP (control channel) @@ -304,38 +212,33 @@ func (c *Conn) SetupMedia(media *core.Media, first bool) (byte, error) { }, } - var res *tcp.Response - res, err = c.Do(req) + res, err := c.Do(req) if err != nil { // some Dahua/Amcrest cameras fail here because two simultaneous // backchannel connections if c.Backchannel { - c.Close() c.Backchannel = false - if err := c.Dial(); err != nil { + if err = c.Reconnect(); err != nil { return 0, err } - if err := c.Describe(); err != nil { - return 0, err - } - - for _, newMedia := range c.Medias { - if newMedia.ID == media.ID { - return c.SetupMedia(newMedia, false) - } - } + return c.SetupMedia(media) } return 0, err } - if c.Session == "" { + if c.session == "" { + // Session: 7116520596809429228 // Session: 216525287999;timeout=60 if s := res.Header.Get("Session"); s != "" { - if j := strings.IndexByte(s, ';'); j > 0 { - s = s[:j] + if i := strings.IndexByte(s, ';'); i > 0 { + c.session = s[:i] + if i = strings.Index(s, "timeout="); i > 0 { + c.keepalive, _ = strconv.Atoi(s[i+8:]) + } + } else { + c.session = s } - c.Session = s } } @@ -353,8 +256,6 @@ func (c *Conn) SetupMedia(media *core.Media, first bool) (byte, error) { } } - c.state = StateSetup - channel := core.Between(transport, "interleaved=", "-") i, err := strconv.Atoi(channel) if err != nil { @@ -365,36 +266,19 @@ func (c *Conn) SetupMedia(media *core.Media, first bool) (byte, error) { } func (c *Conn) Play() (err error) { - c.stateMu.Lock() - defer c.stateMu.Unlock() - - if c.state != StateSetup { - return fmt.Errorf("RTSP PLAY from wrong state: %s", c.state) - } - req := &tcp.Request{Method: MethodPlay, URL: c.URL} - if err = c.Request(req); err == nil { - c.state = StatePlay - } - - return + return c.WriteRequest(req) } func (c *Conn) Teardown() (err error) { // allow TEARDOWN from any state (ex. ANNOUNCE > SETUP) req := &tcp.Request{Method: MethodTeardown, URL: c.URL} - return c.Request(req) + return c.WriteRequest(req) } func (c *Conn) Close() error { - c.stateMu.Lock() - defer c.stateMu.Unlock() - - if c.state == StateNone { - return nil + if c.mode == core.ModeActiveProducer { + _ = c.Teardown() } - - _ = c.Teardown() - c.state = StateNone return c.conn.Close() } diff --git a/pkg/rtsp/client_test.go b/pkg/rtsp/client_test.go new file mode 100644 index 00000000..11bb3049 --- /dev/null +++ b/pkg/rtsp/client_test.go @@ -0,0 +1,94 @@ +package rtsp + +import ( + "github.com/stretchr/testify/require" + "net" + "os" + "testing" + "time" +) + +func TestTimeout(t *testing.T) { + Timeout = time.Millisecond + + ln, err := net.Listen("tcp", "localhost:0") + require.Nil(t, err) + + client := NewClient("rtsp://" + ln.Addr().String() + "/stream") + client.Backchannel = true + + err = client.Dial() + require.Nil(t, err) + + err = client.Describe() + require.ErrorIs(t, err, os.ErrDeadlineExceeded) +} + +func TestMissedControl(t *testing.T) { + Timeout = time.Millisecond + + ln, err := net.Listen("tcp", "localhost:0") + require.Nil(t, err) + + go func() { + conn, err := ln.Accept() + require.Nil(t, err) + + b := make([]byte, 8192) + for { + n, err := conn.Read(b) + require.Nil(t, err) + + req := string(b[:n]) + + switch req[:4] { + case "DESC": + _, _ = conn.Write([]byte(`RTSP/1.0 200 OK +Cseq: 1 +Content-Length: 495 +Content-Type: application/sdp + +v=0 +o=- 1 1 IN IP4 0.0.0.0 +s=go2rtc/1.2.0 +c=IN IP4 0.0.0.0 +t=0 0 +m=audio 0 RTP/AVP 96 +a=rtpmap:96 MPEG4-GENERIC/48000/2 +a=fmtp:96 profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3; config=119056E500 +m=audio 0 RTP/AVP 97 +a=rtpmap:97 OPUS/48000/2 +a=fmtp:97 sprop-stereo=1 +m=video 0 RTP/AVP 98 +a=rtpmap:98 H264/90000 +a=fmtp:98 packetization-mode=1; sprop-parameter-sets=Z2QAKaw0yAeAIn5cBagICAoAAAfQAAE4gdDAAjhAACOEF3lxoYAEcIAARwgu8uFA,aO48MAA=; profile-level-id=640029 +`)) + + case "SETU": + _, _ = conn.Write([]byte(`RTSP/1.0 200 OK +Transport: RTP/AVP/TCP;unicast;interleaved=4-5 +Cseq: 3 +Session: 1 + +`)) + + default: + t.Fail() + } + } + }() + + client := NewClient("rtsp://" + ln.Addr().String() + "/stream") + client.Backchannel = true + + err = client.Dial() + require.Nil(t, err) + + err = client.Describe() + require.Nil(t, err) + require.Len(t, client.Medias, 3) + + ch, err := client.SetupMedia(client.Medias[2], true) + require.Nil(t, err) + require.Equal(t, ch, byte(4)) +} diff --git a/pkg/rtsp/conn.go b/pkg/rtsp/conn.go index 2a0add62..9b23087a 100644 --- a/pkg/rtsp/conn.go +++ b/pkg/rtsp/conn.go @@ -22,23 +22,27 @@ type Conn struct { // public Backchannel bool + PacketSize uint16 SessionName string + Transport string // custom transport support, ex. RTSP over WebSocket Medias []*core.Media - Session string UserAgent string URL *url.URL // internal - auth *tcp.Auth - conn net.Conn - mode core.Mode - state State - stateMu sync.Mutex - reader *bufio.Reader - sequence int - uri string + auth *tcp.Auth + conn net.Conn + keepalive int + mode core.Mode + reader *bufio.Reader + sequence int + session string + uri string + + state State + stateMu sync.Mutex receivers []*core.Receiver senders []*core.Sender @@ -68,13 +72,12 @@ func (s State) String() string { case StateNone: return "NONE" case StateConn: + return "CONN" case StateSetup: - return "SETUP" + return MethodSetup case StatePlay: - return "PLAY" - case StateHandle: - return "HANDLE" + return MethodPlay } return strconv.Itoa(int(s)) } @@ -84,38 +87,24 @@ const ( StateConn StateSetup StatePlay - StateHandle ) func (c *Conn) Handle() (err error) { - c.stateMu.Lock() - - switch c.state { - case StateNone: // Close after PLAY and before Handle is OK (because SETUP after PLAY) - case StatePlay: - c.state = StateHandle - default: - err = fmt.Errorf("RTSP HANDLE from wrong state: %s", c.state) - - c.state = StateNone - _ = c.conn.Close() - } - - ok := c.state == StateHandle - - c.stateMu.Unlock() - - if !ok { - return - } - var timeout time.Duration + var keepaliveDT time.Duration + var keepaliveTS time.Time + switch c.mode { case core.ModeActiveProducer: - // polling frames from remote RTSP Server (ex Camera) - go c.keepalive() + if c.keepalive > 5 { + keepaliveDT = time.Duration(c.keepalive-5) * time.Second + } else { + keepaliveDT = 25 * time.Second + } + keepaliveTS = time.Now().Add(keepaliveDT) + // polling frames from remote RTSP Server (ex Camera) if len(c.receivers) > 0 { // if we receiving video/audio from camera timeout = time.Second * 5 @@ -137,7 +126,9 @@ func (c *Conn) Handle() (err error) { } for c.state != StateNone { - if err = c.conn.SetReadDeadline(time.Now().Add(timeout)); err != nil { + ts := time.Now() + + if err = c.conn.SetReadDeadline(ts.Add(timeout)); err != nil { return } @@ -158,7 +149,7 @@ func (c *Conn) Handle() (err error) { switch string(buf4) { case "RTSP": var res *tcp.Response - if res, err = tcp.ReadResponse(c.reader); err != nil { + if res, err = c.ReadResponse(); err != nil { return } c.Fire(res) @@ -166,13 +157,15 @@ func (c *Conn) Handle() (err error) { case "OPTI", "TEAR", "DESC", "SETU", "PLAY", "PAUS", "RECO", "ANNO", "GET_", "SET_": var req *tcp.Request - if req, err = tcp.ReadRequest(c.reader); err != nil { + if req, err = c.ReadRequest(); err != nil { return } c.Fire(req) continue default: + c.Fire("RTSP wrong input") + for i := 0; ; i++ { // search next start symbol if _, err = c.reader.ReadBytes('$'); err != nil { @@ -204,8 +197,6 @@ func (c *Conn) Handle() (err error) { return fmt.Errorf("RTSP wrong input") } } - - c.Fire("RTSP wrong input") } } else { // hope that the odd channels are always RTCP @@ -254,21 +245,106 @@ func (c *Conn) Handle() (err error) { c.Fire(msg) } + + if keepaliveDT != 0 && ts.After(keepaliveTS) { + req := &tcp.Request{Method: MethodOptions, URL: c.URL} + if err = c.WriteRequest(req); err != nil { + return + } + + keepaliveTS = ts.Add(keepaliveDT) + } } return } -func (c *Conn) keepalive() { - // TODO: rewrite to RTCP - req := &tcp.Request{Method: MethodOptions, URL: c.URL} - for { - time.Sleep(time.Second * 25) - if c.state == StateNone { - return - } - if err := c.Request(req); err != nil { - return +func (c *Conn) WriteRequest(req *tcp.Request) error { + if req.Proto == "" { + req.Proto = ProtoRTSP + } + + if req.Header == nil { + req.Header = make(map[string][]string) + } + + c.sequence++ + // important to send case sensitive CSeq + // https://github.com/AlexxIT/go2rtc/issues/7 + req.Header["CSeq"] = []string{strconv.Itoa(c.sequence)} + + c.auth.Write(req) + + if c.session != "" { + req.Header.Set("Session", c.session) + } + + if req.Body != nil { + val := strconv.Itoa(len(req.Body)) + req.Header.Set("Content-Length", val) + } + + c.Fire(req) + + if err := c.conn.SetWriteDeadline(time.Now().Add(Timeout)); err != nil { + return err + } + + return req.Write(c.conn) +} + +func (c *Conn) ReadRequest() (*tcp.Request, error) { + if err := c.conn.SetReadDeadline(time.Now().Add(Timeout)); err != nil { + return nil, err + } + return tcp.ReadRequest(c.reader) +} + +func (c *Conn) WriteResponse(res *tcp.Response) error { + if res.Proto == "" { + res.Proto = ProtoRTSP + } + + if res.Status == "" { + res.Status = "200 OK" + } + + if res.Header == nil { + res.Header = make(map[string][]string) + } + + if res.Request != nil && res.Request.Header != nil { + seq := res.Request.Header.Get("CSeq") + if seq != "" { + res.Header.Set("CSeq", seq) } } + + if c.session != "" { + if res.Request != nil && res.Request.Method == MethodSetup { + res.Header.Set("Session", c.session+";timeout=60") + } else { + res.Header.Set("Session", c.session) + } + } + + if res.Body != nil { + val := strconv.Itoa(len(res.Body)) + res.Header.Set("Content-Length", val) + } + + c.Fire(res) + + if err := c.conn.SetWriteDeadline(time.Now().Add(Timeout)); err != nil { + return err + } + + return res.Write(c.conn) +} + +func (c *Conn) ReadResponse() (*tcp.Response, error) { + if err := c.conn.SetReadDeadline(time.Now().Add(Timeout)); err != nil { + return nil, err + } + return tcp.ReadResponse(c.reader) } diff --git a/pkg/rtsp/consumer.go b/pkg/rtsp/consumer.go index b0eaf7ce..663c8522 100644 --- a/pkg/rtsp/consumer.go +++ b/pkg/rtsp/consumer.go @@ -6,7 +6,9 @@ import ( "github.com/AlexxIT/go2rtc/pkg/h264" "github.com/AlexxIT/go2rtc/pkg/h265" "github.com/AlexxIT/go2rtc/pkg/mjpeg" + "github.com/AlexxIT/go2rtc/pkg/pcm" "github.com/pion/rtp" + "time" ) func (c *Conn) GetMedias() []*core.Media { @@ -28,10 +30,21 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv switch c.mode { case core.ModeActiveProducer: // backchannel - if channel, err = c.SetupMedia(media, true); err != nil { + c.stateMu.Lock() + defer c.stateMu.Unlock() + + if c.state == StatePlay { + if err = c.Reconnect(); err != nil { + return + } + } + + if channel, err = c.SetupMedia(media); err != nil { return } + c.state = StateSetup + case core.ModePassiveConsumer: channel = byte(len(c.senders)) * 2 @@ -46,21 +59,28 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv // save original codec to sender (can have Codec.Name = ANY) sender := core.NewSender(media, codec) - sender.Handler = c.packetWriter(codec, channel) + // important to send original codec for valid IsRTP check + sender.Handler = c.packetWriter(track.Codec, channel, codec.PayloadType) + + // https://github.com/AlexxIT/go2rtc/issues/331 + if c.mode == core.ModeActiveProducer && track.Codec.Name == core.CodecPCMA { + sender.Handler = pcm.RepackBackchannel(sender.Handler) + } + sender.HandleRTP(track) c.senders = append(c.senders, sender) return nil } -func (c *Conn) packetWriter(codec *core.Codec, channel uint8) core.HandlerFunc { +func (c *Conn) packetWriter(codec *core.Codec, channel, payloadType uint8) core.HandlerFunc { handlerFunc := func(packet *rtp.Packet) { if c.state == StateNone { return } clone := *packet - clone.Header.PayloadType = codec.PayloadType + clone.Header.PayloadType = payloadType size := clone.MarshalSize() @@ -76,6 +96,10 @@ func (c *Conn) packetWriter(codec *core.Codec, channel uint8) core.HandlerFunc { return } + if err := c.conn.SetWriteDeadline(time.Now().Add(Timeout)); err != nil { + return + } + n, err := c.conn.Write(data) if err != nil { return @@ -87,14 +111,23 @@ func (c *Conn) packetWriter(codec *core.Codec, channel uint8) core.HandlerFunc { if !codec.IsRTP() { switch codec.Name { case core.CodecH264: - handlerFunc = h264.RTPPay(1500, handlerFunc) + handlerFunc = h264.RTPPay(c.PacketSize, handlerFunc) case core.CodecH265: - handlerFunc = h265.RTPPay(1500, handlerFunc) + handlerFunc = h265.RTPPay(c.PacketSize, handlerFunc) case core.CodecAAC: handlerFunc = aac.RTPPay(handlerFunc) case core.CodecJPEG: handlerFunc = mjpeg.RTPPay(handlerFunc) } + } else if c.PacketSize != 0 { + switch codec.Name { + case core.CodecH264: + handlerFunc = h264.RTPPay(c.PacketSize, handlerFunc) + handlerFunc = h264.RTPDepay(codec, handlerFunc) + case core.CodecH265: + handlerFunc = h265.RTPPay(c.PacketSize, handlerFunc) + handlerFunc = h265.RTPDepay(codec, handlerFunc) + } } return handlerFunc diff --git a/pkg/rtsp/dial.go b/pkg/rtsp/dial.go new file mode 100644 index 00000000..58d5dd65 --- /dev/null +++ b/pkg/rtsp/dial.go @@ -0,0 +1,44 @@ +package rtsp + +import ( + "crypto/tls" + "errors" + "net" + "net/url" + "strings" + "time" +) + +func Dial(uri string) (net.Conn, error) { + u, err := url.Parse(uri) + if err != nil { + return nil, err + } + + switch u.Scheme { + case "rtsp": + return dialTCP(u.Host, nil) + case "rtsps": + tlsConf := &tls.Config{ServerName: u.Hostname()} + return dialTCP(u.Host, tlsConf) + case "rtspx": + tlsConf := &tls.Config{InsecureSkipVerify: true} + return dialTCP(u.Host, tlsConf) + } + + return nil, errors.New("unsupported scheme: " + u.Scheme) +} + +func dialTCP(address string, tlsConf *tls.Config) (net.Conn, error) { + if strings.IndexByte(address, ':') < 0 { + address += ":554" + } + + conn, err := net.DialTimeout("tcp", address, time.Second*5) + if tlsConf == nil || err != nil { + return conn, err + } + + tlsConn := tls.Client(conn, tlsConf) + return tlsConn, tlsConn.Handshake() +} diff --git a/pkg/rtsp/producer.go b/pkg/rtsp/producer.go index ea7aa3ea..764fb6ef 100644 --- a/pkg/rtsp/producer.go +++ b/pkg/rtsp/producer.go @@ -2,7 +2,7 @@ package rtsp import ( "encoding/json" - "fmt" + "errors" "github.com/AlexxIT/go2rtc/pkg/core" ) @@ -15,51 +15,86 @@ func (c *Conn) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, e } } - switch c.state { - case StateConn, StateSetup: - default: - return nil, fmt.Errorf("RTSP GetTrack from wrong state: %s", c.state) + c.stateMu.Lock() + defer c.stateMu.Unlock() + + if c.state == StatePlay { + if err := c.Reconnect(); err != nil { + return nil, err + } } - channel, err := c.SetupMedia(media, true) + channel, err := c.SetupMedia(media) if err != nil { return nil, err } + c.state = StateSetup + track := core.NewReceiver(media, codec) - track.ID = byte(channel) + track.ID = channel c.receivers = append(c.receivers, track) return track, nil } -func (c *Conn) Start() error { - switch c.mode { - case core.ModeActiveProducer: - if err := c.Play(); err != nil { - return err +func (c *Conn) Start() (err error) { + core.Assert(c.mode == core.ModeActiveProducer || c.mode == core.ModePassiveProducer) + + for { + ok := false + + c.stateMu.Lock() + switch c.state { + case StateNone: + err = nil + case StateConn: + err = errors.New("start from CONN state") + case StateSetup: + switch c.mode { + case core.ModeActiveProducer: + err = c.Play() + case core.ModePassiveProducer: + err = nil + default: + err = errors.New("start from wrong mode: " + c.mode.String()) + } + + if err == nil { + c.state = StatePlay + ok = true + } } - case core.ModePassiveProducer: - default: - return fmt.Errorf("start wrong mode: %d", c.mode) - } + c.stateMu.Unlock() - if err := c.Handle(); c.state != StateNone { - _ = c.conn.Close() - return err - } + if !ok { + return + } - return nil + // Handler can return different states: + // 1. None after PLAY should exit without error + // 2. Play after PLAY should exit from Start with error + // 3. Setup after PLAY should Play once again + err = c.Handle() + } } -func (c *Conn) Stop() error { +func (c *Conn) Stop() (err error) { for _, receiver := range c.receivers { receiver.Close() } for _, sender := range c.senders { sender.Close() } - return c.Close() + + c.stateMu.Lock() + if c.state != StateNone { + c.state = StateNone + err = c.Close() + } + c.stateMu.Unlock() + + return } func (c *Conn) MarshalJSON() ([]byte, error) { @@ -82,3 +117,32 @@ func (c *Conn) MarshalJSON() ([]byte, error) { return json.Marshal(info) } + +func (c *Conn) Reconnect() error { + c.Fire("RTSP reconnect") + + // close current session + _ = c.Close() + + // start new session + if err := c.Dial(); err != nil { + return err + } + if err := c.Describe(); err != nil { + return err + } + + // restore previous medias + for _, receiver := range c.receivers { + if _, err := c.SetupMedia(receiver.Media); err != nil { + return err + } + } + for _, sender := range c.senders { + if _, err := c.SetupMedia(sender.Media); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/rtsp/server.go b/pkg/rtsp/server.go index e32009bf..74aefe37 100644 --- a/pkg/rtsp/server.go +++ b/pkg/rtsp/server.go @@ -8,6 +8,7 @@ import ( "github.com/AlexxIT/go2rtc/pkg/tcp" "net" "net/url" + "strconv" "strings" ) @@ -25,7 +26,7 @@ func (c *Conn) Auth(username, password string) { func (c *Conn) Accept() error { for { - req, err := tcp.ReadRequest(c.reader) + req, err := c.ReadRequest() if err != nil { return err } @@ -39,10 +40,11 @@ func (c *Conn) Accept() error { if !c.auth.Validate(req) { res := &tcp.Response{ - Status: "401 Unauthorized", - Header: map[string][]string{"Www-Authenticate": {`Basic realm="go2rtc"`}}, + Status: "401 Unauthorized", + Header: map[string][]string{"Www-Authenticate": {`Basic realm="go2rtc"`}}, + Request: req, } - if err = c.Response(res); err != nil { + if err = c.WriteResponse(res); err != nil { return err } continue @@ -58,7 +60,7 @@ func (c *Conn) Accept() error { }, Request: req, } - if err = c.Response(res); err != nil { + if err = c.WriteResponse(res); err != nil { return err } @@ -83,7 +85,7 @@ func (c *Conn) Accept() error { c.Fire(MethodAnnounce) res := &tcp.Response{Request: req} - if err = c.Response(res); err != nil { + if err = c.WriteResponse(res); err != nil { return err } @@ -96,7 +98,7 @@ func (c *Conn) Accept() error { Status: "404 Not Found", Request: req, } - return c.Response(res) + return c.WriteResponse(res) } res := &tcp.Response{ @@ -108,11 +110,12 @@ func (c *Conn) Accept() error { // convert tracks to real output medias medias var medias []*core.Media - for _, track := range c.senders { + for i, track := range c.senders { media := &core.Media{ Kind: core.GetKind(track.Codec.Name), Direction: core.DirectionRecvonly, Codecs: []*core.Codec{track.Codec}, + ID: "trackID=" + strconv.Itoa(i), } medias = append(medias, media) } @@ -122,7 +125,7 @@ func (c *Conn) Accept() error { return err } - if err = c.Response(res); err != nil { + if err = c.WriteResponse(res); err != nil { return err } @@ -136,27 +139,24 @@ func (c *Conn) Accept() error { const transport = "RTP/AVP/TCP;unicast;interleaved=" if strings.HasPrefix(tr, transport) { - c.Session = "1" // TODO: fixme + c.session = core.RandString(8, 10) c.state = StateSetup res.Header.Set("Transport", tr[:len(transport)+3]) } else { res.Status = "461 Unsupported transport" } - if err = c.Response(res); err != nil { + if err = c.WriteResponse(res); err != nil { return err } case MethodRecord, MethodPlay: res := &tcp.Response{Request: req} - if err = c.Response(res); err == nil { - c.state = StatePlay - } - return err + return c.WriteResponse(res) case MethodTeardown: res := &tcp.Response{Request: req} - _ = c.Response(res) + _ = c.WriteResponse(res) c.state = StateNone return c.conn.Close() diff --git a/pkg/shell/env.go b/pkg/shell/env.go deleted file mode 100644 index a8867f6a..00000000 --- a/pkg/shell/env.go +++ /dev/null @@ -1,32 +0,0 @@ -package shell - -import ( - "os" - "regexp" - "strings" -) - -func ReplaceEnvVars(text string) string { - re := regexp.MustCompile(`\${([^}{]+)}`) - return re.ReplaceAllStringFunc(text, func(match string) string { - key := match[2 : len(match)-1] - - var def string - var dok bool - - if i := strings.IndexByte(key, ':'); i > 0 { - key, def = key[:i], key[i+1:] - dok = true - } - - if value, vok := os.LookupEnv(key); vok { - return value - } - - if dok { - return def - } - - return match - }) -} diff --git a/pkg/shell/shell.go b/pkg/shell/shell.go index 0b080876..719c0e68 100644 --- a/pkg/shell/shell.go +++ b/pkg/shell/shell.go @@ -1,7 +1,11 @@ package shell import ( + "os" + "os/signal" + "regexp" "strings" + "syscall" ) func QuoteSplit(s string) []string { @@ -39,3 +43,34 @@ func QuoteSplit(s string) []string { } return a } + +func ReplaceEnvVars(text string) string { + re := regexp.MustCompile(`\${([^}{]+)}`) + return re.ReplaceAllStringFunc(text, func(match string) string { + key := match[2 : len(match)-1] + + var def string + var dok bool + + if i := strings.IndexByte(key, ':'); i > 0 { + key, def = key[:i], key[i+1:] + dok = true + } + + if value, vok := os.LookupEnv(key); vok { + return value + } + + if dok { + return def + } + + return match + }) +} + +func RunUntilSignal() { + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + println("exit with signal:", (<-sigs).String()) +} diff --git a/pkg/tcp/request.go b/pkg/tcp/request.go index f0b76edb..5bcbc48b 100644 --- a/pkg/tcp/request.go +++ b/pkg/tcp/request.go @@ -12,7 +12,7 @@ import ( // Do - http.Client with support Digest Authorization func Do(req *http.Request) (*http.Response, error) { - if client == nil { + if secureClient == nil { transport := http.DefaultTransport.(*http.Transport).Clone() dial := transport.DialContext @@ -24,12 +24,32 @@ func Do(req *http.Request) (*http.Response, error) { return conn, err } - client = &http.Client{ + secureClient = &http.Client{ Timeout: time.Second * 5000, Transport: transport, } } + var client *http.Client + + if req.URL.Scheme == "httpx" { + req.URL.Scheme = "https" + + if insecureClient == nil { + transport := secureClient.Transport.(*http.Transport).Clone() + transport.TLSClientConfig.InsecureSkipVerify = true + + insecureClient = &http.Client{ + Timeout: secureClient.Timeout, + Transport: transport, + } + } + + client = insecureClient + } else { + client = secureClient + } + user := req.URL.User // Hikvision won't answer on Basic auth with any headers @@ -92,7 +112,7 @@ func Do(req *http.Request) (*http.Response, error) { return res, nil } -var client *http.Client +var secureClient, insecureClient *http.Client var connKey struct{} func WithConn() (context.Context, *net.Conn) { diff --git a/pkg/tcp/websocket/client.go b/pkg/tcp/websocket/client.go new file mode 100644 index 00000000..e95ce1e4 --- /dev/null +++ b/pkg/tcp/websocket/client.go @@ -0,0 +1,130 @@ +package websocket + +import ( + cryptorand "crypto/rand" + "encoding/binary" + "fmt" + "io" + "net" + "time" +) + +const BinaryMessage = 2 + +type Client struct { + conn net.Conn + remain int +} + +func NewClient(conn net.Conn) *Client { + return &Client{conn: conn} +} + +const finalBit = 0x80 +const maskBit = 0x80 + +func (w *Client) Read(b []byte) (n int, err error) { + if w.remain == 0 { + b2 := make([]byte, 2) + if _, err = io.ReadFull(w.conn, b2); err != nil { + return 0, err + } + + frameType := b2[0] & 0xF + w.remain = int(b2[1] & 0x7F) + + switch frameType { + case BinaryMessage: + default: + return 0, fmt.Errorf("unsupported frame type: %d", frameType) + } + + switch w.remain { + case 126: + if _, err = io.ReadFull(w.conn, b2); err != nil { + return 0, err + } + w.remain = int(binary.BigEndian.Uint16(b2)) + case 127: + b8 := make([]byte, 8) + if _, err = io.ReadFull(w.conn, b8); err != nil { + return 0, err + } + w.remain = int(binary.BigEndian.Uint64(b8)) + } + } + + if w.remain > len(b) { + n, err = io.ReadFull(w.conn, b) + w.remain -= n + return + } + + n, err = io.ReadFull(w.conn, b[:w.remain]) + w.remain = 0 + + return +} + +func (w *Client) Write(b []byte) (n int, err error) { + var data []byte + var start byte + + size := len(b) + + switch { + case size > 65535: + start = 10 + data = make([]byte, size+14) + data[1] = maskBit | 127 + binary.BigEndian.PutUint64(data[2:], uint64(size)) + case size > 125: + start = 4 + data = make([]byte, size+8) + data[1] = maskBit | 126 + binary.BigEndian.PutUint16(data[2:], uint16(size)) + default: + start = 2 + data = make([]byte, size+6) + data[1] = maskBit | byte(size) + } + + data[0] = BinaryMessage | finalBit + + mask := data[start : start+4] + msg := data[start+4:] + + if _, err = cryptorand.Read(mask); err != nil { + return 0, err + } + + for i := 0; i < len(b); i++ { + msg[i] = b[i] ^ mask[i%4] + } + + return w.conn.Write(data) +} + +func (w *Client) Close() error { + return w.conn.Close() +} + +func (w *Client) LocalAddr() net.Addr { + return w.conn.LocalAddr() +} + +func (w *Client) RemoteAddr() net.Addr { + return w.conn.RemoteAddr() +} + +func (w *Client) SetDeadline(t time.Time) error { + return w.conn.SetDeadline(t) +} + +func (w *Client) SetReadDeadline(t time.Time) error { + return w.conn.SetReadDeadline(t) +} + +func (w *Client) SetWriteDeadline(t time.Time) error { + return w.conn.SetWriteDeadline(t) +} diff --git a/pkg/tcp/websocket/dial.go b/pkg/tcp/websocket/dial.go new file mode 100644 index 00000000..737a5cbc --- /dev/null +++ b/pkg/tcp/websocket/dial.go @@ -0,0 +1,64 @@ +package websocket + +import ( + cryptorand "crypto/rand" + "crypto/sha1" + "encoding/base64" + "errors" + "github.com/AlexxIT/go2rtc/pkg/tcp" + "net" + "net/http" + "strings" +) + +func Dial(address string) (net.Conn, error) { + if strings.HasPrefix(address, "ws") { + address = "http" + address[2:] // support http and https + } + + // using custom client for support Digest Auth + // https://github.com/AlexxIT/go2rtc/issues/415 + ctx, pconn := tcp.WithConn() + + req, err := http.NewRequestWithContext(ctx, "GET", address, nil) + if err != nil { + return nil, err + } + + key, accept := GetKeyAccept() + + // Version, Key, Protocol important for Axis cameras + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Sec-WebSocket-Version", "13") + req.Header.Set("Sec-WebSocket-Key", key) + req.Header.Set("Sec-WebSocket-Protocol", "binary") + + res, err := tcp.Do(req) + if err != nil { + return nil, err + } + + if res.StatusCode != http.StatusSwitchingProtocols { + return nil, errors.New("wrong status: " + res.Status) + } + + if res.Header.Get("Sec-Websocket-Accept") != accept { + return nil, errors.New("wrong websocket accept") + } + + return NewClient(*pconn), nil +} + +func GetKeyAccept() (key, accept string) { + b := make([]byte, 16) + _, _ = cryptorand.Read(b) + key = base64.StdEncoding.EncodeToString(b) + + h := sha1.New() + h.Write([]byte(key)) + h.Write([]byte("258EAFA5-E914-47DA-95CA-C5AB0DC85B11")) + accept = base64.StdEncoding.EncodeToString(h.Sum(nil)) + + return +} diff --git a/pkg/webrtc/api.go b/pkg/webrtc/api.go index 8a5c7668..5086109d 100644 --- a/pkg/webrtc/api.go +++ b/pkg/webrtc/api.go @@ -9,6 +9,7 @@ import ( ) // ReceiveMTU = Ethernet MTU (1500) - IP Header (20) - UDP Header (8) +// https://ffmpeg.org/ffmpeg-all.html#Muxer const ReceiveMTU = 1472 func NewAPI(address string) (*webrtc.API, error) { diff --git a/pkg/webrtc/client.go b/pkg/webrtc/client.go index 1e33fd10..50c7773d 100644 --- a/pkg/webrtc/client.go +++ b/pkg/webrtc/client.go @@ -24,6 +24,9 @@ func (c *Conn) CreateOffer(medias []*core.Media) (string, error) { case core.DirectionSendRecv: // default transceiver is sendrecv _, err = c.pc.AddTransceiverFromTrack(NewTrack(media.Kind)) + default: + // Nest cameras require data channel + _, err = c.pc.CreateDataChannel(media.Kind, nil) } if err != nil { diff --git a/pkg/webrtc/conn.go b/pkg/webrtc/conn.go index b7c3c628..e3b1c960 100644 --- a/pkg/webrtc/conn.go +++ b/pkg/webrtc/conn.go @@ -148,6 +148,17 @@ func (c *Conn) getTranseiver(mid string) *webrtc.RTPTransceiver { return nil } +func (c *Conn) getSenderTrack(mid string) *Track { + if tr := c.getTranseiver(mid); tr != nil { + if s := tr.Sender(); s != nil { + if t := s.Track().(*Track); t != nil { + return t + } + } + } + return nil +} + func (c *Conn) getMediaCodec(remote *webrtc.TrackRemote) (*core.Media, *core.Codec) { for _, tr := range c.pc.GetTransceivers() { // search Transeiver for this TrackRemote diff --git a/pkg/webrtc/consumer.go b/pkg/webrtc/consumer.go index 0a278924..070573c6 100644 --- a/pkg/webrtc/consumer.go +++ b/pkg/webrtc/consumer.go @@ -2,14 +2,16 @@ package webrtc import ( "encoding/json" + "errors" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/h264" "github.com/AlexxIT/go2rtc/pkg/h265" + "github.com/AlexxIT/go2rtc/pkg/pcm" "github.com/pion/rtp" ) func (c *Conn) GetMedias() []*core.Media { - return c.medias + return WithResampling(c.medias) } func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error { @@ -30,16 +32,21 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv panic(core.Caller()) } - localTrack := c.getTranseiver(media.ID).Sender().Track().(*Track) + localTrack := c.getSenderTrack(media.ID) + if localTrack == nil { + return errors.New("webrtc: can't get track") + } - sender := core.NewSender(media, track.Codec) + payloadType := codec.PayloadType + + sender := core.NewSender(media, codec) sender.Handler = func(packet *rtp.Packet) { c.send += packet.MarshalSize() //important to send with remote PayloadType - _ = localTrack.WriteRTP(codec.PayloadType, packet) + _ = localTrack.WriteRTP(payloadType, packet) } - switch codec.Name { + switch track.Codec.Name { case core.CodecH264: sender.Handler = h264.RTPPay(1200, sender.Handler) if track.Codec.IsRTP() { @@ -55,6 +62,15 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv if track.Codec.IsRTP() { sender.Handler = h265.RTPDepay(track.Codec, sender.Handler) } + + case core.CodecPCMA, core.CodecPCMU, core.CodecPCM: + if codec.ClockRate == 0 { + if codec.Name == core.CodecPCM { + codec.Name = core.CodecPCMA + } + codec.ClockRate = 8000 + sender.Handler = pcm.Resample(track.Codec, 8000, sender.Handler) + } } sender.HandleRTP(track) diff --git a/pkg/webrtc/helpers.go b/pkg/webrtc/helpers.go index b6e36ee6..b92e72ee 100644 --- a/pkg/webrtc/helpers.go +++ b/pkg/webrtc/helpers.go @@ -52,6 +52,53 @@ func UnmarshalMedias(descriptions []*sdp.MediaDescription) (medias []*core.Media return } +func WithResampling(medias []*core.Media) []*core.Media { + for _, media := range medias { + if media.Kind != core.KindAudio || media.Direction != core.DirectionSendonly { + continue + } + + var pcma, pcmu, pcm *core.Codec + + for _, codec := range media.Codecs { + switch codec.Name { + case core.CodecPCMA: + if codec.ClockRate != 0 { + pcma = codec + } else { + pcma = nil + } + case core.CodecPCMU: + if codec.ClockRate != 0 { + pcmu = codec + } else { + pcmu = nil + } + case core.CodecPCM: + pcm = codec + } + } + + if pcma != nil { + pcma = pcma.Clone() + pcma.ClockRate = 0 // reset clock rate so will match any + media.Codecs = append(media.Codecs, pcma) + } + if pcmu != nil { + pcmu = pcmu.Clone() + pcmu.ClockRate = 0 + media.Codecs = append(media.Codecs, pcmu) + } + if pcma != nil && pcm == nil { + pcm = pcma.Clone() + pcm.Name = core.CodecPCM + media.Codecs = append(media.Codecs, pcm) + } + } + + return medias +} + func NewCandidate(network, address string) (string, error) { i := strings.LastIndexByte(address, ':') if i < 0 { diff --git a/scripts/build.cmd b/scripts/build.cmd index ef14aa30..54565b2d 100644 --- a/scripts/build.cmd +++ b/scripts/build.cmd @@ -36,6 +36,12 @@ go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME% @SET FILENAME=go2rtc_linux_arm go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME% +@SET GOOS=linux +@SET GOARCH=arm +@SET GOARM=6 +@SET FILENAME=go2rtc_linux_armv6 +go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME% + @SET GOOS=linux @SET GOARCH=mipsle @SET FILENAME=go2rtc_linux_mipsel diff --git a/www/add.html b/www/add.html index fc907b76..0a99facb 100644 --- a/www/add.html +++ b/www/add.html @@ -60,6 +60,7 @@ + +
+ +
+
+ + + + +
+ +
+
+ + + + +
+
+ + + + + +
+ +
+
+ + +
@@ -182,6 +238,31 @@ + +
+
+ + +
+
+
+ + +
@@ -206,19 +287,6 @@ - -
- -
-
- - -
diff --git a/www/links.html b/www/links.html index 6faa4637..fab598a6 100644 --- a/www/links.html +++ b/www/links.html @@ -67,12 +67,14 @@

H264/H265 source

  • stream.html WebRTC stream / browsers: all / codecs: H264, PCMU, PCMA, OPUS / +H265 in Safari
  • -
  • stream.html MSE stream / browsers: Chrome, Firefox, Safari Mac/iPad / codecs: H264, H265*, AAC / +OPUS in Chrome and Firefox
  • -
  • stream.mp4 MP4 stream with AAC audio / browsers: Chrome, Firefox / codecs: H264, H265*, AAC
  • -
  • stream.mp4 MP4 stream with any audio / browsers: Chrome / codecs: H264, H265*, AAC, OPUS, MP3, PCMU, PCMA
  • +
  • stream.html MSE stream / browsers: Chrome, Firefox, Safari Mac/iPad / codecs: H264, H265*, AAC, PCMA*, PCMU*, PCM* / +OPUS in Chrome and Firefox
  • +
  • stream.mp4 legacy MP4 stream with AAC audio / browsers: Chrome, Firefox / codecs: H264, H265*, AAC
  • +
  • stream.mp4 modern MP4 stream with common audio / browsers: Chrome, Firefox / codecs: H264, H265*, AAC, FLAC (PCMA, PCMU, PCM)
  • +
  • stream.mp4 MP4 stream with any audio / browsers: Chrome / codecs: H264, H265*, AAC, OPUS, MP3, FLAC (PCMA, PCMU, PCM)
  • frame.mp4 snapshot in MP4-format / browsers: all / codecs: H264, H265*
  • -
  • stream.m3u8 HLS/TS / browsers: Safari all, Chrome Android / codecs: H264
  • -
  • stream.m3u8 HLS/fMP4 / browsers: Safari all, Chrome Android / codecs: H264, H265*, AAC
  • +
  • stream.m3u8 legacy HLS/TS / browsers: Safari all, Chrome Android / codecs: H264
  • +
  • stream.m3u8 legacy HLS/fMP4 / browsers: Safari all, Chrome Android / codecs: H264, H265*, AAC
  • +
  • stream.m3u8 modern HLS/fMP4 / browsers: Safari all, Chrome Android / codecs: H264, H265*, AAC, FLAC (PCMA, PCMU, PCM)
  • MJPEG source

  • stream.html with MJPEG mode / browsers: all / codecs: MJPEG, JPEG
  • diff --git a/www/video-rtc.js b/www/video-rtc.js index 445aa94d..11ec30be 100644 --- a/www/video-rtc.js +++ b/www/video-rtc.js @@ -27,7 +27,8 @@ export class VideoRTC extends HTMLElement { "hvc1.1.6.L153.B0", // H.265 main 5.1 (Chromecast Ultra) "mp4a.40.2", // AAC LC "mp4a.40.5", // AAC HE - "opus", // OPUS Chrome + "flac", // FLAC (PCM compatible) + "opus", // OPUS Chrome, Firefox ]; /**