Merge remote-tracking branch 'remotes/upstream/master' into patch-listen-tls

This commit is contained in:
Sergey Krashevich
2023-05-21 23:29:08 +03:00
135 changed files with 5094 additions and 1568 deletions
+2 -1
View File
@@ -40,7 +40,8 @@ FROM base
# Install ffmpeg, tini (for signal handling), # Install ffmpeg, tini (for signal handling),
# and other common tools for the echo source. # 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) # Hardware Acceleration for Intel CPU (+50MB)
ARG TARGETARCH ARG TARGETARCH
+107 -22
View File
@@ -1,5 +1,9 @@
# go2rtc # 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. Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg, RTMP, etc.
![](assets/go2rtc.png) ![](assets/go2rtc.png)
@@ -42,6 +46,7 @@ Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg
* [Source: RTSP](#source-rtsp) * [Source: RTSP](#source-rtsp)
* [Source: RTMP](#source-rtmp) * [Source: RTMP](#source-rtmp)
* [Source: HTTP](#source-http) * [Source: HTTP](#source-http)
* [Source: ONVIF](#source-onvif)
* [Source: FFmpeg](#source-ffmpeg) * [Source: FFmpeg](#source-ffmpeg)
* [Source: FFmpeg Device](#source-ffmpeg-device) * [Source: FFmpeg Device](#source-ffmpeg-device)
* [Source: Exec](#source-exec) * [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_win64.zip` - Windows 64-bit
- `go2rtc_win32.zip` - Windows 32-bit - `go2rtc_win32.zip` - Windows 32-bit
- `go2rtc_win_arm64.zip` - Windows ARM 64-bit
- `go2rtc_linux_amd64` - Linux 64-bit - `go2rtc_linux_amd64` - Linux 64-bit
- `go2rtc_linux_i386` - Linux 32-bit - `go2rtc_linux_i386` - Linux 32-bit
- `go2rtc_linux_arm64` - Linux ARM 64-bit (ex. Raspberry 64-bit OS) - `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_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_linux_mipsel` - Linux MIPS (ex. [Xiaomi Gateway 3](https://github.com/AlexxIT/XiaomiGateway3))
- `go2rtc_mac_amd64.zip` - Mac Intel 64-bit - `go2rtc_mac_amd64.zip` - Mac Intel 64-bit
- `go2rtc_mac_arm64.zip` - Mac ARM 64-bit - `go2rtc_mac_arm64.zip` - Mac ARM 64-bit
@@ -157,9 +164,10 @@ Available source types:
- [rtsp](#source-rtsp) - `RTSP` and `RTSPS` cameras with [two way audio](#two-way-audio) support - [rtsp](#source-rtsp) - `RTSP` and `RTSPS` cameras with [two way audio](#two-way-audio) support
- [rtmp](#source-rtmp) - `RTMP` streams - [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](#source-ffmpeg) - FFmpeg integration (`HLS`, `files` and many others)
- [ffmpeg:device](#source-ffmpeg-device) - local USB Camera or Webcam - [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 - [echo](#source-echo) - get stream link from bash or python
- [homekit](#source-homekit) - streaming from HomeKit Camera - [homekit](#source-homekit) - streaming from HomeKit Camera
- [dvrip](#source-dvrip) - streaming from DVR-IP NVR - [dvrip](#source-dvrip) - streaming from DVR-IP NVR
@@ -229,6 +237,8 @@ Support Content-Type:
- **HTTP-MJPEG** (`multipart/x`) - simple MJPEG stream over HTTP - **HTTP-MJPEG** (`multipart/x`) - simple MJPEG stream over HTTP
- **MPEG-TS** (`video/mpeg`) - legacy [streaming format](https://en.wikipedia.org/wiki/MPEG_transport_stream) - **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 ```yaml
streams: streams:
# [HTTP-FLV] stream in video/x-flv format # [HTTP-FLV] stream in video/x-flv format
@@ -239,10 +249,26 @@ streams:
# [MJPEG] stream will be proxied without modification # [MJPEG] stream will be proxied without modification
http_mjpeg: https://mjpeg.sanford.io/count.mjpeg 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. **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 #### 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. 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 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. 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. 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 - 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 Linux supported only video for now
- for macOS you can stream Facetime camera or whole Desktop! - for macOS you can stream Facetime camera or whole Desktop!
- for macOS important to set right framerate - for macOS important to set right framerate
Format: `ffmpeg:device?{input-params}#{param1}#{param2}#{param3}`
```yaml ```yaml
streams: 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 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 #### 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 ```yaml
streams: 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 #### Source: Echo
@@ -412,8 +453,10 @@ streams:
Support import camera links from [Home Assistant](https://www.home-assistant.io/) config files: 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 - [Generic Camera](https://www.home-assistant.io/integrations/generic/), setup via GUI
- support [HomeKit Camera](https://www.home-assistant.io/integrations/homekit_controller/) - [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 ```yaml
hass: hass:
@@ -424,7 +467,7 @@ streams:
aqara_g3: hass:Camera-Hub-G3-AB12 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 #### 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/) 2. Camera [any source](#module-streams) => [go2rtc config](#configuration) => [Generic Camera](https://www.home-assistant.io/integrations/generic/)
- Install any [go2rtc](#fast-start) - Install any [go2rtc](#fast-start)
- Add your stream to [go2rtc config](#configuration) - 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) - 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: 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: 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` (H264, H265)
- MP4 snapshot: `http://192.168.1.123:1984/api/frame.mp4?src=camera1` - MP4 stream: `http://192.168.1.123:1984/api/stream.mp4?src=camera1` (H264, H265, AAC)
Read more about [codecs filters](#codecs-filters). 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: 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) - MP4 will include only compatible codecs (H264, H265, AAC)
- HLS will output in the legacy TS format (H264 without audio) - HLS will output in the legacy TS format (H264 without audio)
@@ -914,21 +958,23 @@ 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=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 - `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.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 ## 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. `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 | | Device | WebRTC | MSE | HTTP Progressive Streaming |
|---------------------|-------------------------------|------------------------|-----------------------------------------| |---------------------|-------------------------------|-------------------------------|------------------------------------|
| *latency* | best | medium | bad | | *latency* | best | medium | bad |
| Desktop Chrome 107+ | H264, OPUS, PCMU, PCMA | H264, H265*, AAC, OPUS | H264, H265*, AAC, OPUS, PCMU, PCMA, MP3 | | 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, OPUS | H264, H265*, AAC, OPUS, PCMU, PCMA, MP3 | | Desktop Edge | H264, OPUS, PCMU, PCMA | H264, H265*, AAC, FLAC*, OPUS | H264, H265*, AAC, FLAC*, OPUS, MP3 |
| Desktop Safari | H264, H265*, OPUS, PCMU, PCMA | H264, H265, AAC | **no!** | | 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, OPUS | H264, AAC, OPUS | | Desktop Firefox | H264, OPUS, PCMU, PCMA | H264, AAC, FLAC*, OPUS | H264, AAC, FLAC*, OPUS |
| Android Chrome 107+ | H264, OPUS, PCMU, PCMA | H264, H265*, AAC, OPUS | H264, ?, AAC, OPUS, PCMU, PCMA, MP3 | | Desktop Safari | H264, H265*, OPUS, PCMU, PCMA | H264, H265, AAC, FLAC* | **no!** |
| iPad Safari 13+ | H264, H265*, OPUS, PCMU, PCMA | H264, H265, AAC | **no!** | | iPad Safari 13+ | H264, H265*, OPUS, PCMU, PCMA | H264, H265, AAC, FLAC* | **no!** |
| iPhone Safari 13+ | H264, H265*, OPUS, PCMU, PCMA | **no!** | **no!** | | iPhone Safari 13+ | H264, H265*, OPUS, PCMU, PCMA | **no!** | **no!** |
| masOS Hass App | no | no | no | | masOS Hass App | no | no | no |
@@ -939,9 +985,9 @@ Some examples:
**Audio** **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` - **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) - `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** **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 - 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 - 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 ## 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. 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.
-19
View File
@@ -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}"
-4
View File
@@ -1,4 +0,0 @@
**Project layout**
- https://github.com/golang-standards/project-layout
- https://github.com/micro/micro
-61
View File
@@ -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}
}
-48
View File
@@ -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}
}
-57
View File
@@ -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}
}
-91
View File
@@ -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)
}
-120
View File
@@ -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
}
-21
View File
@@ -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
}
-67
View File
@@ -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
}
-40
View File
@@ -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
}
+20
View File
@@ -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()
}
+17
View File
@@ -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()
}
-173
View File
@@ -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"`
}
-65
View File
@@ -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)
}
-35
View File
@@ -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
}
+2
View File
@@ -16,6 +16,8 @@ require (
github.com/pion/stun v0.4.0 github.com/pion/stun v0.4.0
github.com/pion/webrtc/v3 v3.1.58 github.com/pion/webrtc/v3 v3.1.58
github.com/rs/zerolog v1.29.0 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/stretchr/testify v1.8.2
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
+4
View File
@@ -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 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w=
github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= 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/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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+5 -1
View File
@@ -38,9 +38,13 @@ RUN rm -f /etc/apt/apt.conf.d/docker-clean \
# Install ffmpeg, bash (for run.sh), tini (for signal handling), # Install ffmpeg, bash (for run.sh), tini (for signal handling),
# and other common tools for the echo source. # and other common tools for the echo source.
# non-free for Intel QSV support (not used by go2rtc, just for tests) # 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 \ 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 && \ 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 / / COPY --link --from=rootfs / /
+11
View File
@@ -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
+1 -1
View File
@@ -3,7 +3,7 @@ package api
import ( import (
"crypto/tls" "crypto/tls"
"encoding/json" "encoding/json"
"github.com/AlexxIT/go2rtc/cmd/app" "github.com/AlexxIT/go2rtc/internal/app"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"net" "net"
"net/http" "net/http"
+1 -1
View File
@@ -1,7 +1,7 @@
package api package api
import ( import (
"github.com/AlexxIT/go2rtc/cmd/app" "github.com/AlexxIT/go2rtc/internal/app"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"io" "io"
"net/http" "net/http"
+1 -1
View File
@@ -16,7 +16,7 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
var Version = "1.3.1" var Version = "1.5.0"
var UserAgent = "go2rtc/" + Version var UserAgent = "go2rtc/" + Version
var ConfigPath string var ConfigPath string
@@ -1,8 +1,8 @@
package debug package debug
import ( import (
"github.com/AlexxIT/go2rtc/cmd/api" "github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/cmd/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
) )
@@ -13,15 +13,15 @@ var stackSkip = [][]byte{
[]byte("created by os/signal.Notify"), []byte("created by os/signal.Notify"),
// api/stack.go // api/stack.go
[]byte("github.com/AlexxIT/go2rtc/cmd/api.stackHandler"), []byte("github.com/AlexxIT/go2rtc/internal/api.stackHandler"),
// api/api.go // 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.(*connReader).startBackgroundRead"),
[]byte("created by net/http.(*Server).Serve"), // TODO: why two? []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/internal/rtsp.Init"),
[]byte("created by github.com/AlexxIT/go2rtc/cmd/srtp.Init"), []byte("created by github.com/AlexxIT/go2rtc/internal/srtp.Init"),
// webrtc/api.go // webrtc/api.go
[]byte("created by github.com/pion/ice/v2.NewTCPMuxDefault"), []byte("created by github.com/pion/ice/v2.NewTCPMuxDefault"),
@@ -1,7 +1,7 @@
package dvrip package dvrip
import ( import (
"github.com/AlexxIT/go2rtc/cmd/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/dvrip" "github.com/AlexxIT/go2rtc/pkg/dvrip"
) )
+2 -2
View File
@@ -2,8 +2,8 @@ package echo
import ( import (
"bytes" "bytes"
"github.com/AlexxIT/go2rtc/cmd/app" "github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/cmd/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/shell" "github.com/AlexxIT/go2rtc/pkg/shell"
"os/exec" "os/exec"
+54 -23
View File
@@ -5,26 +5,21 @@ import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
"github.com/AlexxIT/go2rtc/cmd/app" "github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/cmd/rtsp" "github.com/AlexxIT/go2rtc/internal/rtsp"
"github.com/AlexxIT/go2rtc/cmd/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/magic"
pkg "github.com/AlexxIT/go2rtc/pkg/rtsp" pkg "github.com/AlexxIT/go2rtc/pkg/rtsp"
"github.com/AlexxIT/go2rtc/pkg/shell" "github.com/AlexxIT/go2rtc/pkg/shell"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"os" "os"
"os/exec" "os/exec"
"strings"
"sync" "sync"
"time" "time"
) )
func Init() { func Init() {
// depends on RTSP server
if rtsp.Port == "" {
return
}
rtsp.HandleFunc(func(conn *pkg.Conn) bool { rtsp.HandleFunc(func(conn *pkg.Conn) bool {
waitersMu.Lock() waitersMu.Lock()
waiter := waiters[conn.URL.Path] waiter := waiters[conn.URL.Path]
@@ -43,30 +38,66 @@ func Init() {
} }
}) })
streams.HandleFunc("exec", Handle) streams.HandleFunc("exec", execHandle)
log = app.GetLogger("exec") log = app.GetLogger("exec")
} }
func Handle(url string) (core.Producer, error) { func execHandle(url string) (core.Producer, error) {
sum := md5.Sum([]byte(url)) var path string
path := "/" + hex.EncodeToString(sum[:])
url = strings.Replace( args := shell.QuoteSplit(url[5:]) // remove `exec:`
url, "{output}", "rtsp://localhost:"+rtsp.Port+path, 1, for i, arg := range args {
) if arg == "{output}" {
if rtsp.Port == "" {
// remove `exec:` return nil, errors.New("rtsp module disabled")
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() { if log.Debug().Enabled() {
cmd.Stderr = os.Stderr 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) ch := make(chan core.Producer)
waitersMu.Lock() waitersMu.Lock()
+26
View File
@@ -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())
}
+68
View File
@@ -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,
})
}
}
+60
View File
@@ -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)
}
}
}
+50
View File
@@ -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)
}
}
+70
View File
@@ -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)
}
@@ -1,16 +1,15 @@
package ffmpeg package ffmpeg
import ( import (
"bytes"
"errors" "errors"
"github.com/AlexxIT/go2rtc/cmd/app" "github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/cmd/exec" "github.com/AlexxIT/go2rtc/internal/ffmpeg/device"
"github.com/AlexxIT/go2rtc/cmd/ffmpeg/device" "github.com/AlexxIT/go2rtc/internal/ffmpeg/hardware"
"github.com/AlexxIT/go2rtc/cmd/rtsp" "github.com/AlexxIT/go2rtc/internal/rtsp"
"github.com/AlexxIT/go2rtc/cmd/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/ffmpeg"
"net/url" "net/url"
"strconv"
"strings" "strings"
) )
@@ -32,11 +31,11 @@ func Init() {
if args == nil { if args == nil {
return nil, errors.New("can't generate ffmpeg command") 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(defaults["bin"])
device.Init() hardware.Init(defaults["bin"])
} }
var defaults = map[string]string{ var defaults = map[string]string{
@@ -46,21 +45,24 @@ var defaults = map[string]string{
// inputs // inputs
"file": "-re -i {input}", "file": "-re -i {input}",
"http": "-fflags nobuffer -flags low_delay -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}", "rtsp/udp": "-fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -i {input}",
// output // 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` // `-preset superfast` - we can't use ultrafast because it doesn't support `-profile main -level 4.1`
// `-tune zerolatency` - for minimal latency // `-tune zerolatency` - for minimal latency
// `-profile high -level 4.1` - most used streaming profile // `-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", "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 high -level:v 5.1 -preset:v superfast -tune:v zerolatency", "h265": "-c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency",
"mjpeg": "-c:v mjpeg -force_duplicated_matrix:v 1 -huffman:v 0 -pix_fmt:v yuvj420p", "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": "-c:a pcm_mulaw -ar:a 8000 -ac:a 1",
"pcmu/16000": "-c:a pcm_mulaw -ar:a 16000 -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", "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": "-c:a aac", // keep sample rate and channels
"aac/16000": "-c:a aac -ar:a 16000 -ac:a 1", "aac/16000": "-c:a aac -ar:a 16000 -ac:a 1",
"mp3": "-c:a libmp3lame -q:a 8", "mp3": "-c:a libmp3lame -q:a 8",
"pcm": "-c:a pcm_s16be", "pcm": "-c:a pcm_s16be -ar:a 8000 -ac:a 1",
"pcm/8000": "-c:a pcm_s16be -ar:a 8000 -ac:a 1",
"pcm/16000": "-c:a pcm_s16be -ar:a 16000 -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", "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) return strings.Replace(template, "{input}", s, 1)
} }
func parseArgs(s string) *Args { func parseArgs(s string) *ffmpeg.Args {
// init FFmpeg arguments // init FFmpeg arguments
args := &Args{ args := &ffmpeg.Args{
bin: defaults["bin"], Bin: defaults["bin"],
global: defaults["global"], Global: defaults["global"],
output: defaults["output"], Output: defaults["output"],
} }
var query url.Values var query url.Values
if i := strings.IndexByte(s, '#'); i > 0 { if i := strings.IndexByte(s, '#'); i > 0 {
query = parseQuery(s[i+1:]) query = streams.ParseQuery(s[i+1:])
args.video = len(query["video"]) args.Video = len(query["video"])
args.audio = len(query["audio"]) args.Audio = len(query["audio"])
s = s[:i] s = s[:i]
} }
@@ -139,46 +140,46 @@ func parseArgs(s string) *Args {
if i := strings.Index(s, "://"); i > 0 { if i := strings.Index(s, "://"); i > 0 {
switch s[:i] { switch s[:i] {
case "http", "https", "rtmp": case "http", "https", "rtmp":
args.input = inputTemplate("http", s, query) args.Input = inputTemplate("http", s, query)
case "rtsp", "rtsps": case "rtsp", "rtsps":
// https://ffmpeg.org/ffmpeg-protocols.html#rtsp // https://ffmpeg.org/ffmpeg-protocols.html#rtsp
// skip unnecessary input tracks // skip unnecessary input tracks
switch { switch {
case (args.video > 0 && args.audio > 0) || (args.video == 0 && args.audio == 0): case (args.Video > 0 && args.Audio > 0) || (args.Video == 0 && args.Audio == 0):
args.input = "-allowed_media_types video+audio " args.Input = "-allowed_media_types video+audio "
case args.video > 0: case args.Video > 0:
args.input = "-allowed_media_types video " args.Input = "-allowed_media_types video "
case args.audio > 0: case args.Audio > 0:
args.input = "-allowed_media_types audio " args.Input = "-allowed_media_types audio "
} }
args.input += inputTemplate("rtsp", s, query) args.Input += inputTemplate("rtsp", s, query)
default: default:
args.input = "-i " + s args.Input = "-i " + s
} }
} else if streams.Get(s) != nil { } else if streams.Get(s) != nil {
s = "rtsp://localhost:" + rtsp.Port + "/" + s s = "rtsp://127.0.0.1:" + rtsp.Port + "/" + s
switch { switch {
case args.video > 0 && args.audio == 0: case args.Video > 0 && args.Audio == 0:
s += "?video" s += "?video"
case args.audio > 0 && args.video == 0: case args.Audio > 0 && args.Video == 0:
s += "?audio" s += "?audio"
default: default:
s += "?video&audio" s += "?video&audio"
} }
args.input = inputTemplate("rtsp", s, query) args.Input = inputTemplate("rtsp", s, query)
} else if strings.HasPrefix(s, "device?") { } else if strings.HasPrefix(s, "device?") {
var err error var err error
args.input, err = device.GetInput(s) args.Input, err = device.GetInput(s)
if err != nil { if err != nil {
return nil return nil
} }
} else { } else {
args.input = inputTemplate("file", s, query) args.Input = inputTemplate("file", s, query)
} }
if query["async"] != nil { 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: // Parse query params:
@@ -226,7 +227,7 @@ func parseArgs(s string) *Args {
} }
// 3. Process video codecs // 3. Process video codecs
if args.video > 0 { if args.Video > 0 {
for _, video := range query["video"] { for _, video := range query["video"] {
if video != "copy" { if video != "copy" {
if codec := defaults[video]; codec != "" { if codec := defaults[video]; codec != "" {
@@ -243,7 +244,7 @@ func parseArgs(s string) *Args {
} }
// 4. Process audio codecs // 4. Process audio codecs
if args.audio > 0 { if args.Audio > 0 {
for _, audio := range query["audio"] { for _, audio := range query["audio"] {
if audio != "copy" { if audio != "copy" {
if codec := defaults[audio]; codec != "" { if codec := defaults[audio]; codec != "" {
@@ -260,99 +261,20 @@ func parseArgs(s string) *Args {
} }
if query["hardware"] != nil { 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") 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 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()
}
@@ -7,8 +7,17 @@ import (
func TestParseArgs(t *testing.T) { func TestParseArgs(t *testing.T) {
args := parseArgs("rtsp://example.com#video=h264#rotate=180") 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") 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()) 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())
} }
+137
View File
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
+12
View File
@@ -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()
}
+105
View File
@@ -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"`
}
+87 -20
View File
@@ -4,15 +4,17 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/AlexxIT/go2rtc/cmd/api" "github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/cmd/app" "github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/cmd/roborock" "github.com/AlexxIT/go2rtc/internal/roborock"
"github.com/AlexxIT/go2rtc/cmd/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/hass"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"net/http" "net/http"
"os" "os"
"path" "path"
"sync"
) )
func Init() { func Init() {
@@ -29,10 +31,15 @@ func Init() {
log = app.GetLogger("hass") 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) { api.HandleFunc("api/hass", func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "no hass config", http.StatusNotFound) http.Error(w, "no hass config", http.StatusNotFound)
}) })
@@ -40,18 +47,35 @@ func Init() {
} }
api.HandleFunc("api/hass", func(w http.ResponseWriter, _ *http.Request) { 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 var items []api.Stream
for name, url := range entries { for name, url := range entities {
items = append(items, api.Stream{Name: name, URL: url}) items = append(items, api.Stream{Name: name, URL: url})
} }
api.ResponseStreams(w, items) api.ResponseStreams(w, items)
}) })
streams.HandleFunc("hass", func(url string) (core.Producer, error) { streams.HandleFunc("hass", func(url string) (core.Producer, error) {
if hurl := entries[url[5:]]; hurl != "" { // check entity by name
return streams.GetProducer(hurl) 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 // 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 // support load cameras from Hass config file
filename := path.Join(config, ".storage/core.config_entries") filename := path.Join(config, ".storage/core.config_entries")
b, err := os.ReadFile(filename) b, err := os.ReadFile(filename)
if err != nil { if err != nil {
return nil return err
} }
var storage struct { var storage struct {
@@ -88,11 +112,9 @@ func importEntries(config string) map[string]string {
} }
if err = json.Unmarshal(b, &storage); err != nil { if err = json.Unmarshal(b, &storage); err != nil {
return nil return err
} }
urls := map[string]string{}
for _, entrie := range storage.Data.Entries { for _, entrie := range storage.Data.Entries {
switch entrie.Domain { switch entrie.Domain {
case "generic": case "generic":
@@ -102,7 +124,7 @@ func importEntries(config string) map[string]string {
if err = json.Unmarshal(entrie.Options, &options); err != nil { if err = json.Unmarshal(entrie.Options, &options); err != nil {
continue continue
} }
urls[entrie.Title] = options.StreamSource entities[entrie.Title] = options.StreamSource
case "homekit_controller": case "homekit_controller":
if !bytes.Contains(entrie.Data, []byte("iOSPairingId")) { 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 { if err = json.Unmarshal(entrie.Data, &data); err != nil {
continue 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", "homekit://%s:%d?client_id=%s&client_private=%s%s&device_id=%s&device_public=%s",
data.DeviceHost, data.DevicePort, data.DeviceHost, data.DevicePort,
data.ClientID, data.ClientPrivate, data.ClientPublic, data.ClientID, data.ClientPrivate, data.ClientPublic,
@@ -131,15 +153,60 @@ func importEntries(config string) map[string]string {
case "roborock": case "roborock":
_ = json.Unmarshal(entrie.Data, &roborock.Auth) _ = 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: default:
continue 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) //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 log zerolog.Logger
var once sync.Once
+20 -3
View File
@@ -2,14 +2,15 @@ package hls
import ( import (
"fmt" "fmt"
"github.com/AlexxIT/go2rtc/cmd/api" "github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/cmd/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/mp4" "github.com/AlexxIT/go2rtc/pkg/mp4"
"github.com/AlexxIT/go2rtc/pkg/mpegts" "github.com/AlexxIT/go2rtc/pkg/mpegts"
"github.com/AlexxIT/go2rtc/pkg/tcp" "github.com/AlexxIT/go2rtc/pkg/tcp"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"net/http" "net/http"
"strings"
"sync" "sync"
"time" "time"
) )
@@ -48,6 +49,9 @@ const keepalive = 5 * time.Second
var sessions = map[string]*Session{} 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) { func handlerStream(w http.ResponseWriter, r *http.Request) {
// CORS important for Chromecast // CORS important for Chromecast
w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Origin", "*")
@@ -128,11 +132,16 @@ segment.ts?id=` + sid + `&n=%d
segment.ts?id=` + sid + `&n=%d` segment.ts?id=` + sid + `&n=%d`
} }
sessionsMu.Lock()
sessions[sid] = session 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 // bandwidth important for Safari, codecs useful for smooth playback
data := []byte(`#EXTM3U 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) hls/playlist.m3u8?id=` + sid)
if _, err := w.Write(data); err != nil { 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") sid := r.URL.Query().Get("id")
sessionsMu.RLock()
session := sessions[sid] session := sessions[sid]
sessionsMu.RUnlock()
if session == nil { if session == nil {
http.NotFound(w, r) http.NotFound(w, r)
return return
@@ -173,7 +184,9 @@ func handlerSegmentTS(w http.ResponseWriter, r *http.Request) {
} }
sid := r.URL.Query().Get("id") sid := r.URL.Query().Get("id")
sessionsMu.RLock()
session := sessions[sid] session := sessions[sid]
sessionsMu.RUnlock()
if session == nil { if session == nil {
http.NotFound(w, r) http.NotFound(w, r)
return return
@@ -212,7 +225,9 @@ func handlerInit(w http.ResponseWriter, r *http.Request) {
} }
sid := r.URL.Query().Get("id") sid := r.URL.Query().Get("id")
sessionsMu.RLock()
session := sessions[sid] session := sessions[sid]
sessionsMu.RUnlock()
if session == nil { if session == nil {
http.NotFound(w, r) http.NotFound(w, r)
return return
@@ -233,7 +248,9 @@ func handlerSegmentMP4(w http.ResponseWriter, r *http.Request) {
} }
sid := r.URL.Query().Get("id") sid := r.URL.Query().Get("id")
sessionsMu.RLock()
session := sessions[sid] session := sessions[sid]
sessionsMu.RUnlock()
if session == nil { if session == nil {
http.NotFound(w, r) http.NotFound(w, r)
return return
@@ -3,8 +3,8 @@ package homekit
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/AlexxIT/go2rtc/cmd/app/store" "github.com/AlexxIT/go2rtc/internal/app/store"
"github.com/AlexxIT/go2rtc/cmd/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/hap" "github.com/AlexxIT/go2rtc/pkg/hap"
"github.com/AlexxIT/go2rtc/pkg/hap/mdns" "github.com/AlexxIT/go2rtc/pkg/hap/mdns"
"net/http" "net/http"
@@ -1,10 +1,10 @@
package homekit package homekit
import ( import (
"github.com/AlexxIT/go2rtc/cmd/api" "github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/cmd/app" "github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/cmd/srtp" "github.com/AlexxIT/go2rtc/internal/srtp"
"github.com/AlexxIT/go2rtc/cmd/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/homekit" "github.com/AlexxIT/go2rtc/pkg/homekit"
"github.com/rs/zerolog" "github.com/rs/zerolog"
+95
View File
@@ -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
}
+1 -1
View File
@@ -1,7 +1,7 @@
package isapi package isapi
import ( import (
"github.com/AlexxIT/go2rtc/cmd/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/isapi" "github.com/AlexxIT/go2rtc/pkg/isapi"
) )
@@ -1,7 +1,7 @@
package ivideon package ivideon
import ( import (
"github.com/AlexxIT/go2rtc/cmd/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/ivideon" "github.com/AlexxIT/go2rtc/pkg/ivideon"
"strings" "strings"
+23 -6
View File
@@ -2,14 +2,18 @@ package mjpeg
import ( import (
"errors" "errors"
"github.com/AlexxIT/go2rtc/cmd/api" "github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/cmd/streams" "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/mjpeg"
"github.com/AlexxIT/go2rtc/pkg/tcp" "github.com/AlexxIT/go2rtc/pkg/tcp"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
"time"
) )
func Init() { func Init() {
@@ -29,14 +33,16 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
exit := make(chan []byte) exit := make(chan []byte)
cons := &mjpeg.Consumer{ cons := &magic.Keyframe{
RemoteAddr: tcp.RemoteAddr(r), RemoteAddr: tcp.RemoteAddr(r),
UserAgent: r.UserAgent(), UserAgent: r.UserAgent(),
} }
cons.Listen(func(msg any) { cons.Listen(func(msg any) {
switch msg := msg.(type) { if b, ok := msg.([]byte); ok {
case []byte: select {
exit <- msg case exit <- b:
default:
}
} }
}) })
@@ -49,6 +55,17 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
stream.RemoveConsumer(cons) 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 := w.Header()
h.Set("Content-Type", "image/jpeg") h.Set("Content-Type", "image/jpeg")
h.Set("Content-Length", strconv.Itoa(len(data))) h.Set("Content-Length", strconv.Itoa(len(data)))
+18 -19
View File
@@ -4,13 +4,11 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"github.com/AlexxIT/go2rtc/cmd/api" "github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/cmd/app" "github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/cmd/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/mp4" "github.com/AlexxIT/go2rtc/pkg/mp4"
"github.com/AlexxIT/go2rtc/pkg/tcp" "github.com/AlexxIT/go2rtc/pkg/tcp"
"github.com/rs/zerolog" "github.com/rs/zerolog"
@@ -61,6 +59,7 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
if err := stream.AddConsumer(cons); err != nil { if err := stream.AddConsumer(cons); err != nil {
log.Error().Err(err).Caller().Send() log.Error().Err(err).Caller().Send()
http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@@ -82,15 +81,8 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query() query := r.URL.Query()
// Chrome has Safari in UA, so check first Chrome and later Safari
ua := r.UserAgent() ua := r.UserAgent()
if strings.Contains(ua, " Chrome/") { if strings.Contains(ua, " Safari/") && !strings.Contains(ua, " Chrome/") && !query.Has("duration") {
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") {
// auto redirect to HLS/fMP4 format, because Safari not support MP4 stream // auto redirect to HLS/fMP4 format, because Safari not support MP4 stream
url := "stream.m3u8?" + r.URL.RawQuery url := "stream.m3u8?" + r.URL.RawQuery
if !query.Has("mp4") { if !query.Has("mp4") {
@@ -113,15 +105,15 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) {
cons := &mp4.Consumer{ cons := &mp4.Consumer{
RemoteAddr: tcp.RemoteAddr(r), RemoteAddr: tcp.RemoteAddr(r),
UserAgent: r.UserAgent(), UserAgent: r.UserAgent(),
Medias: core.ParseQuery(r.URL.Query()), Medias: mp4.ParseQuery(r.URL.Query()),
} }
mu := &sync.Mutex{}
cons.Listen(func(msg any) { cons.Listen(func(msg any) {
if exit == nil {
return
}
if data, ok := msg.([]byte); ok { if data, ok := msg.([]byte); ok {
mu.Lock() if _, err := w.Write(data); err != nil {
defer mu.Unlock()
if _, err := w.Write(data); err != nil && exit != nil {
select { select {
case exit <- err: case exit <- err:
default: default:
@@ -133,6 +125,7 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) {
if err := stream.AddConsumer(cons); err != nil { if err := stream.AddConsumer(cons); err != nil {
log.Error().Err(err).Caller().Send() log.Error().Err(err).Caller().Send()
http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@@ -143,11 +136,13 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) {
data, err := cons.Init() data, err := cons.Init()
if err != nil { if err != nil {
log.Error().Err(err).Caller().Send() log.Error().Err(err).Caller().Send()
http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
if _, err = w.Write(data); err != nil { if _, err = w.Write(data); err != nil {
log.Error().Err(err).Caller().Send() log.Error().Err(err).Caller().Send()
http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@@ -158,7 +153,10 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) {
if i, _ := strconv.Atoi(s); i > 0 { if i, _ := strconv.Atoi(s); i > 0 {
duration = time.AfterFunc(time.Second*time.Duration(i), func() { duration = time.AfterFunc(time.Second*time.Duration(i), func() {
if exit != nil { if exit != nil {
exit <- nil select {
case exit <- nil:
default:
}
exit = nil exit = nil
} }
}) })
@@ -166,6 +164,7 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) {
} }
err = <-exit err = <-exit
exit = nil
log.Trace().Err(err).Caller().Send() log.Trace().Err(err).Caller().Send()
+8 -2
View File
@@ -2,8 +2,8 @@ package mp4
import ( import (
"errors" "errors"
"github.com/AlexxIT/go2rtc/cmd/api" "github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/cmd/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/mp4" "github.com/AlexxIT/go2rtc/pkg/mp4"
"github.com/AlexxIT/go2rtc/pkg/tcp" "github.com/AlexxIT/go2rtc/pkg/tcp"
@@ -110,6 +110,12 @@ func parseMedias(codecs string, parseAudio bool) (medias []*core.Media) {
case mp4.MimeAAC: case mp4.MimeAAC:
codec := &core.Codec{Name: core.CodecAAC} codec := &core.Codec{Name: core.CodecAAC}
audios = append(audios, codec) 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: case mp4.MimeOpus:
codec := &core.Codec{Name: core.CodecOpus} codec := &core.Codec{Name: core.CodecOpus}
audios = append(audios, codec) audios = append(audios, codec)
@@ -1,8 +1,8 @@
package mpegts package mpegts
import ( import (
"github.com/AlexxIT/go2rtc/cmd/api" "github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/cmd/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/mpegts" "github.com/AlexxIT/go2rtc/pkg/mpegts"
"net/http" "net/http"
) )
+55
View File
@@ -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)
}
@@ -2,8 +2,8 @@ package ngrok
import ( import (
"fmt" "fmt"
"github.com/AlexxIT/go2rtc/cmd/app" "github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/cmd/webrtc" "github.com/AlexxIT/go2rtc/internal/webrtc"
"github.com/AlexxIT/go2rtc/pkg/ngrok" "github.com/AlexxIT/go2rtc/pkg/ngrok"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"net" "net"
+194
View File
@@ -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)
}
@@ -2,8 +2,8 @@ package roborock
import ( import (
"fmt" "fmt"
"github.com/AlexxIT/go2rtc/cmd/api" "github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/cmd/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/roborock" "github.com/AlexxIT/go2rtc/pkg/roborock"
"net/http" "net/http"
+2 -2
View File
@@ -1,8 +1,8 @@
package rtmp package rtmp
import ( import (
"github.com/AlexxIT/go2rtc/cmd/api" "github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/cmd/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/rtmp" "github.com/AlexxIT/go2rtc/pkg/rtmp"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
+47 -18
View File
@@ -6,10 +6,9 @@ import (
"net/url" "net/url"
"strings" "strings"
"github.com/AlexxIT/go2rtc/cmd/app" "github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/cmd/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/mp4"
"github.com/AlexxIT/go2rtc/pkg/rtsp" "github.com/AlexxIT/go2rtc/pkg/rtsp"
"github.com/AlexxIT/go2rtc/pkg/tcp" "github.com/AlexxIT/go2rtc/pkg/tcp"
"github.com/rs/zerolog" "github.com/rs/zerolog"
@@ -22,6 +21,7 @@ func Init() {
Username string `yaml:"username" json:"-"` Username string `yaml:"username" json:"-"`
Password string `yaml:"password" json:"-"` Password string `yaml:"password" json:"-"`
DefaultQuery string `yaml:"default_query" json:"default_query"` DefaultQuery string `yaml:"default_query" json:"default_query"`
PacketSize uint16 `yaml:"pkt_size"`
} `yaml:"rtsp"` } `yaml:"rtsp"`
} }
@@ -56,7 +56,7 @@ func Init() {
log.Info().Str("addr", address).Msg("[rtsp] listen") log.Info().Str("addr", address).Msg("[rtsp] listen")
if query, err := url.ParseQuery(conf.Mod.DefaultQuery); err == nil { if query, err := url.ParseQuery(conf.Mod.DefaultQuery); err == nil {
defaultMedias = mp4.ParseQuery(query) defaultMedias = ParseQuery(query)
} }
go func() { go func() {
@@ -67,6 +67,7 @@ func Init() {
} }
c := rtsp.NewServer(conn) c := rtsp.NewServer(conn)
c.PacketSize = conf.Mod.PacketSize
// skip check auth for localhost // skip check auth for localhost
if conf.Mod.Username != "" && !conn.RemoteAddr().(*net.TCPAddr).IP.IsLoopback() { if conf.Mod.Username != "" && !conn.RemoteAddr().(*net.TCPAddr).IP.IsLoopback() {
c.Auth(conf.Mod.Username, conf.Mod.Password) c.Auth(conf.Mod.Username, conf.Mod.Password)
@@ -90,19 +91,19 @@ var log zerolog.Logger
var handlers []Handler var handlers []Handler
var defaultMedias []*core.Media var defaultMedias []*core.Media
func rtspHandler(url string) (core.Producer, error) { func rtspHandler(rawURL string) (core.Producer, error) {
backchannel := true rawURL, rawQuery, _ := strings.Cut(rawURL, "#")
if i := strings.IndexByte(url, '#'); i > 0 { conn := rtsp.NewClient(rawURL)
if url[i+1:] == "backchannel=0" { conn.Backchannel = true
backchannel = false
}
url = url[:i]
}
conn := rtsp.NewClient(url)
conn.UserAgent = app.UserAgent 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() { if log.Trace().Enabled() {
conn.Listen(func(msg any) { conn.Listen(func(msg any) {
switch msg := msg.(type) { switch msg := msg.(type) {
@@ -120,12 +121,11 @@ func rtspHandler(url string) (core.Producer, error) {
return nil, err return nil, err
} }
conn.Backchannel = backchannel
if err := conn.Describe(); err != nil { if err := conn.Describe(); err != nil {
if !backchannel { if !conn.Backchannel {
return nil, err 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 // second try without backchannel, we need to reconnect
conn.Backchannel = false conn.Backchannel = false
@@ -174,13 +174,18 @@ func tcpHandler(conn *rtsp.Conn) {
conn.SessionName = app.UserAgent conn.SessionName = app.UserAgent
conn.Medias = mp4.ParseQuery(conn.URL.Query()) query := conn.URL.Query()
conn.Medias = ParseQuery(query)
if conn.Medias == nil { if conn.Medias == nil {
for _, media := range defaultMedias { for _, media := range defaultMedias {
conn.Medias = append(conn.Medias, media.Clone()) 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 { if err := stream.AddConsumer(conn); err != nil {
log.Warn().Err(err).Str("stream", name).Msg("[rtsp]") log.Warn().Err(err).Str("stream", name).Msg("[rtsp]")
return return
@@ -242,3 +247,27 @@ func tcpHandler(conn *rtsp.Conn) {
_ = conn.Close() _ = 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)
}
+1 -1
View File
@@ -1,7 +1,7 @@
package srtp package srtp
import ( import (
"github.com/AlexxIT/go2rtc/cmd/app" "github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/pkg/srtp" "github.com/AlexxIT/go2rtc/pkg/srtp"
"net" "net"
) )
+19
View File
@@ -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
}
@@ -2,11 +2,12 @@ package streams
import ( import (
"encoding/json" "encoding/json"
"github.com/AlexxIT/go2rtc/cmd/api" "github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/cmd/app" "github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/cmd/app/store" "github.com/AlexxIT/go2rtc/internal/app/store"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"net/http" "net/http"
"net/url"
) )
func Init() { func Init() {
@@ -39,6 +40,20 @@ func New(name string, source any) *Stream {
return 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 { func GetOrNew(src string) *Stream {
if stream, ok := streams[src]; ok { if stream, ok := streams[src]; ok {
return stream return stream
@@ -53,6 +68,13 @@ func GetOrNew(src string) *Stream {
return New(src, src) 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) { func streamsHandler(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query() query := r.URL.Query()
src := query.Get("src") src := query.Get("src")
@@ -85,11 +107,12 @@ func streamsHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
if stream := Get(name); stream != nil { // support {input} templates: https://github.com/AlexxIT/go2rtc#module-hass
stream.SetSource(src) stream := Get(name)
} else { if stream == nil {
New(name, src) stream = NewTemplate(name, src)
} }
stream.SetSource(src)
case "POST": case "POST":
// with dst - redirect source to dst // with dst - redirect source to dst
@@ -30,8 +30,6 @@ type Producer struct {
receivers []*core.Receiver receivers []*core.Receiver
senders []*core.Receiver senders []*core.Receiver
lastErr error
state state state state
mu sync.Mutex mu sync.Mutex
workerID int workerID int
@@ -58,6 +56,10 @@ func (p *Producer) GetMedias() []*core.Media {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
if p.conn == nil {
return nil
}
return p.conn.GetMedias() return p.conn.GetMedias()
} }
@@ -3,7 +3,6 @@ package streams
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"strings" "strings"
"sync" "sync"
@@ -31,8 +30,6 @@ func NewStream(source any) *Stream {
s.producers = append(s.producers, prod) s.producers = append(s.producers, prod)
} }
return s return s
case *Stream:
return source
case map[string]any: case map[string]any:
return NewStream(source["url"]) return NewStream(source["url"])
case nil: case nil:
@@ -50,24 +47,28 @@ func (s *Stream) SetSource(source string) {
func (s *Stream) AddConsumer(cons core.Consumer) (err error) { func (s *Stream) AddConsumer(cons core.Consumer) (err error) {
// support for multiple simultaneous requests from different consumers // 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 statErrors []error
var statMedias []*core.Media
var codecs string var statProds []*Producer // matched producers for consumer
// Step 1. Get consumer medias // Step 1. Get consumer medias
for _, consMedia := range cons.GetMedias() { for _, consMedia := range cons.GetMedias() {
log.Trace().Msgf("[streams] check cons=%d media=%s", consN, consMedia)
producers: producers:
for _, prod := range s.producers { for prodN, prod := range s.producers {
if err = prod.Dial(); err != nil { if err = prod.Dial(); err != nil {
log.Trace().Err(err).Msgf("[streams] skip prod=%s", prod.url)
statErrors = append(statErrors, err)
continue continue
} }
// Step 2. Get producer medias (not tracks yet) // Step 2. Get producer medias (not tracks yet)
for _, prodMedia := range prod.GetMedias() { 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 // Step 3. Match consumer/producer codecs list
prodCodec, consCodec := prodMedia.MatchMedia(consMedia) prodCodec, consCodec := prodMedia.MatchMedia(consMedia)
@@ -79,6 +80,8 @@ func (s *Stream) AddConsumer(cons core.Consumer) (err error) {
switch prodMedia.Direction { switch prodMedia.Direction {
case core.DirectionRecvonly: case core.DirectionRecvonly:
log.Trace().Msgf("[streams] match prod=%d => cons=%d", prodN, consN)
// Step 4. Get recvonly track from producer // Step 4. Get recvonly track from producer
if track, err = prod.GetTrack(prodMedia, prodCodec); err != nil { if track, err = prod.GetTrack(prodMedia, prodCodec); err != nil {
log.Info().Err(err).Msg("[streams] can't get track") 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: case core.DirectionSendonly:
log.Trace().Msgf("[streams] match cons=%d => prod=%d", consN, prodN)
// Step 4. Get recvonly track from consumer (backchannel) // Step 4. Get recvonly track from consumer (backchannel)
if track, err = cons.(core.Producer).GetTrack(consMedia, consCodec); err != nil { if track, err = cons.(core.Producer).GetTrack(consMedia, consCodec); err != nil {
log.Info().Err(err).Msg("[streams] can't get track") 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() { if !consMedia.MatchAll() {
break producers break producers
@@ -117,18 +122,8 @@ func (s *Stream) AddConsumer(cons core.Consumer) (err error) {
s.stopProducers() s.stopProducers()
} }
if len(producers) == 0 { if len(statProds) == 0 {
if len(codecs) > 0 { return formatError(statMedias, statErrors)
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))
} }
s.mu.Lock() s.mu.Lock()
@@ -136,7 +131,7 @@ func (s *Stream) AddConsumer(cons core.Consumer) (err error) {
s.mu.Unlock() s.mu.Unlock()
// there may be duplicates, but that's not a problem // there may be duplicates, but that's not a problem
for _, prod := range producers { for _, prod := range statProds {
prod.start() prod.start()
} }
@@ -213,9 +208,12 @@ func (s *Stream) MarshalJSON() ([]byte, error) {
return json.Marshal(info) return json.Marshal(info)
} }
func collectCodecs(media *core.Media, codecs *string) { func formatError(statMedias []*core.Media, statErrors []error) error {
var text string
for _, media := range statMedias {
if media.Direction == core.DirectionRecvonly { if media.Direction == core.DirectionRecvonly {
return continue
} }
for _, codec := range media.Codecs { for _, codec := range media.Codecs {
@@ -223,12 +221,34 @@ func collectCodecs(media *core.Media, codecs *string) {
if name == core.CodecAAC { if name == core.CodecAAC {
name = "AAC" name = "AAC"
} }
if strings.Contains(*codecs, name) { if strings.Contains(text, name) {
continue continue
} }
if len(*codecs) > 0 { if len(text) > 0 {
*codecs += "," text += ","
} }
*codecs += name text += 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")
}
+19
View File
@@ -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)
}
+1 -1
View File
@@ -1,7 +1,7 @@
package tapo package tapo
import ( import (
"github.com/AlexxIT/go2rtc/cmd/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/tapo" "github.com/AlexxIT/go2rtc/pkg/tapo"
) )
@@ -1,7 +1,7 @@
package webrtc package webrtc
import ( import (
"github.com/AlexxIT/go2rtc/cmd/api" "github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/pkg/webrtc" "github.com/AlexxIT/go2rtc/pkg/webrtc"
"github.com/pion/sdp/v3" "github.com/pion/sdp/v3"
"strconv" "strconv"
@@ -2,7 +2,7 @@ package webrtc
import ( import (
"errors" "errors"
"github.com/AlexxIT/go2rtc/cmd/api" "github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/webrtc" "github.com/AlexxIT/go2rtc/pkg/webrtc"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
@@ -2,9 +2,9 @@ package webrtc
import ( import (
"errors" "errors"
"github.com/AlexxIT/go2rtc/cmd/api" "github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/cmd/app" "github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/cmd/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/webrtc" "github.com/AlexxIT/go2rtc/pkg/webrtc"
pion "github.com/pion/webrtc/v3" pion "github.com/pion/webrtc/v3"
@@ -2,7 +2,7 @@ package webrtc
import ( import (
"encoding/json" "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/core"
"github.com/AlexxIT/go2rtc/pkg/webrtc" "github.com/AlexxIT/go2rtc/pkg/webrtc"
pion "github.com/pion/webrtc/v3" pion "github.com/pion/webrtc/v3"
@@ -3,10 +3,10 @@ package webtorrent
import ( import (
"errors" "errors"
"fmt" "fmt"
"github.com/AlexxIT/go2rtc/cmd/api" "github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/cmd/app" "github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/cmd/streams" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/cmd/webrtc" "github.com/AlexxIT/go2rtc/internal/webrtc"
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/webtorrent" "github.com/AlexxIT/go2rtc/pkg/webtorrent"
"github.com/rs/zerolog" "github.com/rs/zerolog"
+31 -35
View File
@@ -1,41 +1,41 @@
package main package main
import ( import (
"github.com/AlexxIT/go2rtc/cmd/api" "github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/cmd/app" "github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/cmd/debug" "github.com/AlexxIT/go2rtc/internal/debug"
"github.com/AlexxIT/go2rtc/cmd/dvrip" "github.com/AlexxIT/go2rtc/internal/dvrip"
"github.com/AlexxIT/go2rtc/cmd/echo" "github.com/AlexxIT/go2rtc/internal/echo"
"github.com/AlexxIT/go2rtc/cmd/exec" "github.com/AlexxIT/go2rtc/internal/exec"
"github.com/AlexxIT/go2rtc/cmd/ffmpeg" "github.com/AlexxIT/go2rtc/internal/ffmpeg"
"github.com/AlexxIT/go2rtc/cmd/hass" "github.com/AlexxIT/go2rtc/internal/hass"
"github.com/AlexxIT/go2rtc/cmd/hls" "github.com/AlexxIT/go2rtc/internal/hls"
"github.com/AlexxIT/go2rtc/cmd/homekit" "github.com/AlexxIT/go2rtc/internal/homekit"
"github.com/AlexxIT/go2rtc/cmd/http" "github.com/AlexxIT/go2rtc/internal/http"
"github.com/AlexxIT/go2rtc/cmd/isapi" "github.com/AlexxIT/go2rtc/internal/isapi"
"github.com/AlexxIT/go2rtc/cmd/ivideon" "github.com/AlexxIT/go2rtc/internal/ivideon"
"github.com/AlexxIT/go2rtc/cmd/mjpeg" "github.com/AlexxIT/go2rtc/internal/mjpeg"
"github.com/AlexxIT/go2rtc/cmd/mp4" "github.com/AlexxIT/go2rtc/internal/mp4"
"github.com/AlexxIT/go2rtc/cmd/mpegts" "github.com/AlexxIT/go2rtc/internal/mpegts"
"github.com/AlexxIT/go2rtc/cmd/ngrok" "github.com/AlexxIT/go2rtc/internal/nest"
"github.com/AlexxIT/go2rtc/cmd/roborock" "github.com/AlexxIT/go2rtc/internal/ngrok"
"github.com/AlexxIT/go2rtc/cmd/rtmp" "github.com/AlexxIT/go2rtc/internal/onvif"
"github.com/AlexxIT/go2rtc/cmd/rtsp" "github.com/AlexxIT/go2rtc/internal/roborock"
"github.com/AlexxIT/go2rtc/cmd/srtp" "github.com/AlexxIT/go2rtc/internal/rtmp"
"github.com/AlexxIT/go2rtc/cmd/streams" "github.com/AlexxIT/go2rtc/internal/rtsp"
"github.com/AlexxIT/go2rtc/cmd/tapo" "github.com/AlexxIT/go2rtc/internal/srtp"
"github.com/AlexxIT/go2rtc/cmd/tcp" "github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/cmd/webrtc" "github.com/AlexxIT/go2rtc/internal/tapo"
"github.com/AlexxIT/go2rtc/cmd/webtorrent" "github.com/AlexxIT/go2rtc/internal/webrtc"
"os" "github.com/AlexxIT/go2rtc/internal/webtorrent"
"os/signal" "github.com/AlexxIT/go2rtc/pkg/shell"
"syscall"
) )
func main() { func main() {
app.Init() // init config and logs app.Init() // init config and logs
api.Init() // init HTTP API server api.Init() // init HTTP API server
streams.Init() // load streams list streams.Init() // load streams list
onvif.Init()
rtsp.Init() // add support RTSP client and RTSP server rtsp.Init() // add support RTSP client and RTSP server
rtmp.Init() // add support RTMP client rtmp.Init() // add support RTMP client
@@ -50,7 +50,7 @@ func main() {
isapi.Init() isapi.Init()
mpegts.Init() mpegts.Init()
roborock.Init() roborock.Init()
tcp.Init() nest.Init()
srtp.Init() srtp.Init()
homekit.Init() homekit.Init()
@@ -64,9 +64,5 @@ func main() {
ngrok.Init() ngrok.Init()
debug.Init() debug.Init()
sigs := make(chan os.Signal, 1) shell.RunUntilSignal()
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs
println("exit OK")
} }
+10 -8
View File
@@ -68,7 +68,7 @@ func (c *Codec) Match(remote *Codec) bool {
} }
func UnmarshalCodec(md *sdp.MediaDescription, payloadType string) *Codec { 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 { for _, attr := range md.Attributes {
switch { switch {
@@ -78,7 +78,7 @@ func UnmarshalCodec(md *sdp.MediaDescription, payloadType string) *Codec {
c.Name = strings.ToUpper(ss[0]) c.Name = strings.ToUpper(ss[0])
// fix tailing space: `a=rtpmap:96 H264/90000 ` // 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" { if len(ss) == 3 && ss[2] == "2" {
c.Channels = 2 c.Channels = 2
@@ -99,9 +99,16 @@ func UnmarshalCodec(md *sdp.MediaDescription, payloadType string) *Codec {
case "8": case "8":
c.Name = CodecPCMA c.Name = CodecPCMA
c.ClockRate = 8000 c.ClockRate = 8000
case "10":
c.Name = CodecPCM
c.ClockRate = 44100
c.Channels = 2
case "11":
c.Name = CodecPCM
c.ClockRate = 44100
case "14": case "14":
c.Name = CodecMP3 c.Name = CodecMP3
c.ClockRate = 44100 c.ClockRate = 90000 // it's not real sample rate
case "26": case "26":
c.Name = CodecJPEG c.Name = CodecJPEG
c.ClockRate = 90000 c.ClockRate = 90000
@@ -113,11 +120,6 @@ func UnmarshalCodec(md *sdp.MediaDescription, payloadType string) *Codec {
return c return c
} }
func atoi(s string) (i int) {
i, _ = strconv.Atoi(s)
return
}
func DecodeH264(fmtp string) string { func DecodeH264(fmtp string) string {
if ps := Between(fmtp, "sprop-parameter-sets=", ","); ps != "" { if ps := Between(fmtp, "sprop-parameter-sets=", ","); ps != "" {
if sps, _ := base64.StdEncoding.DecodeString(ps); len(sps) >= 4 { if sps, _ := base64.StdEncoding.DecodeString(ps); len(sps) >= 4 {
+1
View File
@@ -28,6 +28,7 @@ const (
CodecPCM = "L16" // Linear PCM CodecPCM = "L16" // Linear PCM
CodecELD = "ELD" // AAC-ELD CodecELD = "ELD" // AAC-ELD
CodecFLAC = "FLAC"
CodecAll = "ALL" CodecAll = "ALL"
CodecAny = "ANY" CodecAny = "ANY"
+20
View File
@@ -6,8 +6,14 @@ import (
"runtime" "runtime"
"strconv" "strconv"
"strings" "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-_" const symbols = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_"
// RandString base10 - numbers, base16 - hex, base36 - digits+letters, base64 - URL safe symbols // 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) 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 { func Between(s, sub1, sub2 string) string {
i := strings.Index(s, sub1) i := strings.Index(s, sub1)
if i < 0 { if i < 0 {
@@ -41,6 +56,11 @@ func Between(s, sub1, sub2 string) string {
return s return s
} }
func Atoi(s string) (i int) {
i, _ = strconv.Atoi(s)
return
}
func Assert(ok bool) { func Assert(ok bool) {
if !ok { if !ok {
_, file, line, _ := runtime.Caller(1) _, file, line, _ := runtime.Caller(1)
+12 -1
View File
@@ -82,11 +82,18 @@ func (m *Media) MatchAll() bool {
return false 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 { func GetKind(name string) string {
switch name { switch name {
case CodecH264, CodecH265, CodecVP8, CodecVP9, CodecAV1, CodecJPEG: case CodecH264, CodecH265, CodecVP8, CodecVP9, CodecAV1, CodecJPEG:
return KindVideo return KindVideo
case CodecPCMU, CodecPCMA, CodecAAC, CodecOpus, CodecG722, CodecMP3, CodecELD: case CodecPCMU, CodecPCMA, CodecAAC, CodecOpus, CodecG722, CodecMP3, CodecPCM, CodecELD, CodecFLAC:
return KindAudio return KindAudio
} }
return "" return ""
@@ -129,6 +136,10 @@ func MarshalSDP(name string, medias []*Media) ([]byte, error) {
} }
md.WithCodec(codec.PayloadType, name, codec.ClockRate, codec.Channels, codec.FmtpLine) 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) sd.MediaDescriptions = append(sd.MediaDescriptions, md)
} }
+80
View File
@@ -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()
}
+13
View File
@@ -26,6 +26,19 @@ func AnnexB2AVC(b []byte) []byte {
return b 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 forbiddenZeroBit = 0x80
const nalUnitType = 0x1F const nalUnitType = 0x1F
+4
View File
@@ -94,6 +94,10 @@ func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc {
} }
func RTPPay(mtu uint16, handler core.HandlerFunc) core.HandlerFunc { func RTPPay(mtu uint16, handler core.HandlerFunc) core.HandlerFunc {
if mtu == 0 {
mtu = 1472
}
payloader := &Payloader{IsAVC: true} payloader := &Payloader{IsAVC: true}
sequencer := rtp.NewRandomSequencer() sequencer := rtp.NewRandomSequencer()
mtu -= 12 // rtp.Header size mtu -= 12 // rtp.Header size
+54
View File
@@ -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
}
+4
View File
@@ -76,6 +76,10 @@ func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc {
} }
func RTPPay(mtu uint16, handler core.HandlerFunc) core.HandlerFunc { func RTPPay(mtu uint16, handler core.HandlerFunc) core.HandlerFunc {
if mtu == 0 {
mtu = 1472
}
payloader := &Payloader{} payloader := &Payloader{}
sequencer := rtp.NewRandomSequencer() sequencer := rtp.NewRandomSequencer()
mtu -= 12 // rtp.Header size mtu -= 12 // rtp.Header size
+143
View File
@@ -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")
}
+115
View File
@@ -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()
}
+12 -3
View File
@@ -32,6 +32,16 @@ const (
Mdat = "mdat" Mdat = "mdat"
) )
const (
sampleIsNonSync = 0x10000
sampleDependsOn1 = 0x1000000
sampleDependsOn2 = 0x2000000
SampleVideoIFrame = sampleDependsOn2
SampleVideoNonIFrame = sampleDependsOn1 | sampleIsNonSync
SampleAudio = sampleIsNonSync
)
func (m *Movie) WriteFileType() { func (m *Movie) WriteFileType() {
m.StartAtom(Ftyp) m.StartAtom(Ftyp)
m.WriteString("iso5") m.WriteString("iso5")
@@ -250,7 +260,7 @@ func (m *Movie) WriteAudioTrack(id uint32, codec string, timescale uint32, chann
m.EndAtom() // TRAK 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(Moof)
m.StartAtom(MoofMfhd) m.StartAtom(MoofMfhd)
@@ -279,7 +289,7 @@ func (m *Movie) WriteMovieFragment(seq, tid, duration, size uint32, time uint64)
m.WriteUint32(tid) // track id m.WriteUint32(tid) // track id
m.WriteUint32(duration) // default sample duration m.WriteUint32(duration) // default sample duration
m.WriteUint32(size) // default sample size m.WriteUint32(size) // default sample size
m.WriteUint32(0x2000000) // default sample flags m.WriteUint32(flags) // default sample flags
m.EndAtom() m.EndAtom()
m.StartAtom(MoofTrafTfdt) m.StartAtom(MoofTrafTfdt)
@@ -314,5 +324,4 @@ func (m *Movie) WriteData(b []byte) {
m.StartAtom(Mdat) m.StartAtom(Mdat)
m.Write(b) m.Write(b)
m.EndAtom() m.EndAtom()
} }
+16 -2
View File
@@ -2,6 +2,7 @@ package iso
import ( import (
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/pcm"
) )
func (m *Movie) WriteVideo(codec string, width, height uint16, conf []byte) { 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) { func (m *Movie) WriteAudio(codec string, channels uint16, sampleRate uint32, conf []byte) {
switch codec { switch codec {
case core.CodecAAC, core.CodecMP3: 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: case core.CodecOpus:
m.StartAtom("Opus") m.StartAtom("Opus") // supported in Chrome and Firefox
case core.CodecPCMU: case core.CodecPCMU:
m.StartAtom("ulaw") m.StartAtom("ulaw")
case core.CodecPCMA: case core.CodecPCMA:
@@ -56,6 +59,11 @@ func (m *Movie) WriteAudio(codec string, channels uint16, sampleRate uint32, con
default: default:
panic("unsupported iso audio: " + codec) panic("unsupported iso audio: " + codec)
} }
if channels == 0 {
channels = 1
}
m.Skip(6) m.Skip(6)
m.WriteUint16(1) // data_reference_index m.WriteUint16(1) // data_reference_index
m.Skip(2) // version m.Skip(2) // version
@@ -72,6 +80,10 @@ func (m *Movie) WriteAudio(codec string, channels uint16, sampleRate uint32, con
m.WriteEsdsAAC(conf) m.WriteEsdsAAC(conf)
case core.CodecMP3: case core.CodecMP3:
m.WriteEsdsMP3() m.WriteEsdsMP3()
case core.CodecFLAC:
m.StartAtom("dfLa")
m.Write(pcm.FLACHeader(false, sampleRate))
m.EndAtom()
case core.CodecOpus: case core.CodecOpus:
// don't know what means this magic // don't know what means this magic
m.StartAtom("dOps") m.StartAtom("dOps")
@@ -106,6 +118,7 @@ func (m *Movie) WriteEsdsAAC(conf []byte) {
m.Skip(2) // es id m.Skip(2) // es id
m.Skip(1) // es flags 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(4, 0x80, 0x80, 0x80, size4+header+size5)
m.WriteBytes(0x40) // object id m.WriteBytes(0x40) // object id
m.WriteBytes(0x15) // stream type m.WriteBytes(0x15) // stream type
@@ -139,6 +152,7 @@ func (m *Movie) WriteEsdsMP3() {
m.Skip(2) // es id m.Skip(2) // es id
m.Skip(1) // es flags 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(4, 0x80, 0x80, 0x80, size4)
m.WriteBytes(0x6B) // object id m.WriteBytes(0x6B) // object id
m.WriteBytes(0x15) // stream type m.WriteBytes(0x15) // stream type
+214
View File
@@ -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()
}
+91
View File
@@ -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
}
+41
View File
@@ -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)
}
+26 -18
View File
@@ -6,7 +6,9 @@ import (
"github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/h264" "github.com/AlexxIT/go2rtc/pkg/h264"
"github.com/AlexxIT/go2rtc/pkg/h265" "github.com/AlexxIT/go2rtc/pkg/h265"
"github.com/AlexxIT/go2rtc/pkg/pcm"
"github.com/pion/rtp" "github.com/pion/rtp"
"sync"
) )
type Consumer struct { type Consumer struct {
@@ -19,6 +21,7 @@ type Consumer struct {
senders []*core.Sender senders []*core.Sender
muxer *Muxer muxer *Muxer
mu sync.Mutex
wait byte wait byte
send int 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 { func (c *Consumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error {
trackID := byte(len(c.senders)) trackID := byte(len(c.senders))
handler := core.NewSender(media, track.Codec) codec := track.Codec.Clone()
handler := core.NewSender(media, codec)
switch track.Codec.Name { switch track.Codec.Name {
case core.CodecH264: case core.CodecH264:
@@ -70,10 +74,12 @@ func (c *Consumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv
c.wait = waitNone c.wait = waitNone
} }
// important to use Mutex because right fragment order
c.mu.Lock()
buf := c.muxer.Marshal(trackID, packet) buf := c.muxer.Marshal(trackID, packet)
c.Fire(buf) c.Fire(buf)
c.send += len(buf) c.send += len(buf)
c.mu.Unlock()
} }
if track.Codec.IsRTP() { if track.Codec.IsRTP() {
@@ -97,46 +103,48 @@ func (c *Consumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv
c.wait = waitNone c.wait = waitNone
} }
c.mu.Lock()
buf := c.muxer.Marshal(trackID, packet) buf := c.muxer.Marshal(trackID, packet)
c.Fire(buf) c.Fire(buf)
c.send += len(buf) c.send += len(buf)
c.mu.Unlock()
} }
if track.Codec.IsRTP() { if track.Codec.IsRTP() {
handler.Handler = h265.RTPDepay(track.Codec, handler.Handler) handler.Handler = h265.RTPDepay(track.Codec, handler.Handler)
} }
case core.CodecAAC: default:
handler.Handler = func(packet *rtp.Packet) { handler.Handler = func(packet *rtp.Packet) {
if c.wait != waitNone { if c.wait != waitNone {
return return
} }
c.mu.Lock()
buf := c.muxer.Marshal(trackID, packet) buf := c.muxer.Marshal(trackID, packet)
c.Fire(buf) c.Fire(buf)
c.send += len(buf) c.send += len(buf)
c.mu.Unlock()
} }
switch track.Codec.Name {
case core.CodecAAC:
if track.Codec.IsRTP() { if track.Codec.IsRTP() {
handler.Handler = aac.RTPDepay(handler.Handler) handler.Handler = aac.RTPDepay(handler.Handler)
} }
case core.CodecOpus, core.CodecMP3: // no changes
case core.CodecOpus, core.CodecMP3, core.CodecPCMU, core.CodecPCMA: case core.CodecPCMA, core.CodecPCMU, core.CodecPCM:
handler.Handler = func(packet *rtp.Packet) { handler.Handler = pcm.FLACEncoder(track.Codec, handler.Handler)
if c.wait != waitNone { codec.Name = core.CodecFLAC
return
}
buf := c.muxer.Marshal(trackID, packet)
c.Fire(buf)
c.send += len(buf)
}
default: default:
panic("unsupported codec") handler.Handler = nil
}
}
if handler.Handler == nil {
println("ERROR: MP4 unsupported codec: " + track.Codec.String())
return nil
} }
handler.HandleRTP(track) handler.HandleRTP(track)
+39 -3
View File
@@ -4,9 +4,45 @@ import "github.com/AlexxIT/go2rtc/pkg/core"
// ParseQuery - like usual parse, but with mp4 param handler // ParseQuery - like usual parse, but with mp4 param handler
func ParseQuery(query map[string][]string) []*core.Media { func ParseQuery(query map[string][]string) []*core.Media {
if query["mp4"] != nil { if v := query["mp4"]; len(v) != 0 {
cons := Consumer{} medias := []*core.Media{
return cons.GetMedias() {
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) return core.ParseQuery(query)
+52 -20
View File
@@ -15,12 +15,14 @@ type Muxer struct {
fragIndex uint32 fragIndex uint32
dts []uint64 dts []uint64
pts []uint32 pts []uint32
codecs []*core.Codec
} }
const ( const (
MimeH264 = "avc1.640029" MimeH264 = "avc1.640029"
MimeH265 = "hvc1.1.6.L153.B0" MimeH265 = "hvc1.1.6.L153.B0"
MimeAAC = "mp4a.40.2" MimeAAC = "mp4a.40.2"
MimeFlac = "flac"
MimeOpus = "opus" MimeOpus = "opus"
) )
@@ -43,6 +45,8 @@ func (m *Muxer) MimeCodecs(codecs []*core.Codec) string {
s += MimeAAC s += MimeAAC
case core.CodecOpus: case core.CodecOpus:
s += MimeOpus s += MimeOpus
case core.CodecFLAC:
s += MimeFlac
} }
} }
@@ -60,9 +64,11 @@ func (m *Muxer) GetInit(codecs []*core.Codec) ([]byte, error) {
switch codec.Name { switch codec.Name {
case core.CodecH264: case core.CodecH264:
sps, pps := h264.GetParameterSet(codec.FmtpLine) 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} sps = []byte{0x67, 0x42, 0x00, 0x0a, 0xf8, 0x41, 0xa2}
}
if len(pps) == 0 {
pps = []byte{0x68, 0xce, 0x38, 0x80} pps = []byte{0x68, 0xce, 0x38, 0x80}
} }
@@ -79,10 +85,14 @@ func (m *Muxer) GetInit(codecs []*core.Codec) ([]byte, error) {
case core.CodecH265: case core.CodecH265:
vps, sps, pps := h265.GetParameterSet(codec.FmtpLine) 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} 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} 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} 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, 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( mv.WriteAudioTrack(
uint32(i+1), codec.Name, codec.ClockRate, codec.Channels, nil, uint32(i+1), codec.Name, codec.ClockRate, codec.Channels, nil,
) )
} }
m.pts = append(m.pts, 0)
m.dts = append(m.dts, 0) m.dts = append(m.dts, 0)
m.pts = append(m.pts, 0)
m.codecs = append(m.codecs, codec)
} }
mv.StartAtom(iso.MoovMvex) mv.StartAtom(iso.MoovMvex)
@@ -138,28 +149,49 @@ func (m *Muxer) Reset() {
} }
func (m *Muxer) Marshal(trackID byte, packet *rtp.Packet) []byte { func (m *Muxer) Marshal(trackID byte, packet *rtp.Packet) []byte {
// important before increment codec := m.codecs[trackID]
time := m.dts[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++ m.fragIndex++
var duration uint32 mv := iso.NewMovie(1024 + size)
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.WriteMovieFragment( mv.WriteMovieFragment(
m.fragIndex, uint32(trackID+1), duration, m.fragIndex, uint32(trackID+1), duration, uint32(size), flags, m.dts[trackID],
uint32(len(packet.Payload)), time,
) )
mv.WriteData(packet.Payload) 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() return mv.Bytes()
} }
+1 -1
View File
@@ -23,7 +23,7 @@ func NewClient(res *http.Response) *Client {
func (c *Client) Handle() error { func (c *Client) Handle() error {
reader := NewReader() reader := NewReader()
b := make([]byte, 1024*1024*256) // 256K b := make([]byte, 1024*256) // 256K
probe := core.NewProbe(c.medias == nil) probe := core.NewProbe(c.medias == nil)
for probe == nil || probe.Active() { for probe == nil || probe.Active() {

Some files were not shown because too many files have changed in this diff Show More