mirror of
https://github.com/AlexxIT/go2rtc.git
synced 2025-10-05 00:12:48 +08:00
Compare commits
60 Commits
v0.1-alpha
...
v0.1-alpha
Author | SHA1 | Date | |
---|---|---|---|
![]() |
64247fc90f | ||
![]() |
c019dc58b1 | ||
![]() |
d3adaf05b1 | ||
![]() |
e6cfd1818b | ||
![]() |
fae4398d21 | ||
![]() |
5daf043937 | ||
![]() |
18d7b9075b | ||
![]() |
7c4497f856 | ||
![]() |
befa4ca1e6 | ||
![]() |
dd3b326f7a | ||
![]() |
e36123bb19 | ||
![]() |
9310343ad3 | ||
![]() |
e2d4fa3393 | ||
![]() |
5fea2932c1 | ||
![]() |
1fd110b70d | ||
![]() |
8377cf2655 | ||
![]() |
8f01b08d42 | ||
![]() |
97ce4c3114 | ||
![]() |
4813a64d9d | ||
![]() |
7923ec74a8 | ||
![]() |
1f0a5fb880 | ||
![]() |
c6a3ee65b8 | ||
![]() |
12b712426d | ||
![]() |
a9af245ef8 | ||
![]() |
f251129a2f | ||
![]() |
d28debabe9 | ||
![]() |
07bf00f9f6 | ||
![]() |
be6ec7dbb9 | ||
![]() |
4e575d1356 | ||
![]() |
4cbacfec0c | ||
![]() |
31e24c6e03 | ||
![]() |
401bf85a10 | ||
![]() |
f36851f83a | ||
![]() |
67522dbb19 | ||
![]() |
26b5745f0a | ||
![]() |
46f6a5d8e1 | ||
![]() |
48f58d0669 | ||
![]() |
fd0b8f3c39 | ||
![]() |
863bf503e2 | ||
![]() |
7a3a1a5336 | ||
![]() |
b851041caa | ||
![]() |
a4acde6d95 | ||
![]() |
1139d4fcad | ||
![]() |
159ad52277 | ||
![]() |
87bc07e404 | ||
![]() |
d1b29275d7 | ||
![]() |
7560bcbc83 | ||
![]() |
090c360747 | ||
![]() |
a81bf0daa8 | ||
![]() |
c7128897b8 | ||
![]() |
07def5ba04 | ||
![]() |
b7f4c63517 | ||
![]() |
92c67df7b4 | ||
![]() |
64c0f287ed | ||
![]() |
d96af31f86 | ||
![]() |
cc55281f12 | ||
![]() |
c10d619df8 | ||
![]() |
65f451e0c5 | ||
![]() |
ecd46700db | ||
![]() |
2f588c77c4 |
59
.github/workflows/builder.yml
vendored
Normal file
59
.github/workflows/builder.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
# https://github.com/home-assistant/builder
|
||||
name: 'Builder'
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [ 'v*' ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
hassio:
|
||||
name: Hassio Addon
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Branch name
|
||||
run: |
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
echo "REPO=alexxit/go2rtc" >> $GITHUB_ENV
|
||||
echo "TAG=${VERSION}" >> $GITHUB_ENV
|
||||
echo "IMAGE=alexxit/go2rtc:${VERSION}" >> $GITHUB_ENV
|
||||
|
||||
- name: Build amd64
|
||||
uses: home-assistant/builder@master
|
||||
with:
|
||||
args: --amd64 --target build/hassio --version $TAG-amd64 --no-latest --docker-hub-check
|
||||
|
||||
- name: Build i386
|
||||
uses: home-assistant/builder@master
|
||||
with:
|
||||
args: --i386 --target build/hassio --version $TAG-i386 --no-latest --docker-hub-check
|
||||
|
||||
- name: Build aarch64
|
||||
uses: home-assistant/builder@master
|
||||
with:
|
||||
args: --aarch64 --target build/hassio --version $TAG-aarch64 --no-latest --docker-hub-check
|
||||
|
||||
- name: Build armv7
|
||||
uses: home-assistant/builder@master
|
||||
with:
|
||||
args: --armv7 --target build/hassio --version $TAG-armv7 --no-latest --docker-hub-check
|
||||
|
||||
- name: Docker manifest
|
||||
run: |
|
||||
# thanks to https://github.com/aler9/rtsp-simple-server/blob/main/Makefile
|
||||
docker manifest create "${IMAGE}" \
|
||||
"${IMAGE}-amd64" "${IMAGE}-i386" "${IMAGE}-aarch64" "${IMAGE}-armv7"
|
||||
docker manifest push "${IMAGE}"
|
||||
|
||||
docker manifest create "${REPO}:latest" \
|
||||
"${IMAGE}-amd64" "${IMAGE}-i386" "${IMAGE}-aarch64" "${IMAGE}-armv7"
|
||||
docker manifest push "${REPO}:latest"
|
215
README.md
215
README.md
@@ -1,20 +1,21 @@
|
||||
# go2rtc
|
||||
|
||||
**go2rtc** - ultimate camera streaming application with support RTSP, WebRTC, FFmpeg, RTMP, etc.
|
||||
Ultimate camera streaming application with support RTSP, WebRTC, FFmpeg, RTMP, etc.
|
||||
|
||||
- zero-dependency and zero-config small [app for all OS](#installation) (Windows, macOS, Linux, ARM)
|
||||
- zero-dependency and zero-config [small app](#go2rtc-binary) for all OS (Windows, macOS, Linux, ARM)
|
||||
- zero-delay for all supported protocols (lowest possible streaming latency)
|
||||
- zero-load on CPU for supported codecs
|
||||
- on the fly transcoding for unsupported codecs [via FFmpeg](#source-ffmpeg)
|
||||
- low CPU load for supported codecs
|
||||
- on the fly transcoding for unsupported codecs via [FFmpeg](#source-ffmpeg)
|
||||
- multi-source 2-way [codecs negotiation](#codecs-negotiation)
|
||||
- streaming from private networks via [Ngrok or SSH-tunnels](#module-webrtc)
|
||||
- streaming from private networks via [Ngrok](#module-ngrok)
|
||||
- can be [integrated to](#module-api) any smart home platform or be used as [standalone app](#go2rtc-binary)
|
||||
|
||||
**Inspired by:**
|
||||
|
||||
- [webrtc](https://github.com/pion/webrtc) go library and whole [@pion](https://github.com/pion) team
|
||||
- series of streaming projects from [@deepch](https://github.com/deepch)
|
||||
- [webrtc](https://github.com/pion/webrtc) go library and whole [@pion](https://github.com/pion) team
|
||||
- [rtsp-simple-server](https://github.com/aler9/rtsp-simple-server) idea from [@aler9](https://github.com/aler9)
|
||||
- [GStreamer](https://gstreamer.freedesktop.org/) multimedia framework pipeline idea
|
||||
- [GStreamer](https://gstreamer.freedesktop.org/) framework pipeline idea
|
||||
- [MediaSoup](https://mediasoup.org/) framework routing idea
|
||||
|
||||
## Codecs negotiation
|
||||
@@ -30,22 +31,40 @@ For example, you want to watch RTSP-stream from [Dahua IPC-K42](https://www.dahu
|
||||
- you can't get camera audio directly, because its audio codecs doesn't match with your browser codecs
|
||||
- so you decide to use transcoding via FFmpeg and add this setting to config YAML file
|
||||
- you have chosen `OPUS/48000/2` codec, because it is higher quality than the `PCMU/8000` or `PCMA/8000`
|
||||
- now you have stream with two sources - **RTSP and FFmpeg**
|
||||
|
||||
**go2rtc** automatically match codecs for you browser and all your stream sources. This called **multi-source 2-way codecs negotiation**. And this is one of the main features of this app.
|
||||
|
||||
**PS.** You can select `PCMU` or `PCMA` codec in camera setting and don't use transcoding at all. Or you can select `AAC` codec for main stream and `PCMU` codec for second stream and add both RTSP to YAML config, this also will work fine.
|
||||
Now you have stream with two sources - **RTSP and FFmpeg**:
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
dahua:
|
||||
- rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif
|
||||
- ffmpeg:rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif#audio=opus
|
||||
- ffmpeg:rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0#audio=opus
|
||||
```
|
||||
|
||||
**go2rtc** automatically match codecs for you browser and all your stream sources. This called **multi-source 2-way codecs negotiation**. And this is one of the main features of this app.
|
||||
|
||||

|
||||
|
||||
## Installation
|
||||
**PS.** You can select `PCMU` or `PCMA` codec in camera setting and don't use transcoding at all. Or you can select `AAC` codec for main stream and `PCMU` codec for second stream and add both RTSP to YAML config, this also will work fine.
|
||||
|
||||
## Fast start
|
||||
|
||||
1. Download [binary](#go2rtc-binary) or use [Docker](#go2rtc-docker) or [Home Assistant Add-on](#go2rtc-home-assistant-add-on)
|
||||
2. Open web interface: `http://localhost:1984/`
|
||||
|
||||
**Optionally:**
|
||||
|
||||
- add your [streams](#module-streams) to [config](#configuration) file
|
||||
- setup [external access](#module-webrtc) to webrtc
|
||||
- setup [external access](#module-ngrok) to web interface
|
||||
- install [ffmpeg](#source-ffmpeg) for transcoding
|
||||
|
||||
**Developers:**
|
||||
|
||||
- write your own [web interface](#module-api)
|
||||
- integrate [web api](#module-api) into your smart home platform
|
||||
|
||||
### go2rtc: Binary
|
||||
|
||||
Download binary for your OS from [latest release](https://github.com/AlexxIT/go2rtc/releases/):
|
||||
|
||||
@@ -59,7 +78,34 @@ Download binary for your OS from [latest release](https://github.com/AlexxIT/go2
|
||||
- `go2rtc_mac_amd64` - Mac with Intel
|
||||
- `go2rtc_mac_arm64` - Mac with M1
|
||||
|
||||
Don't forget to fix the rights `chmod +x go2rtc_linux_xxx` on Linux and Mac.
|
||||
Don't forget to fix the rights `chmod +x go2rtc_xxx_xxx` on Linux and Mac.
|
||||
|
||||
### go2rtc: Home Assistant Add-on
|
||||
|
||||
[](https://my.home-assistant.io/redirect/supervisor_addon/?addon=a889bffc_go2rtc&repository_url=https%3A%2F%2Fgithub.com%2FAlexxIT%2Fhassio-addons)
|
||||
|
||||
1. Install Add-On:
|
||||
- Settings > Add-ons > Plus > Repositories > Add `https://github.com/AlexxIT/hassio-addons`
|
||||
- go2rtc > Install > Start
|
||||
2. Setup [Integration](#module-hass)
|
||||
|
||||
**Optionally:**
|
||||
|
||||
- create `go2rtc.yaml` in your Home Assistant [config](https://www.home-assistant.io/docs/configuration) folder
|
||||
|
||||
### go2rtc: Docker
|
||||
|
||||
Container [alexxit/go2rtc](https://hub.docker.com/r/alexxit/go2rtc) with support `amd64`, `386`, `arm64`, `arm`. This container same as [Home Assistant Add-on](#go2rtc-home-assistant-add-on), but can be used separately from the Home Assistant. Container has preinstalled [FFmpeg](#source-ffmpeg) and [Ngrok](#module-ngrok) applications.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
go2rtc:
|
||||
image: alexxit/go2rtc
|
||||
network_mode: host
|
||||
restart: always
|
||||
volumes:
|
||||
- "~/go2rtc.yaml:/config/go2rtc.yaml"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -69,14 +115,14 @@ Create file `go2rtc.yaml` next to the app.
|
||||
- `api` server will start on default **1984 port**
|
||||
- `rtsp` server will start on default **8554 port**
|
||||
- `webrtc` will use random UDP port for each connection
|
||||
- `ffmpeg` will use default transcoding options (you need to install it [manually](https://ffmpeg.org/))
|
||||
- `ffmpeg` will use default transcoding options (you may install it [manually](https://ffmpeg.org/))
|
||||
|
||||
Available modules:
|
||||
|
||||
- [streams](#module-streams)
|
||||
- [api](#module-api) - HTTP API (important for WebRTC support)
|
||||
- [rtsp](#module-rtsp) - RTSP Server (important for FFmpeg support)
|
||||
- [webrtc](#module-webrtc) - WebRTC Server (important for external access)
|
||||
- [webrtc](#module-webrtc) - WebRTC Server
|
||||
- [ngrok](#module-ngrok) - Ngrok integration (external access for private network)
|
||||
- [ffmpeg](#source-ffmpeg) - FFmpeg integration
|
||||
- [hass](#module-hass) - Home Assistant integration
|
||||
@@ -84,7 +130,7 @@ Available modules:
|
||||
|
||||
### Module: Streams
|
||||
|
||||
**go2rtc** support different stream source types. You can config only one link as stream source or multiple.
|
||||
**go2rtc** support different stream source types. You can config one or multiple links of any type as stream source.
|
||||
|
||||
Available source types:
|
||||
|
||||
@@ -94,12 +140,14 @@ Available source types:
|
||||
- [exec](#source-exec) - advanced FFmpeg and GStreamer integration
|
||||
- [hass](#source-hass) - Home Assistant integration
|
||||
|
||||
**PS.** You can use sources like `MJPEG`, `HLS` and others via FFmpeg integration.
|
||||
|
||||
#### Source: RTSP
|
||||
|
||||
- Support **RTSP and RTSPS** links with multiple video and audio tracks
|
||||
- Support **2-way audio** ONLY for [ONVIF Profile T](https://www.onvif.org/specs/stream/ONVIF-Streaming-Spec.pdf) cameras (back channel connection)
|
||||
|
||||
**Attention:** proprietary 2-way audio standards are not supported!
|
||||
**Attention:** other 2-way audio standards are not supported! ONVIF without Profile T is not supported!
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
@@ -130,18 +178,21 @@ streams:
|
||||
|
||||
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.
|
||||
|
||||
Format: `ffmpeg:{input}#{params}`. Examples:
|
||||
- FFmpeg preistalled for **Docker** and **Hass Add-on** users
|
||||
- **Hass Add-on** users can target files from [/media](https://www.home-assistant.io/more-info/local-media/setup-media/) folder
|
||||
|
||||
Format: `ffmpeg:{input}#{param1}#{param2}#{param3}`. Examples:
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
# [FILE] all tracks will be copied without transcoding codecs
|
||||
file1: ffmpeg:~/media/BigBuckBunny.mp4
|
||||
file1: ffmpeg:/media/BigBuckBunny.mp4
|
||||
|
||||
# [FILE] video will be transcoded to H264, audio will be skipped
|
||||
file2: ffmpeg:~/media/BigBuckBunny.mp4#video=h264
|
||||
file2: ffmpeg:/media/BigBuckBunny.mp4#video=h264
|
||||
|
||||
# [FILE] video will be copied, audio will be transcoded to pcmu
|
||||
file3: ffmpeg:~/media/BigBuckBunny.mp4#video=copy&audio=pcmu
|
||||
file3: ffmpeg:/media/BigBuckBunny.mp4#video=copy#audio=pcmu
|
||||
|
||||
# [HLS] video will be copied, audio will be skipped
|
||||
hls: ffmpeg:https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/gear5/prog_index.m3u8#video=copy
|
||||
@@ -150,31 +201,18 @@ streams:
|
||||
mjpeg: ffmpeg:http://185.97.122.128/cgi-bin/faststream.jpg?stream=half&fps=15#video=h264
|
||||
|
||||
# [RTSP] video and audio will be copied
|
||||
rtsp: ffmpeg:rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0#video=copy&audio=copy
|
||||
rtsp: ffmpeg:rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0#video=copy#audio=copy
|
||||
```
|
||||
|
||||
All trascoding formats has built-in templates. But you can override them via YAML config. You can also add your own formats to config and use them with source params.
|
||||
All trascoding formats has [built-in templates](https://github.com/AlexxIT/go2rtc/blob/master/cmd/ffmpeg/ffmpeg.go): `h264`, `h264/ultra`, `h264/high`, `h265`, `opus`, `pcmu`, `pcmu/16000`, `pcmu/48000`, `pcma`, `pcma/16000`, `pcma/48000`, `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.
|
||||
|
||||
```yaml
|
||||
ffmpeg:
|
||||
bin: ffmpeg # path to ffmpeg binary
|
||||
link: -hide_banner -i {input} # if input is link
|
||||
file: -hide_banner -re -stream_loop -1 -i {input} # if input not link
|
||||
rtsp: -hide_banner -fflags nobuffer -flags low_delay -rtsp_transport tcp -i {input} # if input is RTSP link
|
||||
output: -rtsp_transport tcp -f rtsp {output} # output
|
||||
|
||||
h264: "-codec:v libx264 -g 30 -preset superfast -tune zerolatency -profile main -level 4.1"
|
||||
h264/ultra: "-codec:v libx264 -g 30 -preset ultrafast -tune zerolatency"
|
||||
h264/high: "-codec:v libx264 -g 30 -preset superfast -tune zerolatency"
|
||||
h265: "-codec:v libx265 -g 30 -preset ultrafast -tune zerolatency"
|
||||
opus: "-codec:a libopus -ar 48000 -ac 2"
|
||||
pcmu: "-codec:a pcm_mulaw -ar 8000 -ac 1"
|
||||
pcmu/16000: "-codec:a pcm_mulaw -ar 16000 -ac 1"
|
||||
pcmu/48000: "-codec:a pcm_mulaw -ar 48000 -ac 1"
|
||||
pcma: "-codec:a pcm_alaw -ar 8000 -ac 1"
|
||||
pcma/16000: "-codec:a pcm_alaw -ar 16000 -ac 1"
|
||||
pcma/48000: "-codec:a pcm_alaw -ar 48000 -ac 1"
|
||||
aac/16000: "-codec:a aac -ar 16000 -ac 1"
|
||||
h264: "-codec:v libx264 -g 30 -preset superfast -tune zerolatency -profile main -level 4.1"
|
||||
mycodec: "-any args that support ffmpeg..."
|
||||
```
|
||||
|
||||
#### Source: Exec
|
||||
@@ -183,7 +221,7 @@ FFmpeg source just a shortcut to exec source. You can get any stream or file or
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
stream1: exec:ffmpeg -hide_banner -re -stream_loop -1 -i ~/media/BigBuckBunny.mp4 -c copy -rtsp_transport tcp -f rtsp {output}
|
||||
stream1: exec:ffmpeg -hide_banner -re -stream_loop -1 -i /media/BigBuckBunny.mp4 -c copy -rtsp_transport tcp -f rtsp {output}
|
||||
```
|
||||
|
||||
#### Source: Hass
|
||||
@@ -194,7 +232,7 @@ Support import camera links from [Home Assistant](https://www.home-assistant.io/
|
||||
|
||||
```yaml
|
||||
hass:
|
||||
config: "~/.homeassistant"
|
||||
config: "/config" # skip this setting if you Hass Add-on user
|
||||
|
||||
streams:
|
||||
generic_camera: hass:Camera1 # Settings > Integrations > Integration Name
|
||||
@@ -202,7 +240,7 @@ streams:
|
||||
|
||||
### Module: API
|
||||
|
||||
The HTTP API is the main part for interacting with the application.
|
||||
The HTTP API is the main part for interacting with the application. Default address: `http://127.0.0.1:1984/`.
|
||||
|
||||
- you can use WebRTC only when HTTP API enabled
|
||||
- you can disable HTTP API with `listen: ""` and use, for example, only RTSP client/server protocol
|
||||
@@ -212,11 +250,15 @@ The HTTP API is the main part for interacting with the application.
|
||||
|
||||
```yaml
|
||||
api:
|
||||
listen: ":1984" # HTTP API port ("" - disabled)
|
||||
base_path: "" # API prefix for serve on suburl
|
||||
static_dir: "www" # folder for static files ("" - disabled)
|
||||
listen: ":1984" # HTTP API port ("" - disabled)
|
||||
base_path: "" # API prefix for serve on suburl
|
||||
static_dir: "" # folder for static files (custom web interface)
|
||||
```
|
||||
|
||||
**PS. go2rtc** don't provide HTTPS or password protection. Use [Nginx](https://nginx.org/) or [Ngrok](#module-ngrok) or [Home Assistant Add-on](#go2rtc-home-assistant-add-on) for this tasks.
|
||||
|
||||
**PS2.** You can access microphone (for 2-way audio) only with HTTPS
|
||||
|
||||
### Module: RTSP
|
||||
|
||||
You can get any stream as RTSP-stream with codecs filter:
|
||||
@@ -236,9 +278,9 @@ rtsp:
|
||||
|
||||
### Module: WebRTC
|
||||
|
||||
WebRTC usually works without problems in the local network. But external access may require additional settings. It depends on what type of internet do you have.
|
||||
WebRTC usually works without problems in the local network. But external access may require additional settings. It depends on what type of Internet do you have.
|
||||
|
||||
- by default, WebRTC use two random UDP ports for each connection (for video and audio)
|
||||
- by default, WebRTC use two random UDP ports for each connection (video and audio)
|
||||
- you can enable one additional TCP port for all connections and use it for external access
|
||||
|
||||
**Static public IP**
|
||||
@@ -302,6 +344,7 @@ webrtc:
|
||||
|
||||
With Ngrok integration you can get external access to your streams in situation when you have Internet with private IP-address.
|
||||
|
||||
- Ngrok preistalled for **Docker** and **Hass Add-on** users
|
||||
- you may need external access for two different things:
|
||||
- WebRTC stream, so you need tunnel WebRTC TCP port (ex. 8555)
|
||||
- go2rtc web interface, so you need tunnel API HTTP port (ex. 1984)
|
||||
@@ -353,14 +396,24 @@ tunnels:
|
||||
|
||||
### Module: Hass
|
||||
|
||||
go2rtc compatible with Home Assistant [RTSPtoWebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) integration API.
|
||||
**go2rtc** compatible with Home Assistant [RTSPtoWebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) integration.
|
||||
|
||||
- add integration with link to go2rtc HTTP API:
|
||||
- Hass > Settings > Integrations > Add Integration > RTSPtoWebRTC > `http://192.168.1.123:1984/`
|
||||
- add generic camera with RTSP link:
|
||||
- Hass > Settings > Integrations > Add Integration > Generic Camera > `rtsp://...`
|
||||
- use Picture Entity or Picture Glance lovelace card
|
||||
- open full screen card - this is should be WebRTC stream
|
||||
If you install **go2rtc** as [Hass Add-on](#go2rtc-home-assistant-add-on) - you need to use localhost IP-address, example:
|
||||
|
||||
- `http://127.0.0.1:1984/` to web interface
|
||||
- `rtsp://127.0.0.1:8554/camera1` to RTSP streams
|
||||
|
||||
In other cases you need to use IP-address of server with **go2rtc** application.
|
||||
|
||||
1. Add integration with link to go2rtc HTTP API:
|
||||
- Hass > Settings > Integrations > Add Integration > [RTSPtoWebRTC](https://my.home-assistant.io/redirect/config_flow_start/?domain=rtsp_to_webrtc) > `http://127.0.0.1:1984/`
|
||||
2. Add generic camera with RTSP link:
|
||||
- Hass > Settings > Integrations > Add Integration > [Generic Camera](https://my.home-assistant.io/redirect/config_flow_start/?domain=generic) > `rtsp://...` or `rtmp://...`
|
||||
- you can use either direct RTSP links to cameras or take RTSP streams from **go2rtc**
|
||||
3. Use Picture Entity or Picture Glance lovelace card
|
||||
4. Open full screen card - this is should be WebRTC stream
|
||||
|
||||
PS. Default Home Assistant lovelace cards don't support 2-way audio. You can use 2-way audio from [Add-on Web UI](https://my.home-assistant.io/redirect/supervisor_addon/?addon=a889bffc_go2rtc&repository_url=https%3A%2F%2Fgithub.com%2FAlexxIT%2Fhassio-addons). But you need use HTTPS to access the microphone. This is a browser restriction and cannot be avoided.
|
||||
|
||||
### Module: Log
|
||||
|
||||
@@ -376,3 +429,53 @@ log:
|
||||
streams: error
|
||||
webrtc: fatal
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
By default `go2rtc` start Web interface on port `1984` and RTSP on port `8554`. Both ports are accessible from your local network. So anyone on your local network can watch video from your cameras without authorization. The same rule applies to the Home Assistant Add-on.
|
||||
|
||||
This is not a problem if you trust your local network as much as I do. But you can change this behaviour with a `go2rtc.yaml` config:
|
||||
|
||||
```yaml
|
||||
api:
|
||||
listen: "127.0.0.1:1984" # localhost
|
||||
|
||||
rtsp:
|
||||
listen: "127.0.0.1:8554" # localhost
|
||||
|
||||
webrtc:
|
||||
listen: ":8555" # external TCP port
|
||||
```
|
||||
|
||||
- local access to RTSP is not a problem for [FFmpeg](#source-ffmpeg) integration, because it runs locally on your server
|
||||
- local access to API is not a problem for [Home Assistant Add-on](#go2rtc-home-assistant-add-on), because Hass runs locally on same server and Add-on Web UI protected with Hass authorization ([Ingress feature](https://www.home-assistant.io/blog/2019/04/15/hassio-ingress/))
|
||||
- external access to WebRTC TCP port is not a problem, because it used only for transmit encrypted media data
|
||||
- anyway you need to open this port to your local network and to the Internet in order for WebRTC to work
|
||||
|
||||
If you need Web interface protection without Home Assistant Add-on - you need to use reverse proxy, like [Nginx](https://nginx.org/), [Caddy](https://caddyserver.com/), [Ngrok](https://ngrok.com/), etc.
|
||||
|
||||
PS. Additionally WebRTC opens a lot of random UDP ports for transmit encrypted media. They work without problems on the local network. And sometimes work for external access, even if you haven't opened ports on your router. But for stable external WebRTC access, you need to configure the TCP port.
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q. What's the difference between go2rtc, WebRTC Camera and RTSPtoWebRTC?**
|
||||
|
||||
**go2rtc** is a new version of the server-side [WebRTC Camera](https://github.com/AlexxIT/WebRTC) integration, completely rewritten from scratch, with a number of fixes and a huge number of new features. It is compatible with native Home Assistant [RTSPtoWebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) integration. So you [can use](#module-hass) default lovelace Picture Entity or Picture Glance.
|
||||
|
||||
**Q. Why go2rtc is an addon and not an integration?**
|
||||
|
||||
Because **go2rtc** is more than just viewing your stream online with WebRTC. You can use it all the time for your various tasks. But every time the Hass is rebooted - all integrations are also rebooted. So your streams may be interrupted if you use them in additional tasks.
|
||||
|
||||
When **go2rtc** is released, the **WebRTC Camera** integration will be updated. And you can decide whether to use the integration or the addon.
|
||||
|
||||
**Q. Which RTSP link should I use inside Hass?**
|
||||
|
||||
You can use direct link to your cameras there (as you always do). **go2rtc** support zero-config feature. You may leave `streams` config section empty. And your streams will be created on the fly on first start from Hass. And your cameras will have multiple connections. Some from Hass directly and one from **go2rtc**.
|
||||
|
||||
Also you can specify your streams in **go2rtc** [config file](#configuration) and use RTSP links to this addon. With additional features: multi-source [codecs negotiation](#codecs-negotiation) or FFmpeg [transcoding](#source-ffmpeg) for unsupported codecs. Or use them as source for Frigate. And your cameras will have one connection from **go2rtc**. And **go2rtc** will have multiple connection - some from Hass via RTSP protocol, some from your browser via WebRTC protocol.
|
||||
|
||||
Use any config what you like.
|
||||
|
||||
**Q. What about lovelace card with support 2-way audio?**
|
||||
|
||||
At this moment I am focused on improving stability and adding new features to **go2rtc**. Maybe someone could write such a card themselves. It's not difficult, I have [some sketches](https://github.com/AlexxIT/go2rtc/blob/master/www/webrtc.html).
|
||||
|
24
build/hassio/Dockerfile
Normal file
24
build/hassio/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
ARG BUILD_FROM
|
||||
FROM $BUILD_FROM
|
||||
|
||||
RUN apk add --no-cache git go ffmpeg
|
||||
|
||||
ARG BUILD_ARCH
|
||||
|
||||
RUN git clone https://github.com/AlexxIT/go2rtc \
|
||||
&& cd go2rtc \
|
||||
&& CGO_ENABLED=0 go build -ldflags "-s -w" -trimpath -o /usr/local/bin
|
||||
|
||||
# https://github.com/home-assistant/docker-base/blob/master/alpine/Dockerfile
|
||||
RUN if [ "${BUILD_ARCH}" = "aarch64" ]; then BUILD_ARCH="arm64"; \
|
||||
elif [ "${BUILD_ARCH}" = "armv7" ]; then BUILD_ARCH="arm"; fi \
|
||||
&& cd go2rtc \
|
||||
&& curl $(curl -s "https://raw.githubusercontent.com/ngrok/docker-ngrok/main/releases.json" | jq -r ".${BUILD_ARCH}.url") -o ngrok.zip \
|
||||
&& unzip ngrok -d /usr/local/bin
|
||||
|
||||
RUN rm -r /go2rtc
|
||||
|
||||
COPY run.sh /
|
||||
RUN chmod a+x /run.sh
|
||||
|
||||
CMD [ "/run.sh" ]
|
6
build/hassio/config.yaml
Normal file
6
build/hassio/config.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# https://github.com/home-assistant/builder/blob/master/builder.sh
|
||||
name: go2rtc
|
||||
description: Ultimate camera streaming application
|
||||
url: https://github.com/AlexxIT/go2rtc
|
||||
image: alexxit/go2rtc
|
||||
arch: [ amd64, aarch64, i386, armv7 ]
|
14
build/hassio/run.sh
Normal file
14
build/hassio/run.sh
Normal file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/with-contenv bashio
|
||||
|
||||
set +e
|
||||
|
||||
# set cwd for go2rtc (for config file, Hass itegration, etc)
|
||||
cd /config
|
||||
|
||||
# add the feature to override go2rtc binary from Hass config folder
|
||||
export PATH="/config:$PATH"
|
||||
|
||||
while true; do
|
||||
go2rtc
|
||||
sleep 5
|
||||
done
|
@@ -37,14 +37,14 @@ func Init() {
|
||||
|
||||
HandleFunc("/api/frame.mp4", frameHandler)
|
||||
HandleFunc("/api/frame.raw", frameHandler)
|
||||
HandleFunc("/api/stack", stackHandler)
|
||||
HandleFunc("/api/stats", statsHandler)
|
||||
HandleFunc("/api/streams", streamsHandler)
|
||||
HandleFunc("/api/ws", apiWS)
|
||||
|
||||
// ensure we can listen without errors
|
||||
listener, err := net.Listen("tcp", cfg.Mod.Listen)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("[api] listen")
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().Str("addr", cfg.Mod.Listen).Msg("[api] listen")
|
||||
@@ -52,7 +52,7 @@ func Init() {
|
||||
go func() {
|
||||
s := http.Server{}
|
||||
if err = s.Serve(listener); err != nil {
|
||||
log.Fatal().Err(err).Msg("[api] Serve")
|
||||
log.Fatal().Err(err).Msg("[api] serve")
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -69,16 +69,30 @@ var basePath string
|
||||
var log zerolog.Logger
|
||||
var wsHandlers = make(map[string]WSHandler)
|
||||
|
||||
func statsHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
v := map[string]interface{}{
|
||||
"streams": streams.All(),
|
||||
func streamsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
src := r.URL.Query().Get("src")
|
||||
|
||||
switch r.Method {
|
||||
case "PUT":
|
||||
streams.Get(src)
|
||||
return
|
||||
case "DELETE":
|
||||
streams.Delete(src)
|
||||
return
|
||||
}
|
||||
|
||||
var v interface{}
|
||||
if src != "" {
|
||||
v = streams.Get(src)
|
||||
} else {
|
||||
v = streams.All()
|
||||
}
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.stats] marshal")
|
||||
log.Error().Err(err).Msg("[api.streams] marshal")
|
||||
}
|
||||
if _, err = w.Write(data); err != nil {
|
||||
log.Error().Err(err).Msg("[api.stats] write")
|
||||
log.Error().Err(err).Msg("[api.streams] write")
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -8,8 +8,8 @@ import (
|
||||
)
|
||||
|
||||
func frameHandler(w http.ResponseWriter, r *http.Request) {
|
||||
url := r.URL.Query().Get("url")
|
||||
stream := streams.Get(url)
|
||||
src := r.URL.Query().Get("src")
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
|
@@ -8,6 +8,7 @@ import (
|
||||
func initStatic(staticDir string) {
|
||||
var root http.FileSystem
|
||||
if staticDir != "" {
|
||||
log.Info().Str("dir", staticDir).Msg("[api] serve static")
|
||||
root = http.Dir(staticDir)
|
||||
} else {
|
||||
root = http.FS(www.Static)
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"github.com/rs/zerolog"
|
||||
"gopkg.in/yaml.v3"
|
||||
"io"
|
||||
@@ -9,13 +10,25 @@ import (
|
||||
)
|
||||
|
||||
func Init() {
|
||||
data, _ = os.ReadFile("go2rtc.yaml")
|
||||
config := flag.String(
|
||||
"config",
|
||||
"go2rtc.yaml",
|
||||
"Path to go2rtc configuration file",
|
||||
)
|
||||
|
||||
flag.Parse()
|
||||
|
||||
data, _ = os.ReadFile(*config)
|
||||
|
||||
var cfg struct {
|
||||
Mod map[string]string `yaml:"log"`
|
||||
}
|
||||
|
||||
LoadConfig(&cfg)
|
||||
if data != nil {
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
println("ERROR: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
var writer io.Writer = os.Stdout
|
||||
|
||||
@@ -39,7 +52,9 @@ func Init() {
|
||||
|
||||
modules = cfg.Mod
|
||||
|
||||
log.Info().Msgf("go2rtc %s/%s", runtime.GOOS, runtime.GOARCH)
|
||||
path, _ := os.Getwd()
|
||||
log.Debug().Str("os", runtime.GOOS).Str("arch", runtime.GOARCH).
|
||||
Str("cwd", path).Int("conf_size", len(data)).Msgf("[app]")
|
||||
}
|
||||
|
||||
func LoadConfig(v interface{}) {
|
||||
|
27
cmd/debug/debug.go
Normal file
27
cmd/debug/debug.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
api.HandleFunc("/api/stack", stackHandler)
|
||||
api.HandleFunc("/api/exit", exitHandler)
|
||||
|
||||
streams.HandleFunc("null", nullHandler)
|
||||
}
|
||||
|
||||
func exitHandler(_ http.ResponseWriter, r *http.Request) {
|
||||
s := r.URL.Query().Get("code")
|
||||
code, _ := strconv.Atoi(s)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func nullHandler(string) (streamer.Producer, error) {
|
||||
return nil, nil
|
||||
}
|
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package debug
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -8,19 +8,22 @@ import (
|
||||
)
|
||||
|
||||
var stackSkip = [][]byte{
|
||||
// debug.go
|
||||
[]byte("github.com/AlexxIT/go2rtc/cmd/debug.handler"),
|
||||
|
||||
// cmd.go
|
||||
[]byte("github.com/AlexxIT/go2rtc/cmd.Run"),
|
||||
// main.go
|
||||
[]byte("main.main()"),
|
||||
[]byte("created by os/signal.Notify"),
|
||||
|
||||
// api.go
|
||||
// api/stack.go
|
||||
[]byte("github.com/AlexxIT/go2rtc/cmd/api.stackHandler"),
|
||||
|
||||
// api/api.go
|
||||
[]byte("created by github.com/AlexxIT/go2rtc/cmd/api.Init"),
|
||||
[]byte("created by net/http.(*connReader).startBackgroundRead"),
|
||||
[]byte("created by net/http.(*Server).Serve"),
|
||||
[]byte("created by net/http.(*Server).Serve"), // TODO: why two?
|
||||
|
||||
[]byte("created by github.com/AlexxIT/go2rtc/cmd/rtsp.Init"),
|
||||
|
||||
// webrtc/api.go
|
||||
[]byte("created by github.com/pion/ice/v2.NewTCPMuxDefault"),
|
||||
}
|
||||
|
||||
func stackHandler(w http.ResponseWriter, r *http.Request) {
|
@@ -49,7 +49,7 @@ func Handle(url string) (streamer.Producer, error) {
|
||||
)
|
||||
|
||||
// remove `exec:`
|
||||
args := strings.Split(url[5:], " ")
|
||||
args := QuoteSplit(url[5:])
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
|
||||
if log.Trace().Enabled() {
|
||||
@@ -70,7 +70,7 @@ func Handle(url string) (streamer.Producer, error) {
|
||||
}
|
||||
|
||||
select {
|
||||
case <-time.After(time.Second * 10):
|
||||
case <-time.After(time.Second * 15):
|
||||
_ = cmd.Process.Kill()
|
||||
log.Error().Str("url", url).Msg("[exec] timeout")
|
||||
return nil, errors.New("timeout")
|
||||
@@ -83,3 +83,39 @@ func Handle(url string) (streamer.Producer, error) {
|
||||
|
||||
var log zerolog.Logger
|
||||
var waiters map[string]chan streamer.Producer
|
||||
|
||||
func QuoteSplit(s string) []string {
|
||||
var a []string
|
||||
|
||||
for len(s) > 0 {
|
||||
is := strings.IndexByte(s, ' ')
|
||||
if is >= 0 {
|
||||
// skip prefix and double spaces
|
||||
if is == 0 {
|
||||
// goto next symbol
|
||||
s = s[1:]
|
||||
continue
|
||||
}
|
||||
|
||||
// check if quote in word
|
||||
if i := strings.IndexByte(s[:is], '"'); i >= 0 {
|
||||
// search quote end
|
||||
if is = strings.Index(s, `" `); is > 0 {
|
||||
is += 1
|
||||
} else {
|
||||
is = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if is >= 0 {
|
||||
a = append(a, strings.ReplaceAll(s[:is], `"`, ""))
|
||||
s = s[is+1:]
|
||||
} else {
|
||||
//add last word
|
||||
a = append(a, s)
|
||||
break
|
||||
}
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
@@ -1,6 +1,41 @@
|
||||
## Devices Windows
|
||||
|
||||
```
|
||||
>ffmpeg -hide_banner -f dshow -list_options true -i video="VMware Virtual USB Video Device"
|
||||
[dshow @ 0000025695e52900] DirectShow video device options (from video devices)
|
||||
[dshow @ 0000025695e52900] Pin "Record" (alternative pin name "0")
|
||||
[dshow @ 0000025695e52900] pixel_format=yuyv422 min s=1280x720 fps=1 max s=1280x720 fps=10
|
||||
[dshow @ 0000025695e52900] pixel_format=yuyv422 min s=1280x720 fps=1 max s=1280x720 fps=10 (tv, bt470bg/bt709/unknown, topleft)
|
||||
[dshow @ 0000025695e52900] pixel_format=nv12 min s=1280x720 fps=1 max s=1280x720 fps=23
|
||||
[dshow @ 0000025695e52900] pixel_format=nv12 min s=1280x720 fps=1 max s=1280x720 fps=23 (tv, bt470bg/bt709/unknown, topleft)
|
||||
```
|
||||
|
||||
## Devices Mac
|
||||
|
||||
```
|
||||
% ./ffmpeg -hide_banner -f avfoundation -list_devices true -i ""
|
||||
[AVFoundation indev @ 0x7f8b1f504d80] AVFoundation video devices:
|
||||
[AVFoundation indev @ 0x7f8b1f504d80] [0] FaceTime HD Camera
|
||||
[AVFoundation indev @ 0x7f8b1f504d80] [1] Capture screen 0
|
||||
[AVFoundation indev @ 0x7f8b1f504d80] AVFoundation audio devices:
|
||||
[AVFoundation indev @ 0x7f8b1f504d80] [0] Soundflower (2ch)
|
||||
[AVFoundation indev @ 0x7f8b1f504d80] [1] Built-in Microphone
|
||||
[AVFoundation indev @ 0x7f8b1f504d80] [2] Soundflower (64ch)
|
||||
```
|
||||
|
||||
## Devices Linux
|
||||
|
||||
```
|
||||
# ffmpeg -hide_banner -f v4l2 -list_formats all -i /dev/video0
|
||||
[video4linux2,v4l2 @ 0x7f7de7c58bc0] Raw : yuyv422 : YUYV 4:2:2 : 640x480 160x120 176x144 320x176 320x240 352x288 432x240 544x288 640x360 752x416 800x448 800x600 864x480 960x544 960x720 1024x576 1184x656 1280x720 1280x960
|
||||
[video4linux2,v4l2 @ 0x7f7de7c58bc0] Compressed: mjpeg : Motion-JPEG : 640x480 160x120 176x144 320x176 320x240 352x288 432x240 544x288 640x360 752x416 800x448 800x600 864x480 960x544 960x720 1024x576 1184x656 1280x720 1280x960
|
||||
```
|
||||
|
||||
## Useful links
|
||||
|
||||
- https://superuser.com/questions/564402/explanation-of-x264-tune
|
||||
- https://stackoverflow.com/questions/33624016/why-sliced-thread-affect-so-much-on-realtime-encoding-using-ffmpeg-x264
|
||||
- https://codec.fandom.com/ru/wiki/X264_-_%D0%BE%D0%BF%D0%B8%D1%81%D0%B0%D0%BD%D0%B8%D0%B5_%D0%BA%D0%BB%D1%8E%D1%87%D0%B5%D0%B9_%D0%BA%D0%BE%D0%B4%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D1%8F
|
||||
- https://codec.fandom.com/ru/wiki/X264_-_описание_ключей_кодирования
|
||||
- https://html5test.com/
|
||||
- https://trac.ffmpeg.org/wiki/Capture/Webcam
|
||||
- https://trac.ffmpeg.org/wiki/DirectShow
|
||||
|
63
cmd/ffmpeg/device_darwin.go
Normal file
63
cmd/ffmpeg/device_darwin.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// https://trac.ffmpeg.org/wiki/Capture/Webcam
|
||||
const deviceInputPrefix = "-f avfoundation"
|
||||
|
||||
func deviceInputSuffix(videoIdx, audioIdx int) string {
|
||||
video := findMedia(streamer.KindVideo, videoIdx)
|
||||
audio := findMedia(streamer.KindAudio, audioIdx)
|
||||
switch {
|
||||
case video != nil && audio != nil:
|
||||
return `"` + video.Title + `:` + audio.Title + `"`
|
||||
case video != nil:
|
||||
return `"` + video.Title + `"`
|
||||
case audio != nil:
|
||||
return `"` + audio.Title + `"`
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func loadMedias() {
|
||||
cmd := exec.Command(
|
||||
tpl["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 = streamer.KindVideo
|
||||
continue
|
||||
case strings.HasSuffix(line, "audio devices:"):
|
||||
kind = streamer.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) *streamer.Media {
|
||||
return &streamer.Media{
|
||||
Kind: kind, Title: name,
|
||||
}
|
||||
}
|
36
cmd/ffmpeg/device_linux.go
Normal file
36
cmd/ffmpeg/device_linux.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/rs/zerolog/log"
|
||||
"io/ioutil"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// https://trac.ffmpeg.org/wiki/Capture/Webcam
|
||||
const deviceInputPrefix = "-f v4l2"
|
||||
|
||||
func deviceInputSuffix(videoIdx, audioIdx int) string {
|
||||
video := findMedia(streamer.KindVideo, videoIdx)
|
||||
return video.Title
|
||||
}
|
||||
|
||||
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(), streamer.KindVideo) {
|
||||
media := loadMedia(streamer.KindVideo, "/dev/"+file.Name())
|
||||
medias = append(medias, media)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadMedia(kind, name string) *streamer.Media {
|
||||
return &streamer.Media{
|
||||
Kind: kind, Title: name,
|
||||
}
|
||||
}
|
59
cmd/ffmpeg/device_windows.go
Normal file
59
cmd/ffmpeg/device_windows.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// https://trac.ffmpeg.org/wiki/DirectShow
|
||||
const deviceInputPrefix = "-f dshow"
|
||||
|
||||
func deviceInputSuffix(videoIdx, audioIdx int) string {
|
||||
video := findMedia(streamer.KindVideo, videoIdx)
|
||||
audio := findMedia(streamer.KindAudio, audioIdx)
|
||||
switch {
|
||||
case video != nil && audio != nil:
|
||||
return `video="` + video.Title + `":audio=` + audio.Title + `"`
|
||||
case video != nil:
|
||||
return `video="` + video.Title + `"`
|
||||
case audio != nil:
|
||||
return `audio="` + audio.Title + `"`
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func loadMedias() {
|
||||
cmd := exec.Command(
|
||||
tpl["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 = streamer.KindVideo
|
||||
} else if strings.HasSuffix(line, "(audio)") {
|
||||
kind = streamer.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) *streamer.Media {
|
||||
return &streamer.Media{
|
||||
Kind: kind, Title: name,
|
||||
}
|
||||
}
|
73
cmd/ffmpeg/devices.go
Normal file
73
cmd/ffmpeg/devices.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/rs/zerolog/log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func getDevice(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 medias []*streamer.Media
|
||||
|
||||
func findMedia(kind string, index int) *streamer.Media {
|
||||
for _, media := range medias {
|
||||
if media.Kind != kind {
|
||||
continue
|
||||
}
|
||||
if index == 0 {
|
||||
return media
|
||||
}
|
||||
index--
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleDevices(w http.ResponseWriter, r *http.Request) {
|
||||
if medias == nil {
|
||||
loadMedias()
|
||||
}
|
||||
|
||||
data, err := json.Marshal(medias)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.ffmpeg]")
|
||||
return
|
||||
}
|
||||
if _, err = w.Write(data); err != nil {
|
||||
log.Error().Err(err).Msg("[api.ffmpeg]")
|
||||
}
|
||||
}
|
@@ -1,6 +1,7 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/exec"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
@@ -20,9 +21,9 @@ func Init() {
|
||||
"bin": "ffmpeg",
|
||||
|
||||
// inputs
|
||||
"link": "-hide_banner -i {input}",
|
||||
"rtsp": "-hide_banner -fflags nobuffer -flags low_delay -rtsp_transport tcp -i {input}",
|
||||
"file": "-hide_banner -re -stream_loop -1 -i {input}",
|
||||
"link": "-i {input}",
|
||||
"rtsp": "-fflags nobuffer -flags low_delay -rtsp_transport tcp -i {input}",
|
||||
"file": "-re -stream_loop -1 -i {input}",
|
||||
|
||||
// output
|
||||
"out": "-rtsp_transport tcp -f rtsp {output}",
|
||||
@@ -31,7 +32,8 @@ func Init() {
|
||||
// `-preset superfast` - we can't use ultrafast because it doesn't support `-profile main -level 4.1`
|
||||
// `-tune zerolatency` - for minimal latency
|
||||
// `-profile main -level 4.1` - most used streaming profile
|
||||
"h264": "-codec:v libx264 -g 30 -preset superfast -tune zerolatency -profile main -level 4.1",
|
||||
// `-pix_fmt yuv420p` - if input pix format 4:2:2
|
||||
"h264": "-codec:v libx264 -g 30 -preset superfast -tune zerolatency -profile main -level 4.1 -pix_fmt yuv420p",
|
||||
"h264/ultra": "-codec:v libx264 -g 30 -preset ultrafast -tune zerolatency",
|
||||
"h264/high": "-codec:v libx264 -g 30 -preset superfast -tune zerolatency",
|
||||
"h265": "-codec:v libx265 -g 30 -preset ultrafast -tune zerolatency",
|
||||
@@ -47,14 +49,14 @@ func Init() {
|
||||
|
||||
app.LoadConfig(&cfg)
|
||||
|
||||
tpl := cfg.Mod
|
||||
tpl = cfg.Mod
|
||||
|
||||
streams.HandleFunc("ffmpeg", func(s string) (streamer.Producer, error) {
|
||||
s = s[7:] // remove `ffmpeg:`
|
||||
|
||||
var query url.Values
|
||||
if i := strings.IndexByte(s, '#'); i > 0 {
|
||||
query, _ = url.ParseQuery(s[i+1:])
|
||||
query = parseQuery(s[i+1:])
|
||||
s = s[:i]
|
||||
}
|
||||
|
||||
@@ -62,13 +64,15 @@ func Init() {
|
||||
switch {
|
||||
case strings.HasPrefix(s, "rtsp"):
|
||||
template = tpl["rtsp"]
|
||||
case strings.HasPrefix(s, "device"):
|
||||
template, _ = getDevice(s)
|
||||
case strings.Contains(s, "://"):
|
||||
template = tpl["link"]
|
||||
default:
|
||||
template = tpl["file"]
|
||||
}
|
||||
|
||||
s = "exec:" + tpl["bin"] + " " +
|
||||
s = "exec:" + tpl["bin"] + " -hide_banner " +
|
||||
strings.Replace(template, "{input}", s, 1)
|
||||
|
||||
if query != nil {
|
||||
@@ -109,4 +113,21 @@ func Init() {
|
||||
|
||||
return exec.Handle(s)
|
||||
})
|
||||
|
||||
api.HandleFunc("/api/devices", handleDevices)
|
||||
}
|
||||
|
||||
var tpl map[string]string
|
||||
|
||||
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
|
||||
}
|
||||
|
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/cmd/webrtc"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
@@ -39,28 +41,45 @@ func Init() {
|
||||
return
|
||||
}
|
||||
|
||||
ent := new(entries)
|
||||
if err = json.Unmarshal(data, ent); err != nil {
|
||||
storage := new(entries)
|
||||
if err = json.Unmarshal(data, storage); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
urls := map[string]string{}
|
||||
|
||||
for _, entrie := range ent.Data.Entries {
|
||||
switch entrie.Domain {
|
||||
case "generic":
|
||||
if entrie.Options.StreamSource != "" {
|
||||
urls[entrie.Title] = entrie.Options.StreamSource
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
streams.HandleFunc("hass", func(url string) (streamer.Producer, error) {
|
||||
if hurl := urls[url[5:]]; hurl != "" {
|
||||
return streams.GetProducer(hurl)
|
||||
}
|
||||
return nil, fmt.Errorf("can't get url: %s", url)
|
||||
})
|
||||
|
||||
for _, entrie := range storage.Data.Entries {
|
||||
switch entrie.Domain {
|
||||
case "generic":
|
||||
if entrie.Options.StreamSource == "" {
|
||||
continue
|
||||
}
|
||||
urls[entrie.Title] = entrie.Options.StreamSource
|
||||
|
||||
//case "homekit_controller":
|
||||
// if entrie.Data.ClientID == "" {
|
||||
// continue
|
||||
// }
|
||||
// urls[entrie.Title] = fmt.Sprintf(
|
||||
// "homekit://%s:%d?client_id=%s&client_private=%s%s&device_id=%s&device_public=%s",
|
||||
// entrie.Data.DeviceHost, entrie.Data.DevicePort,
|
||||
// entrie.Data.ClientID, entrie.Data.ClientPrivate, entrie.Data.ClientPublic,
|
||||
// entrie.Data.DeviceID, entrie.Data.DevicePublic,
|
||||
// )
|
||||
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
streams.Get("hass:" + entrie.Title)
|
||||
}
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
@@ -80,6 +99,15 @@ func handler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: fixme
|
||||
if strings.HasPrefix(url, "rtsp://") {
|
||||
port := ":" + rtsp.Port + "/"
|
||||
i := strings.Index(url, port)
|
||||
if i > 0 {
|
||||
url = url[i+len(port):]
|
||||
}
|
||||
}
|
||||
|
||||
stream := streams.Get(url)
|
||||
str, err = webrtc.ExchangeSDP(stream, string(offer), r.UserAgent())
|
||||
if err != nil {
|
||||
|
@@ -13,8 +13,8 @@ func Init() {
|
||||
}
|
||||
|
||||
func handler(ctx *api.Context, msg *streamer.Message) {
|
||||
url := ctx.Request.URL.Query().Get("url")
|
||||
stream := streams.Get(url)
|
||||
src := ctx.Request.URL.Query().Get("src")
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
|
@@ -65,8 +65,17 @@ func rtspHandler(url string) (streamer.Producer, error) {
|
||||
if err = conn.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn.Backchannel = true
|
||||
if err = conn.Describe(); err != nil {
|
||||
return nil, err
|
||||
// second try without backchannel, we need to reconnect
|
||||
if err = conn.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn.Backchannel = false
|
||||
if err = conn.Describe(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
|
@@ -19,6 +19,9 @@ func HandleFunc(scheme string, handler Handler) {
|
||||
|
||||
func HasProducer(url string) bool {
|
||||
i := strings.IndexByte(url, ':')
|
||||
if i <= 0 { // TODO: i < 4 ?
|
||||
return false
|
||||
}
|
||||
return handlers[url[:i]] != nil
|
||||
}
|
||||
|
||||
|
@@ -2,6 +2,7 @@ package streams
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type state byte
|
||||
@@ -21,15 +22,19 @@ type Producer struct {
|
||||
tracks []*streamer.Track
|
||||
|
||||
state state
|
||||
mx sync.Mutex
|
||||
}
|
||||
|
||||
func (p *Producer) GetMedias() []*streamer.Media {
|
||||
p.mx.Lock()
|
||||
defer p.mx.Unlock()
|
||||
|
||||
if p.state == stateNone {
|
||||
log.Debug().Str("url", p.url).Msg("[streams] probe producer")
|
||||
|
||||
var err error
|
||||
p.element, err = GetProducer(p.url)
|
||||
if err != nil {
|
||||
if err != nil || p.element == nil {
|
||||
log.Error().Err(err).Str("url", p.url).Msg("[streams] probe producer")
|
||||
return nil
|
||||
}
|
||||
@@ -41,6 +46,9 @@ func (p *Producer) GetMedias() []*streamer.Media {
|
||||
}
|
||||
|
||||
func (p *Producer) GetTrack(media *streamer.Media, codec *streamer.Codec) *streamer.Track {
|
||||
p.mx.Lock()
|
||||
defer p.mx.Unlock()
|
||||
|
||||
if p.state == stateMedias {
|
||||
p.state = stateTracks
|
||||
}
|
||||
@@ -61,6 +69,9 @@ func (p *Producer) GetTrack(media *streamer.Media, codec *streamer.Codec) *strea
|
||||
// internals
|
||||
|
||||
func (p *Producer) start() {
|
||||
p.mx.Lock()
|
||||
defer p.mx.Unlock()
|
||||
|
||||
if p.state != stateTracks {
|
||||
return
|
||||
}
|
||||
@@ -72,10 +83,18 @@ func (p *Producer) start() {
|
||||
}
|
||||
|
||||
func (p *Producer) stop() {
|
||||
p.mx.Lock()
|
||||
|
||||
log.Debug().Str("url", p.url).Msg("[streams] stop producer")
|
||||
|
||||
_ = p.element.Stop()
|
||||
p.element = nil
|
||||
if p.element != nil {
|
||||
_ = p.element.Stop()
|
||||
p.element = nil
|
||||
} else {
|
||||
log.Warn().Str("url", p.url).Msg("[streams] stop empty producer")
|
||||
}
|
||||
p.tracks = nil
|
||||
p.state = stateNone
|
||||
|
||||
p.mx.Unlock()
|
||||
}
|
||||
|
@@ -2,6 +2,7 @@ package streams
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
)
|
||||
|
||||
@@ -29,6 +30,7 @@ func NewStream(source interface{}) *Stream {
|
||||
}
|
||||
case map[string]interface{}:
|
||||
return NewStream(source["url"])
|
||||
case nil:
|
||||
default:
|
||||
panic("wrong source type")
|
||||
}
|
||||
@@ -61,6 +63,10 @@ func (s *Stream) AddConsumer(cons streamer.Consumer) (err error) {
|
||||
|
||||
// Step 4. Get producer track
|
||||
prodTrack := prod.GetTrack(prodMedia, prodCodec)
|
||||
if prodTrack == nil {
|
||||
log.Warn().Msg("[stream] can't get track")
|
||||
continue
|
||||
}
|
||||
|
||||
// Step 5. Add track to consumer and get new track
|
||||
consTrack := consumer.element.AddTrack(consMedia, prodTrack)
|
||||
@@ -74,7 +80,7 @@ func (s *Stream) AddConsumer(cons streamer.Consumer) (err error) {
|
||||
|
||||
// can't match tracks for consumer
|
||||
if len(consumer.tracks) == 0 {
|
||||
return nil
|
||||
return errors.New("couldn't find the matching tracks")
|
||||
}
|
||||
|
||||
s.consumers = append(s.consumers, consumer)
|
||||
@@ -113,15 +119,21 @@ func (s *Stream) RemoveConsumer(cons streamer.Consumer) {
|
||||
}
|
||||
|
||||
func (s *Stream) AddProducer(prod streamer.Producer) {
|
||||
panic("not implemented")
|
||||
producer := &Producer{element: prod, state: stateTracks}
|
||||
s.producers = append(s.producers, producer)
|
||||
}
|
||||
|
||||
func (s *Stream) RemoveProducer(prod streamer.Producer) {
|
||||
panic("not implemented")
|
||||
for i, producer := range s.producers {
|
||||
if producer.element == prod {
|
||||
s.removeProducer(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Stream) Active() bool {
|
||||
if len(s.consumers) > 0{
|
||||
if len(s.consumers) > 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
|
@@ -2,6 +2,7 @@ package streams
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/fake"
|
||||
"github.com/AlexxIT/go2rtc/pkg/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
@@ -103,7 +104,7 @@ a=control:streamid=0
|
||||
|
||||
func TestRouting(t *testing.T) {
|
||||
prod := &fake.Producer{}
|
||||
prod.Medias, _ = streamer.UnmarshalRTSPSDP([]byte(dahuaSimple))
|
||||
prod.Medias, _ = rtsp.UnmarshalSDP([]byte(dahuaSimple))
|
||||
assert.Len(t, prod.Medias, 3)
|
||||
|
||||
HandleFunc("fake", func(url string) (streamer.Producer, error) {
|
||||
|
@@ -34,14 +34,19 @@ func Get(name string) *Stream {
|
||||
return nil
|
||||
}
|
||||
|
||||
func Delete(name string) {
|
||||
delete(streams, name)
|
||||
}
|
||||
|
||||
func All() map[string]interface{} {
|
||||
active := map[string]interface{}{}
|
||||
all := map[string]interface{}{}
|
||||
for name, stream := range streams {
|
||||
if stream.Active() {
|
||||
active[name] = stream
|
||||
}
|
||||
all[name] = stream
|
||||
//if stream.Active() {
|
||||
// all[name] = stream
|
||||
//}
|
||||
}
|
||||
return active
|
||||
return all
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
|
75
cmd/webrtc/candidates.go
Normal file
75
cmd/webrtc/candidates.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
"github.com/pion/sdp/v3"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var candidates []string
|
||||
|
||||
func AddCandidate(address string) {
|
||||
candidates = append(candidates, address)
|
||||
}
|
||||
|
||||
func addCanditates(answer string) (string, error) {
|
||||
if len(candidates) == 0 {
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
sd := &sdp.SessionDescription{}
|
||||
if err := sd.Unmarshal([]byte(answer)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
md := sd.MediaDescriptions[0]
|
||||
|
||||
_, end := md.Attribute("end-of-candidates")
|
||||
if end {
|
||||
md.Attributes = md.Attributes[:len(md.Attributes)-1]
|
||||
}
|
||||
|
||||
for _, address := range candidates {
|
||||
if strings.HasPrefix(address, "stun:") {
|
||||
ip, err := webrtc.GetPublicIP()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("[webrtc] public IP")
|
||||
continue
|
||||
}
|
||||
address = ip.String() + address[4:]
|
||||
|
||||
log.Debug().Str("addr", address).Msg("[webrtc] stun public address")
|
||||
}
|
||||
|
||||
cand, err := webrtc.NewCandidate(address)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("[webrtc] candidate")
|
||||
continue
|
||||
}
|
||||
|
||||
md.WithPropertyAttribute(cand)
|
||||
}
|
||||
|
||||
if end {
|
||||
md.WithPropertyAttribute("end-of-candidates")
|
||||
}
|
||||
|
||||
data, err := sd.Marshal()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func candidateHandler(ctx *api.Context, msg *streamer.Message) {
|
||||
if ctx.Consumer == nil {
|
||||
return
|
||||
}
|
||||
if conn := ctx.Consumer.(*webrtc.Conn); conn != nil {
|
||||
log.Trace().Str("candidate", msg.Value.(string)).Msg("[webrtc] remote")
|
||||
conn.Push(msg)
|
||||
}
|
||||
}
|
@@ -8,10 +8,7 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
pion "github.com/pion/webrtc/v3"
|
||||
"github.com/rs/zerolog"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
@@ -56,120 +53,23 @@ func Init() {
|
||||
|
||||
candidates = cfg.Mod.Candidates
|
||||
|
||||
api.HandleFunc("/api/webrtc", apiHandler)
|
||||
api.HandleFunc("/api/webrtc/camera", cameraHandler)
|
||||
api.HandleWS(webrtc.MsgTypeOffer, offerHandler)
|
||||
api.HandleWS(webrtc.MsgTypeCandidate, candidateHandler)
|
||||
}
|
||||
|
||||
func AddCandidate(address string) {
|
||||
candidates = append(candidates, address)
|
||||
}
|
||||
|
||||
var Port string
|
||||
var log zerolog.Logger
|
||||
var candidates []string
|
||||
|
||||
var NewPConn func() (*pion.PeerConnection, error)
|
||||
|
||||
func apiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
url := r.URL.Query().Get("url")
|
||||
stream := streams.Get(url)
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// get offer
|
||||
offer, err := ioutil.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[webrtc] read offer")
|
||||
return
|
||||
}
|
||||
|
||||
// create new webrtc instance
|
||||
cons := new(webrtc.Conn)
|
||||
cons.Conn, err = NewPConn()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[webrtc] new conn")
|
||||
return
|
||||
}
|
||||
|
||||
cons.UserAgent = r.UserAgent()
|
||||
cons.Listen(func(msg interface{}) {
|
||||
if msg == streamer.StateNull {
|
||||
stream.RemoveConsumer(cons)
|
||||
}
|
||||
})
|
||||
|
||||
if err = stream.AddConsumer(cons); err != nil {
|
||||
log.Warn().Err(err).Msg("[api.webrtc] add consumer")
|
||||
return
|
||||
}
|
||||
|
||||
cons.Init()
|
||||
|
||||
// exchange sdp with waiting all candidates
|
||||
answer, err := cons.ExchangeSDP(string(offer), true)
|
||||
|
||||
// send SDP to client
|
||||
if _, err = w.Write([]byte(answer)); err != nil {
|
||||
log.Error().Err(err).Msg("[api.webrtc] send answer")
|
||||
}
|
||||
}
|
||||
|
||||
func cameraHandler(w http.ResponseWriter, r *http.Request) {
|
||||
url := r.URL.Query().Get("url")
|
||||
stream := streams.Get(url)
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// get offer
|
||||
offer, err := ioutil.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[webrtc] read offer")
|
||||
return
|
||||
}
|
||||
|
||||
// create new webrtc instance
|
||||
conn := new(webrtc.Conn)
|
||||
conn.Conn, err = NewPConn()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[webrtc] new conn")
|
||||
return
|
||||
}
|
||||
|
||||
conn.UserAgent = r.UserAgent()
|
||||
conn.Listen(func(msg interface{}) {
|
||||
switch msg.(type) {
|
||||
case pion.PeerConnectionState:
|
||||
if msg == pion.PeerConnectionStateDisconnected {
|
||||
stream.RemoveConsumer(conn)
|
||||
}
|
||||
case streamer.Track:
|
||||
//stream.AddProducer(conn)
|
||||
}
|
||||
})
|
||||
|
||||
conn.Init()
|
||||
|
||||
// exchange sdp with waiting all candidates
|
||||
answer, err := conn.ExchangeSDP(string(offer), true)
|
||||
|
||||
// send SDP to client
|
||||
if _, err = w.Write([]byte(answer)); err != nil {
|
||||
log.Error().Err(err).Msg("[api.webrtc] send answer")
|
||||
}
|
||||
}
|
||||
|
||||
func offerHandler(ctx *api.Context, msg *streamer.Message) {
|
||||
name := ctx.Request.URL.Query().Get("url")
|
||||
stream := streams.Get(name)
|
||||
src := ctx.Request.URL.Query().Get("src")
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().Str("stream", name).Msg("[webrtc] new consumer")
|
||||
log.Debug().Str("src", src).Msg("[webrtc] new consumer")
|
||||
|
||||
var err error
|
||||
|
||||
@@ -208,6 +108,7 @@ func offerHandler(ctx *api.Context, msg *streamer.Message) {
|
||||
// 2. AddConsumer, so we get new tracks
|
||||
if err = stream.AddConsumer(conn); err != nil {
|
||||
log.Warn().Err(err).Msg("[api.webrtc] add consumer")
|
||||
_ = conn.Conn.Close()
|
||||
ctx.Error(err)
|
||||
return
|
||||
}
|
||||
@@ -216,7 +117,11 @@ func offerHandler(ctx *api.Context, msg *streamer.Message) {
|
||||
|
||||
// exchange sdp without waiting all candidates
|
||||
//answer, err := conn.ExchangeSDP(offer, false)
|
||||
answer, err := conn.GetAnswer()
|
||||
//answer, err := conn.GetAnswer()
|
||||
answer, err := conn.GetCompleteAnswer()
|
||||
if err == nil {
|
||||
answer, err = addCanditates(answer)
|
||||
}
|
||||
log.Trace().Msgf("[webrtc] answer\n%s", answer)
|
||||
|
||||
if err != nil {
|
||||
@@ -229,29 +134,6 @@ func offerHandler(ctx *api.Context, msg *streamer.Message) {
|
||||
Type: webrtc.MsgTypeAnswer, Value: answer,
|
||||
})
|
||||
|
||||
for _, address := range candidates {
|
||||
if strings.HasPrefix(address, "stun:") {
|
||||
ip, err := webrtc.GetPublicIP()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("[webrtc] public IP")
|
||||
continue
|
||||
}
|
||||
address = ip.String() + address[4:]
|
||||
|
||||
log.Debug().Str("addr", address).Msg("[webrtc] stun public address")
|
||||
}
|
||||
|
||||
cand, err := webrtc.NewCandidate(address)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("[webrtc] candidate")
|
||||
continue
|
||||
}
|
||||
|
||||
conn.Fire(&streamer.Message{
|
||||
Type: webrtc.MsgTypeCandidate, Value: cand,
|
||||
})
|
||||
}
|
||||
|
||||
ctx.Consumer = conn
|
||||
}
|
||||
|
||||
@@ -287,6 +169,7 @@ func ExchangeSDP(
|
||||
// 2. AddConsumer, so we get new tracks
|
||||
if err = stream.AddConsumer(conn); err != nil {
|
||||
log.Warn().Err(err).Msg("[api.webrtc] add consumer")
|
||||
_ = conn.Conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -295,6 +178,9 @@ func ExchangeSDP(
|
||||
// exchange sdp without waiting all candidates
|
||||
//answer, err := conn.ExchangeSDP(offer, false)
|
||||
answer, err = conn.GetCompleteAnswer()
|
||||
if err == nil {
|
||||
answer, err = addCanditates(answer)
|
||||
}
|
||||
log.Trace().Msgf("[webrtc] answer\n%s", answer)
|
||||
|
||||
if err != nil {
|
||||
@@ -303,13 +189,3 @@ func ExchangeSDP(
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func candidateHandler(ctx *api.Context, msg *streamer.Message) {
|
||||
if ctx.Consumer == nil {
|
||||
return
|
||||
}
|
||||
if conn := ctx.Consumer.(*webrtc.Conn); conn != nil {
|
||||
log.Trace().Str("candidate", msg.Value.(string)).Msg("[webrtc] remote")
|
||||
conn.Push(msg)
|
||||
}
|
||||
}
|
||||
|
11
go.mod
11
go.mod
@@ -38,4 +38,13 @@ require (
|
||||
golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664 // indirect
|
||||
)
|
||||
|
||||
replace github.com/deepch/vdk v0.0.19 => github.com/AlexxIT/vdk v0.0.18-0.20220616041030-b0d122807b2e
|
||||
replace (
|
||||
// windows support: https://github.com/brutella/dnssd/pull/35
|
||||
github.com/brutella/dnssd v1.2.2 => github.com/rblenkinsopp/dnssd v1.2.3-0.20220516082132-0923f3c787a1
|
||||
// RTP tlv8 fix
|
||||
github.com/brutella/hap v0.0.17 => github.com/AlexxIT/hap v0.0.15-0.20220823033740-ce7d1564e657
|
||||
// MSE update
|
||||
github.com/deepch/vdk v0.0.19 => github.com/AlexxIT/vdk v0.0.18-0.20220616041030-b0d122807b2e
|
||||
// AES_256_CM_HMAC_SHA1_80 support
|
||||
github.com/pion/srtp/v2 v2.0.10 => github.com/AlexxIT/srtp/v2 v2.0.10-0.20220608200505-3191d4f19c10
|
||||
)
|
||||
|
4
go.sum
4
go.sum
@@ -1,3 +1,5 @@
|
||||
github.com/AlexxIT/srtp/v2 v2.0.10-0.20220608200505-3191d4f19c10 h1:4aKRthhmkYcStKuk1hcyvkeNJ/BDx5BTIvYmDO9ZJvg=
|
||||
github.com/AlexxIT/srtp/v2 v2.0.10-0.20220608200505-3191d4f19c10/go.mod h1:5TtM9yw6lsH0ppNCehB/EjEUli7VkUgKSPJqWVqbhQ4=
|
||||
github.com/AlexxIT/vdk v0.0.18-0.20220616041030-b0d122807b2e h1:NAgHHZB+JUN3/J4/yq1q1EAc8xwJ8bb/Qp0AcjkfzAA=
|
||||
github.com/AlexxIT/vdk v0.0.18-0.20220616041030-b0d122807b2e/go.mod h1:KqQ/KU3hOc4a62l/jPRH5Hiz5fhTq5cGCl8IqeCxWQI=
|
||||
github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ=
|
||||
@@ -90,8 +92,6 @@ github.com/pion/sdp/v3 v3.0.5/go.mod h1:iiFWFpQO8Fy3S5ldclBkpXqmWy02ns78NOKoLLL0
|
||||
github.com/pion/srtp v1.5.1 h1:9Q3jAfslYZBt+C69SI/ZcONJh9049JUHZWYRRf5KEKw=
|
||||
github.com/pion/srtp v1.5.1/go.mod h1:B+QgX5xPeQTNc1CJStJPHzOlHK66ViMDWTT0HZTCkcA=
|
||||
github.com/pion/srtp/v2 v2.0.9/go.mod h1:5TtM9yw6lsH0ppNCehB/EjEUli7VkUgKSPJqWVqbhQ4=
|
||||
github.com/pion/srtp/v2 v2.0.10 h1:b8ZvEuI+mrL8hbr/f1YiJFB34UMrOac3R3N1yq2UN0w=
|
||||
github.com/pion/srtp/v2 v2.0.10/go.mod h1:XEeSWaK9PfuMs7zxXyiN252AHPbH12NX5q/CFDWtUuA=
|
||||
github.com/pion/stun v0.3.5 h1:uLUCBCkQby4S1cf6CGuR9QrVOKcvUwFeemaC865QHDg=
|
||||
github.com/pion/stun v0.3.5/go.mod h1:gDMim+47EeEtfWogA37n6qXZS88L5V6LqFcf+DZA2UA=
|
||||
github.com/pion/transport v0.6.0/go.mod h1:iWZ07doqOosSLMhZ+FXUTq+TamDoXSllxpbGcfkCmbE=
|
||||
|
2
main.go
2
main.go
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/debug"
|
||||
"github.com/AlexxIT/go2rtc/cmd/exec"
|
||||
"github.com/AlexxIT/go2rtc/cmd/ffmpeg"
|
||||
"github.com/AlexxIT/go2rtc/cmd/hass"
|
||||
@@ -33,6 +34,7 @@ func main() {
|
||||
mse.Init()
|
||||
|
||||
ngrok.Init()
|
||||
debug.Init()
|
||||
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
@@ -3,9 +3,12 @@ package rtmp
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/deepch/vdk/av"
|
||||
"github.com/deepch/vdk/codec/aacparser"
|
||||
"github.com/deepch/vdk/codec/h264parser"
|
||||
"github.com/deepch/vdk/format/rtmp"
|
||||
"github.com/pion/rtp"
|
||||
@@ -70,9 +73,36 @@ func (c *Client) Dial() (err error) {
|
||||
c.tracks = append(c.tracks, track)
|
||||
|
||||
case av.AAC:
|
||||
panic("not implemented")
|
||||
// TODO: fix support
|
||||
cd := stream.(aacparser.CodecData)
|
||||
|
||||
// a=fmtp:97 streamtype=5;profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=1588
|
||||
fmtp := fmt.Sprintf(
|
||||
"config=%s",
|
||||
hex.EncodeToString(cd.ConfigBytes),
|
||||
)
|
||||
|
||||
codec := &streamer.Codec{
|
||||
Name: streamer.CodecAAC,
|
||||
ClockRate: uint32(cd.Config.SampleRate),
|
||||
Channels: uint16(cd.Config.ChannelConfig),
|
||||
FmtpLine: fmtp,
|
||||
}
|
||||
|
||||
media := &streamer.Media{
|
||||
Kind: streamer.KindAudio,
|
||||
Direction: streamer.DirectionSendonly,
|
||||
Codecs: []*streamer.Codec{codec},
|
||||
}
|
||||
c.medias = append(c.medias, media)
|
||||
|
||||
track := &streamer.Track{
|
||||
Codec: codec, Direction: media.Direction,
|
||||
}
|
||||
c.tracks = append(c.tracks, track)
|
||||
|
||||
default:
|
||||
panic("unsupported codec")
|
||||
fmt.Printf("[rtmp] unsupported codec %+v\n", stream)
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -2,6 +2,7 @@ package rtmp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"strconv"
|
||||
)
|
||||
@@ -16,7 +17,7 @@ func (c *Client) GetTrack(media *streamer.Media, codec *streamer.Codec) *streame
|
||||
return track
|
||||
}
|
||||
}
|
||||
panic("wrong codec")
|
||||
panic(fmt.Sprintf("wrong media/codec: %+v %+v", media, codec))
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
|
117
pkg/rtsp/conn.go
117
pkg/rtsp/conn.go
@@ -43,11 +43,15 @@ const (
|
||||
ModeServerConsumer
|
||||
)
|
||||
|
||||
const KeepAlive = time.Second * 25
|
||||
|
||||
type Conn struct {
|
||||
streamer.Element
|
||||
|
||||
// public
|
||||
|
||||
Backchannel bool
|
||||
|
||||
Medias []*streamer.Media
|
||||
Session string
|
||||
UserAgent string
|
||||
@@ -104,6 +108,9 @@ func (c *Conn) Dial() (err error) {
|
||||
//if c.state != StateClientInit {
|
||||
// panic("wrong state")
|
||||
//}
|
||||
if c.conn != nil && c.auth != nil {
|
||||
c.auth.Reset()
|
||||
}
|
||||
|
||||
c.conn, err = net.DialTimeout(
|
||||
"tcp", c.URL.Host, 10*time.Second,
|
||||
@@ -144,7 +151,9 @@ func (c *Conn) Request(req *tcp.Request) error {
|
||||
}
|
||||
|
||||
c.sequence++
|
||||
req.Header.Set("CSeq", strconv.Itoa(c.sequence))
|
||||
// important to send case sensitive CSeq
|
||||
// https://github.com/AlexxIT/go2rtc/issues/7
|
||||
req.Header["CSeq"] = []string{strconv.Itoa(c.sequence)}
|
||||
|
||||
c.auth.Write(req)
|
||||
|
||||
@@ -189,7 +198,7 @@ func (c *Conn) Do(req *tcp.Request) (*tcp.Response, error) {
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("wrong response on %s", req.Method)
|
||||
return res, fmt.Errorf("wrong response on %s", req.Method)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
@@ -254,23 +263,27 @@ func (c *Conn) Describe() error {
|
||||
Method: MethodDescribe,
|
||||
URL: c.URL,
|
||||
Header: map[string][]string{
|
||||
"Accept": {"application/sdp"},
|
||||
"Require": {"www.onvif.org/ver20/backchannel"},
|
||||
"Accept": {"application/sdp"},
|
||||
},
|
||||
}
|
||||
|
||||
if c.Backchannel {
|
||||
req.Header.Set("Require", "www.onvif.org/ver20/backchannel")
|
||||
}
|
||||
|
||||
res, err := c.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// fix bug in Sonoff camera SDP "o=- 1 1 IN IP4 rom t_rtsplin"
|
||||
// TODO: make some universal fix
|
||||
if i := bytes.Index(res.Body, []byte("rom t_rtsplin")); i > 0 {
|
||||
res.Body[i+3] = '_'
|
||||
if val := res.Header.Get("Content-Base"); val != "" {
|
||||
c.URL, err = url.Parse(val)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
c.Medias, err = streamer.UnmarshalRTSPSDP(res.Body)
|
||||
c.Medias, err = UnmarshalSDP(res.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -355,10 +368,23 @@ func (c *Conn) SetupMedia(
|
||||
// we send our `interleaved`, but camera can answer with another
|
||||
|
||||
// Transport: RTP/AVP/TCP;unicast;interleaved=10-11;ssrc=10117CB7
|
||||
// Transport: RTP/AVP/TCP;unicast;destination=192.168.1.123;source=192.168.10.12;interleaved=0
|
||||
// Transport: RTP/AVP/TCP;ssrc=22345682;interleaved=0-1
|
||||
s := res.Header.Get("Transport")
|
||||
s, ok1, ok2 := between(s, "RTP/AVP/TCP;unicast;interleaved=", "-")
|
||||
if !ok1 || !ok2 {
|
||||
panic("wrong response")
|
||||
// TODO: rewrite
|
||||
if !strings.HasPrefix(s, "RTP/AVP/TCP;") {
|
||||
return nil, fmt.Errorf("wrong transport: %s", s)
|
||||
}
|
||||
|
||||
i := strings.Index(s, "interleaved=")
|
||||
if i < 0 {
|
||||
return nil, fmt.Errorf("wrong transport: %s", s)
|
||||
}
|
||||
|
||||
s = s[i+len("interleaved="):]
|
||||
i = strings.IndexAny(s, "-;")
|
||||
if i > 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
|
||||
ch, err = strconv.Atoi(s)
|
||||
@@ -425,15 +451,17 @@ func (c *Conn) Accept() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.URL == nil {
|
||||
c.URL = req.URL
|
||||
c.UserAgent = req.Header.Get("User-Agent")
|
||||
}
|
||||
|
||||
c.Fire(req)
|
||||
|
||||
// Receiver: OPTIONS > DESCRIBE > SETUP... > PLAY > TEARDOWN
|
||||
// Sender: OPTIONS > ANNOUNCE > SETUP... > RECORD > TEARDOWN
|
||||
switch req.Method {
|
||||
case MethodOptions:
|
||||
c.URL = req.URL
|
||||
c.UserAgent = req.Header.Get("User-Agent")
|
||||
|
||||
res := &tcp.Response{
|
||||
Header: map[string][]string{
|
||||
"Public": {"OPTIONS, SETUP, TEARDOWN, DESCRIBE, PLAY, PAUSE, ANNOUNCE, RECORD"},
|
||||
@@ -449,7 +477,7 @@ func (c *Conn) Accept() error {
|
||||
return errors.New("wrong content type")
|
||||
}
|
||||
|
||||
c.Medias, err = streamer.UnmarshalRTSPSDP(req.Body)
|
||||
c.Medias, err = UnmarshalSDP(req.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -549,6 +577,7 @@ func (c *Conn) Handle() (err error) {
|
||||
}()
|
||||
|
||||
//c.Fire(streamer.StatePlaying)
|
||||
ts := time.Now().Add(KeepAlive)
|
||||
|
||||
for {
|
||||
// we can read:
|
||||
@@ -603,7 +632,7 @@ func (c *Conn) Handle() (err error) {
|
||||
if channelID&1 == 0 {
|
||||
packet := &rtp.Packet{}
|
||||
if err = packet.Unmarshal(buf); err != nil {
|
||||
return errors.New("wrong RTP data")
|
||||
return
|
||||
}
|
||||
|
||||
track := c.channels[channelID]
|
||||
@@ -611,22 +640,34 @@ func (c *Conn) Handle() (err error) {
|
||||
_ = track.WriteRTP(packet)
|
||||
//return fmt.Errorf("wrong channelID: %d", channelID)
|
||||
} else {
|
||||
panic("wrong channelID")
|
||||
continue // TODO: maybe fix this
|
||||
//panic("wrong channelID")
|
||||
}
|
||||
} else {
|
||||
msg := &RTCP{Channel: channelID}
|
||||
|
||||
if err = msg.Header.Unmarshal(buf); err != nil {
|
||||
return errors.New("wrong RTCP data")
|
||||
return
|
||||
}
|
||||
|
||||
msg.Packets, err = rtcp.Unmarshal(buf)
|
||||
if err != nil {
|
||||
return errors.New("wrong RTCP data")
|
||||
return
|
||||
}
|
||||
|
||||
c.Fire(msg)
|
||||
}
|
||||
|
||||
// keep-alive
|
||||
now := time.Now()
|
||||
if now.After(ts) {
|
||||
req := &tcp.Request{Method: MethodOptions, URL: c.URL}
|
||||
// don't need to wait respose on this request
|
||||
if err = c.Request(req); err != nil {
|
||||
return err
|
||||
}
|
||||
ts = now.Add(KeepAlive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -686,17 +727,35 @@ type RTCP struct {
|
||||
Packets []rtcp.Packet
|
||||
}
|
||||
|
||||
func between(s, sub1, sub2 string) (res string, ok1 bool, ok2 bool) {
|
||||
i := strings.Index(s, sub1)
|
||||
if i >= 0 {
|
||||
ok1 = true
|
||||
s = s[i+len(sub1):]
|
||||
const sdpHeader = `v=0
|
||||
o=- 0 0 IN IP4 0.0.0.0
|
||||
s=-
|
||||
t=0 0`
|
||||
|
||||
func UnmarshalSDP(rawSDP []byte) ([]*streamer.Media, error) {
|
||||
medias, err := streamer.UnmarshalSDP(rawSDP)
|
||||
if err != nil {
|
||||
// fix SDP header for some cameras
|
||||
i := bytes.Index(rawSDP, []byte("\nm="))
|
||||
if i > 0 {
|
||||
rawSDP = append([]byte(sdpHeader), rawSDP[i:]...)
|
||||
medias, err = streamer.UnmarshalSDP(rawSDP)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
i = strings.Index(s, sub2)
|
||||
if i >= 0 {
|
||||
return s[:i], ok1, true
|
||||
// fix bug in ONVIF spec
|
||||
// https://www.onvif.org/specs/stream/ONVIF-Streaming-Spec-v241.pdf
|
||||
for _, media := range medias {
|
||||
switch media.Direction {
|
||||
case streamer.DirectionRecvonly, "":
|
||||
media.Direction = streamer.DirectionSendonly
|
||||
case streamer.DirectionSendonly:
|
||||
media.Direction = streamer.DirectionRecvonly
|
||||
}
|
||||
}
|
||||
|
||||
return s, ok1, false
|
||||
return medias, nil
|
||||
}
|
||||
|
@@ -46,12 +46,13 @@ func GetKind(name string) string {
|
||||
// - deepch/vdk/format/rtsp/sdp.Media
|
||||
// - pion/sdp.MediaDescription
|
||||
type Media struct {
|
||||
Kind string // video, audio
|
||||
Direction string
|
||||
Codecs []*Codec
|
||||
Kind string `json:"kind,omitempty"` // video or audio
|
||||
Direction string `json:"direction,omitempty"`
|
||||
Codecs []*Codec `json:"codecs,omitempty"`
|
||||
|
||||
MID string // TODO: fixme?
|
||||
Control string // TODO: fixme?
|
||||
MID string `json:"mid,omitempty"` // TODO: fixme?
|
||||
Control string `json:"control,omitempty"` // TODO: fixme?
|
||||
Title string `json:"title,omitempty"` // TODO: fixme?
|
||||
}
|
||||
|
||||
func (m *Media) String() string {
|
||||
@@ -180,26 +181,6 @@ func UnmarshalSDP(rawSDP []byte) ([]*Media, error) {
|
||||
return medias, nil
|
||||
}
|
||||
|
||||
func UnmarshalRTSPSDP(rawSDP []byte) ([]*Media, error) {
|
||||
medias, err := UnmarshalSDP(rawSDP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// fix bug in ONVIF spec
|
||||
// https://www.onvif.org/specs/stream/ONVIF-Streaming-Spec-v241.pdf
|
||||
for _, media := range medias {
|
||||
switch media.Direction {
|
||||
case DirectionRecvonly, "":
|
||||
media.Direction = DirectionSendonly
|
||||
case DirectionSendonly:
|
||||
media.Direction = DirectionRecvonly
|
||||
}
|
||||
}
|
||||
|
||||
return medias, nil
|
||||
}
|
||||
|
||||
func MarshalSDP(medias []*Media) ([]byte, error) {
|
||||
sd := &sdp.SessionDescription{}
|
||||
|
||||
|
@@ -3,6 +3,7 @@ package streamer
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/pion/rtp"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type WriterFunc func(packet *rtp.Packet) error
|
||||
@@ -12,6 +13,7 @@ type Track struct {
|
||||
Codec *Codec
|
||||
Direction string
|
||||
Sink map[*Track]WriterFunc
|
||||
mx sync.Mutex
|
||||
}
|
||||
|
||||
func (t *Track) String() string {
|
||||
@@ -21,9 +23,11 @@ func (t *Track) String() string {
|
||||
}
|
||||
|
||||
func (t *Track) WriteRTP(p *rtp.Packet) error {
|
||||
t.mx.Lock()
|
||||
for _, f := range t.Sink {
|
||||
_ = f(p)
|
||||
}
|
||||
t.mx.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -35,10 +39,14 @@ func (t *Track) Bind(w WriterFunc) *Track {
|
||||
clone := &Track{
|
||||
Codec: t.Codec, Direction: t.Direction, Sink: t.Sink,
|
||||
}
|
||||
t.mx.Lock()
|
||||
t.Sink[clone] = w
|
||||
t.mx.Unlock()
|
||||
return clone
|
||||
}
|
||||
|
||||
func (t *Track) Unbind() {
|
||||
t.mx.Lock()
|
||||
delete(t.Sink, t)
|
||||
t.mx.Unlock()
|
||||
}
|
||||
|
@@ -80,6 +80,12 @@ func (a *Auth) Write(req *Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Auth) Reset() {
|
||||
if a.Method == AuthDigest {
|
||||
a.Method = AuthUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func Between(s, sub1, sub2 string) string {
|
||||
i := strings.Index(s, sub1)
|
||||
if i < 0 {
|
||||
|
@@ -47,10 +47,13 @@ func ReadResponse(r *bufio.Reader) (*Response, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if line == "" {
|
||||
return nil, errors.New("empty response on RTSP request")
|
||||
}
|
||||
|
||||
ss := strings.SplitN(line, " ", 3)
|
||||
if len(ss) != 3 {
|
||||
return nil, errors.New("malformed response")
|
||||
return nil, fmt.Errorf("malformed response: %s", line)
|
||||
}
|
||||
|
||||
res := &Response{
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"github.com/pion/ice/v2"
|
||||
"github.com/pion/interceptor"
|
||||
"github.com/pion/webrtc/v3"
|
||||
"net"
|
||||
@@ -21,31 +22,30 @@ func NewAPI(address string) (*webrtc.API, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if address == "" {
|
||||
return webrtc.NewAPI(
|
||||
webrtc.WithMediaEngine(m),
|
||||
webrtc.WithInterceptorRegistry(i),
|
||||
), nil
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", address)
|
||||
if err != nil {
|
||||
return webrtc.NewAPI(
|
||||
webrtc.WithMediaEngine(m),
|
||||
webrtc.WithInterceptorRegistry(i),
|
||||
), err
|
||||
}
|
||||
|
||||
s := webrtc.SettingEngine{
|
||||
//LoggerFactory: customLoggerFactory{},
|
||||
}
|
||||
s.SetNetworkTypes([]webrtc.NetworkType{
|
||||
webrtc.NetworkTypeUDP4, webrtc.NetworkTypeUDP6,
|
||||
webrtc.NetworkTypeTCP4, webrtc.NetworkTypeTCP6,
|
||||
|
||||
// disable listen on Hassio docker interfaces
|
||||
s.SetInterfaceFilter(func(name string) bool {
|
||||
return name != "hassio" && name != "docker0"
|
||||
})
|
||||
|
||||
tcpMux := webrtc.NewICETCPMux(nil, ln, 8)
|
||||
s.SetICETCPMux(tcpMux)
|
||||
// disable mDNS listener
|
||||
s.SetICEMulticastDNSMode(ice.MulticastDNSModeDisabled)
|
||||
|
||||
if address != "" {
|
||||
ln, err := net.Listen("tcp", address)
|
||||
if err == nil {
|
||||
s.SetNetworkTypes([]webrtc.NetworkType{
|
||||
webrtc.NetworkTypeUDP4, webrtc.NetworkTypeUDP6,
|
||||
webrtc.NetworkTypeTCP4, webrtc.NetworkTypeTCP6,
|
||||
})
|
||||
|
||||
tcpMux := webrtc.NewICETCPMux(nil, ln, 8)
|
||||
s.SetICETCPMux(tcpMux)
|
||||
}
|
||||
}
|
||||
|
||||
return webrtc.NewAPI(
|
||||
webrtc.WithMediaEngine(m),
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/pion/webrtc/v3"
|
||||
)
|
||||
@@ -57,7 +58,8 @@ func (c *Conn) Init() {
|
||||
}
|
||||
}
|
||||
|
||||
panic("something wrong")
|
||||
fmt.Printf("TODO: webrtc ontrack %+v\n", remote)
|
||||
fmt.Printf("TODO: webrtc ontrack %#v\n", remote)
|
||||
})
|
||||
|
||||
c.Conn.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
@@ -75,76 +77,6 @@ func (c *Conn) Init() {
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Conn) ExchangeSDP(offer string, complete bool) (answer string, err error) {
|
||||
sdOffer := webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeOffer, SDP: offer,
|
||||
}
|
||||
if err = c.Conn.SetRemoteDescription(sdOffer); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
//for _, tr := range c.Conn.GetTransceivers() {
|
||||
// switch tr.Direction() {
|
||||
// case webrtc.RTPTransceiverDirectionSendonly:
|
||||
// // disable transceivers if we don't have track
|
||||
// // make direction=inactive
|
||||
// // don't really necessary, but anyway
|
||||
// if tr.Sender() == nil {
|
||||
// if err = tr.Stop(); err != nil {
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
// case webrtc.RTPTransceiverDirectionRecvonly:
|
||||
// // TODO: change codecs list
|
||||
// caps := webrtc.RTPCodecCapability{
|
||||
// MimeType: webrtc.MimeTypePCMU,
|
||||
// ClockRate: 8000,
|
||||
// }
|
||||
// codecs := []webrtc.RTPCodecParameters{
|
||||
// {RTPCodecCapability: caps},
|
||||
// }
|
||||
// if err = tr.SetCodecPreferences(codecs); err != nil {
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
var sdAnswer webrtc.SessionDescription
|
||||
sdAnswer, err = c.Conn.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
//var sd *sdp.SessionDescription
|
||||
//sd, err = sdAnswer.Unmarshal()
|
||||
//for _, media := range sd.MediaDescriptions {
|
||||
// if media.MediaName.Media != "audio" {
|
||||
// continue
|
||||
// }
|
||||
// for i, attr := range media.Attributes {
|
||||
// if attr.Key == "sendonly" {
|
||||
// attr.Key = "inactive"
|
||||
// media.Attributes[i] = attr
|
||||
// break
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
//var b []byte
|
||||
//b, err = sd.Marshal()
|
||||
//sdAnswer.SDP = string(b)
|
||||
|
||||
if err = c.Conn.SetLocalDescription(sdAnswer); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if complete {
|
||||
<-webrtc.GatheringCompletePromise(c.Conn)
|
||||
return c.Conn.LocalDescription().SDP, nil
|
||||
}
|
||||
|
||||
return sdAnswer.SDP, nil
|
||||
}
|
||||
|
||||
func (c *Conn) SetOffer(offer string) (err error) {
|
||||
sdOffer := webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeOffer, SDP: offer,
|
||||
@@ -198,7 +130,9 @@ func (c *Conn) GetCompleteAnswer() (answer string, err error) {
|
||||
func (c *Conn) remote() string {
|
||||
for _, trans := range c.Conn.GetTransceivers() {
|
||||
pair, _ := trans.Receiver().Transport().ICETransport().GetSelectedCandidatePair()
|
||||
return pair.Remote.String()
|
||||
if pair.Remote != nil {
|
||||
return pair.Remote.String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
@@ -47,6 +47,9 @@ func GetPublicIP() (net.IP, error) {
|
||||
if err = c.Do(message, func(e stun.Event) { res = e }); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = c.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if res.Error != nil {
|
||||
return nil, res.Error
|
||||
|
@@ -2,11 +2,18 @@
|
||||
|
||||
- UPX-3.96 pack broken bin for `linux_mipsel`
|
||||
- UPX-3.95 pack broken bin for `mac_amd64`
|
||||
- UPX windows pack is recognised by anti-viruses as malicious
|
||||
- `aarch64` = `arm64`
|
||||
- `armv7` = `arm`
|
||||
|
||||
## Virus
|
||||
|
||||
- https://go.dev/doc/faq#virus
|
||||
- https://groups.google.com/g/golang-nuts/c/lPwiWYaApSU
|
||||
|
||||
## Useful links
|
||||
|
||||
- https://github.com/golang/go/wiki/GoArm
|
||||
- https://gist.github.com/asukakenji/f15ba7e588ac42795f421b48b8aede63
|
||||
- https://en.wikipedia.org/wiki/AArch64
|
||||
- https://stackoverflow.com/questions/22267189/what-does-the-w-flag-mean-when-passed-in-via-the-ldflags-option-to-the-go-comman
|
||||
|
@@ -2,13 +2,13 @@
|
||||
|
||||
@SET GOOS=windows
|
||||
@SET GOARCH=amd64
|
||||
@SET FILENAME=go2rtc_win64.exe
|
||||
go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx-3.96 %FILENAME%
|
||||
@SET FILENAME=go2rtc_win64.zip
|
||||
go build -ldflags "-s -w" -trimpath && 7z a -sdel %FILENAME% go2rtc.exe
|
||||
|
||||
@SET GOOS=windows
|
||||
@SET GOARCH=386
|
||||
@SET FILENAME=go2rtc_win32.exe
|
||||
go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx-3.96 %FILENAME%
|
||||
@SET FILENAME=go2rtc_win32.zip
|
||||
go build -ldflags "-s -w" -trimpath && 7z a -sdel %FILENAME% go2rtc.exe
|
||||
|
||||
@SET GOOS=linux
|
||||
@SET GOARCH=amd64
|
||||
|
5
scripts/build_linux_amd64.cmd
Normal file
5
scripts/build_linux_amd64.cmd
Normal file
@@ -0,0 +1,5 @@
|
||||
@SET GOOS=linux
|
||||
@SET GOARCH=amd64
|
||||
@cd ..
|
||||
del go2rtc
|
||||
go build -ldflags "-s -w" -trimpath
|
5
scripts/build_linux_mips.cmd
Normal file
5
scripts/build_linux_mips.cmd
Normal file
@@ -0,0 +1,5 @@
|
||||
@ECHO OFF
|
||||
@SET GOOS=linux
|
||||
@SET GOARCH=mipsle
|
||||
cd ..
|
||||
go build -ldflags "-s -w" -trimpath && upx-3.95 go2rtc
|
5
scripts/build_mac_amd64.cmd
Normal file
5
scripts/build_mac_amd64.cmd
Normal file
@@ -0,0 +1,5 @@
|
||||
@SET GOOS=darwin
|
||||
@SET GOARCH=amd64
|
||||
@cd ..
|
||||
del go2rtc
|
||||
go build -ldflags "-s -w" -trimpath
|
4
scripts/build_win64.cmd
Normal file
4
scripts/build_win64.cmd
Normal file
@@ -0,0 +1,4 @@
|
||||
@SET GOOS=windows
|
||||
@SET GOARCH=amd64
|
||||
cd ..
|
||||
go build -ldflags "-w -s" -trimpath
|
@@ -46,4 +46,8 @@ pc.ontrack = ev => {
|
||||
|
||||
video.srcObject = ev.streams[0];
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
## Useful links
|
||||
|
||||
- https://divtable.com/table-styler/
|
82
www/devices.html
Normal file
82
www/devices.html
Normal file
@@ -0,0 +1,82 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
|
||||
<title>go2rtc</title>
|
||||
|
||||
<style>
|
||||
table {
|
||||
background-color: white;
|
||||
text-align: left;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
table td, table th {
|
||||
border: 1px solid black;
|
||||
padding: 5px 5px;
|
||||
}
|
||||
|
||||
table tbody td {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
table thead {
|
||||
background: #CFCFCF;
|
||||
background: linear-gradient(to bottom, #dbdbdb 0%, #d3d3d3 66%, #CFCFCF 100%);
|
||||
border-bottom: 3px solid black;
|
||||
}
|
||||
|
||||
table thead th {
|
||||
font-size: 15px;
|
||||
font-weight: bold;
|
||||
color: black;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 5px 5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<input id="src" type="text" placeholder="url">
|
||||
<a id="add" href="#">add</a>
|
||||
</div>
|
||||
<table id="devices">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kind</th>
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
<script>
|
||||
const baseUrl = location.origin + location.pathname.substr(
|
||||
0, location.pathname.lastIndexOf("/")
|
||||
);
|
||||
|
||||
function reload() {
|
||||
fetch(`${baseUrl}/api/devices`).then(r => {
|
||||
r.json().then(data => {
|
||||
let html = '';
|
||||
|
||||
data.forEach(function (item) {
|
||||
html += `<tr><td>${item.kind}</td><td>${item.title}</td></tr>`;
|
||||
})
|
||||
|
||||
let content = document.getElementById('devices').getElementsByTagName('tbody')[0];
|
||||
content.innerHTML = html
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
reload();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
115
www/index.html
115
www/index.html
@@ -1,44 +1,107 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport"
|
||||
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
|
||||
<title>go2rtc</title>
|
||||
|
||||
<style>
|
||||
table {
|
||||
background-color: white;
|
||||
text-align: left;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
table td, table th {
|
||||
border: 1px solid black;
|
||||
padding: 5px 5px;
|
||||
}
|
||||
|
||||
table tbody td {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
table thead {
|
||||
background: #CFCFCF;
|
||||
background: linear-gradient(to bottom, #dbdbdb 0%, #d3d3d3 66%, #CFCFCF 100%);
|
||||
border-bottom: 3px solid black;
|
||||
}
|
||||
|
||||
table thead th {
|
||||
font-size: 15px;
|
||||
font-weight: bold;
|
||||
color: black;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 5px 5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="header"></div>
|
||||
<table id="items"></table>
|
||||
<div class="header">
|
||||
<input id="src" type="text" placeholder="url">
|
||||
<a id="add" href="#">add</a>
|
||||
<a href="devices.html">devices</a>
|
||||
</div>
|
||||
<table id="streams">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Online</th>
|
||||
<th>Commands</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
<script>
|
||||
const baseUrl = location.origin + location.pathname.substr(
|
||||
0, location.pathname.lastIndexOf("/")
|
||||
);
|
||||
|
||||
const header = document.getElementById('header');
|
||||
header.innerHTML = `<a href="api/stats">stats</a>`;
|
||||
|
||||
const links = [
|
||||
'<a href="webrtc-async.html?url={name}">webrtc-async</a>',
|
||||
// '<a href="webrtc-sync.html?url={name}">webrtc-sync</a>',
|
||||
'<a href="api/frame.mp4?url={name}">frame.mp4</a>',
|
||||
'<a href="api/frame.raw?url={name}">frame.raw</a>',
|
||||
'<a href="mse.html?url={name}">mse</a>',
|
||||
'<a href="webrtc.html?src={name}">webrtc</a>',
|
||||
'<a href="mse.html?src={name}">mse</a>',
|
||||
'<a href="api/frame.mp4?src={name}">frame.mp4</a>',
|
||||
'<a href="api/streams?src={name}">info</a>',
|
||||
];
|
||||
|
||||
fetch(`${baseUrl}/api/stats`).then(r => {
|
||||
r.json().then(data => {
|
||||
const content = document.getElementById('items');
|
||||
function reload() {
|
||||
fetch(`${baseUrl}/api/streams`).then(r => {
|
||||
r.json().then(data => {
|
||||
let html = '';
|
||||
|
||||
for (let name in data.streams) {
|
||||
let html = `<tr><td>${name || 'default'}</td>`;
|
||||
links.forEach(link => {
|
||||
html += `<td>${link.replace('{name}', name)}</td>`
|
||||
})
|
||||
html += `</tr>`;
|
||||
content.innerHTML += html
|
||||
}
|
||||
});
|
||||
})
|
||||
for (const [name, value] of Object.entries(data)) {
|
||||
const online = value !== null ? value.length : 0
|
||||
html += `<tr><td>${name || 'default'}</td><td>${online}</td><td>`;
|
||||
links.forEach(link => {
|
||||
html += link.replace('{name}', encodeURIComponent(name)) + ' ';
|
||||
})
|
||||
html += `<a href="#" onclick="deleteStream('${name}')">delete</a>`;
|
||||
html += `</td></tr>`;
|
||||
}
|
||||
|
||||
let content = document.getElementById('streams').getElementsByTagName('tbody')[0];
|
||||
content.innerHTML = html
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
function deleteStream(src) {
|
||||
fetch(`${baseUrl}/api/streams?src=${encodeURIComponent(src)}`, {method: 'DELETE'}).then(reload);
|
||||
}
|
||||
|
||||
const addButton = document.querySelector('a#add');
|
||||
addButton.onclick = () => {
|
||||
let src = document.querySelector('input#src');
|
||||
fetch(`${baseUrl}/api/streams?src=${encodeURIComponent(src.value)}`, {method: 'PUT'}).then(reload);
|
||||
}
|
||||
|
||||
reload();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
Reference in New Issue
Block a user