Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b5d40caffc | |||
| 1e0952be86 | |||
| d5fa933772 | |||
| 73bf96e123 | |||
| 4ea5a22eda | |||
| a79fe6041d | |||
| 07440f359e | |||
| 01ef67153e | |||
| fded87aa33 | |||
| 52a4fc329c | |||
| ce61d5759c | |||
| 39cc4610e3 | |||
| 67b25015df | |||
| f0d627fa55 | |||
| 9809f41117 | |||
| 2ce72dbcca | |||
| ddfeb6fae6 | |||
| 19130a4858 | |||
| 51b494b193 | |||
| fd3b3c9bf1 | |||
| fa763399c2 | |||
| af2398c072 | |||
| 19b0bc5f44 | |||
| f94cd16cb7 | |||
| 3246e7284c | |||
| 9339957c13 | |||
| 4ca397da3d | |||
| f6936f7cee | |||
| bdafaef7dc | |||
| 209d7b47d9 | |||
| 4283ae1022 | |||
| c2a398211c | |||
| 6c2f883f9e | |||
| c34f9ae2b7 | |||
| c29dd8c4e3 | |||
| 9e65f18e08 | |||
| db3fb72ac8 | |||
| 90cdfafcf5 | |||
| fa8d4e4807 | |||
| 37abe2ce0d | |||
| 1c3835f2a8 | |||
| bc6e4f40bf | |||
| ac5bcda492 | |||
| 7bd42eb55f | |||
| e4c7ffd1b4 | |||
| d31cf5521b | |||
| 9de980a63c | |||
| 74cef13479 | |||
| 887a491077 | |||
| 253fc4c915 | |||
| 3a51fa2397 | |||
| 306451f94f | |||
| 39811d121b | |||
| 99b962e7bb | |||
| 3dd14a826c | |||
| a99d7097b9 | |||
| 4f97e119ac | |||
| 44ee0066a5 | |||
| e5e899450f | |||
| 05a2f53b67 | |||
| 63bcaa836a | |||
| ba68bcb89e | |||
| 4a162c9a55 | |||
| c2f5f37f40 | |||
| 11201790d2 | |||
| 64804cbc87 | |||
| 75818d6967 | |||
| 14bb4b40f7 | |||
| 0fdb0b128b | |||
| fe28c32400 | |||
| 888159d2b6 | |||
| 397eb0b6ee | |||
| ffeb473918 | |||
| 966bedd38c | |||
| 0e270081fe | |||
| 1612f9c81e | |||
| bff9b06d5d | |||
| 59555cfe1d | |||
| c94d1e237d | |||
| 82a8e07b66 | |||
| e29307125c | |||
| 1eaacdb217 | |||
| c09438d3d0 | |||
| 8b126c0d37 | |||
| 3139189975 | |||
| 4fe078c7c0 | |||
| 083ec127fd | |||
| bcb9756aca | |||
| 981974eac9 | |||
| 5b29306d4f | |||
| e89c5cb429 | |||
| 04f263aa15 | |||
| af717b2172 | |||
| 91a7b5be27 |
+2
-1
@@ -41,7 +41,8 @@ FROM base
|
||||
# Install ffmpeg, tini (for signal handling),
|
||||
# and other common tools for the echo source.
|
||||
# alsa-plugins-pulse for ALSA support (+0MB)
|
||||
RUN apk add --no-cache tini ffmpeg bash curl jq alsa-plugins-pulse
|
||||
# font-droid for FFmpeg drawtext filter (+2MB)
|
||||
RUN apk add --no-cache tini ffmpeg bash curl jq alsa-plugins-pulse font-droid
|
||||
|
||||
# Hardware Acceleration for Intel CPU (+50MB)
|
||||
ARG TARGETARCH
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# go2rtc
|
||||
|
||||
[](https://github.com/AlexxIT/go2rtc/stargazers)
|
||||
[](https://hub.docker.com/r/alexxit/go2rtc)
|
||||
[](https://github.com/AlexxIT/go2rtc/releases)
|
||||
|
||||
Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg, RTMP, etc.
|
||||
|
||||

|
||||
@@ -98,10 +102,12 @@ Download binary for your OS from [latest release](https://github.com/AlexxIT/go2
|
||||
|
||||
- `go2rtc_win64.zip` - Windows 64-bit
|
||||
- `go2rtc_win32.zip` - Windows 32-bit
|
||||
- `go2rtc_win_arm64.zip` - Windows ARM 64-bit
|
||||
- `go2rtc_linux_amd64` - Linux 64-bit
|
||||
- `go2rtc_linux_i386` - Linux 32-bit
|
||||
- `go2rtc_linux_arm64` - Linux ARM 64-bit (ex. Raspberry 64-bit OS)
|
||||
- `go2rtc_linux_arm` - Linux ARM 32-bit (ex. Raspberry 32-bit OS)
|
||||
- `go2rtc_linux_armv6` - Linux ARMv6 (for old Raspberry 1 and Zero)
|
||||
- `go2rtc_linux_mipsel` - Linux MIPS (ex. [Xiaomi Gateway 3](https://github.com/AlexxIT/XiaomiGateway3))
|
||||
- `go2rtc_mac_amd64.zip` - Mac Intel 64-bit
|
||||
- `go2rtc_mac_arm64.zip` - Mac ARM 64-bit
|
||||
@@ -199,7 +205,7 @@ streams:
|
||||
- rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=1
|
||||
amcrest_doorbell:
|
||||
- rtsp://username:password@192.168.1.123:554/cam/realmonitor?channel=1&subtype=0#backchannel=0
|
||||
unify_camera: rtspx://192.168.1.123:7441/fD6ouM72bWoFijxK
|
||||
unifi_camera: rtspx://192.168.1.123:7441/fD6ouM72bWoFijxK
|
||||
glichy_camera: ffmpeg:rstp://username:password@192.168.1.123/live/ch00_1
|
||||
```
|
||||
|
||||
@@ -207,7 +213,7 @@ streams:
|
||||
|
||||
- **Amcrest Doorbell** users may want to disable two way audio, because with an active stream you won't have a call button working. You need to add `#backchannel=0` to the end of your RTSP link in YAML config file
|
||||
- **Dahua Doorbell** users may want to change backchannel [audio codec](https://github.com/AlexxIT/go2rtc/issues/52)
|
||||
- **Unify** users may want to disable HTTPS verification. Use `rtspx://` prefix instead of `rtsps://`. And don't use `?enableSrtp` [suffix](https://github.com/AlexxIT/go2rtc/issues/81)
|
||||
- **Ubiquiti UniFi** users may want to disable HTTPS verification. Use `rtspx://` prefix instead of `rtsps://`. And don't use `?enableSrtp` [suffix](https://github.com/AlexxIT/go2rtc/issues/81)
|
||||
- **TP-Link Tapo** users may skip login and password, because go2rtc support login [without them](https://drmnsamoliu.github.io/video.html)
|
||||
- If your camera has two RTSP links - you can add both of them as sources. This is useful when streams has different codecs, as example AAC audio with main stream and PCMU/PCMA audio with second stream
|
||||
- If the stream from your camera is glitchy, try using [ffmpeg source](#source-ffmpeg). It will not add CPU load if you won't use transcoding
|
||||
@@ -447,8 +453,10 @@ streams:
|
||||
|
||||
Support import camera links from [Home Assistant](https://www.home-assistant.io/) config files:
|
||||
|
||||
- support [Generic Camera](https://www.home-assistant.io/integrations/generic/), setup via GUI
|
||||
- support [HomeKit Camera](https://www.home-assistant.io/integrations/homekit_controller/)
|
||||
- [Generic Camera](https://www.home-assistant.io/integrations/generic/), setup via GUI
|
||||
- [HomeKit Camera](https://www.home-assistant.io/integrations/homekit_controller/)
|
||||
- [ONVIF](https://www.home-assistant.io/integrations/onvif/)
|
||||
- [Roborock](https://github.com/humbertogontijo/homeassistant-roborock) vacuums with camera
|
||||
|
||||
```yaml
|
||||
hass:
|
||||
@@ -459,7 +467,7 @@ streams:
|
||||
aqara_g3: hass:Camera-Hub-G3-AB12
|
||||
```
|
||||
|
||||
More cameras, like [Tuya](https://www.home-assistant.io/integrations/tuya/), [ONVIF](https://www.home-assistant.io/integrations/onvif/), and possibly others can also be imported by using [this method](https://github.com/felipecrs/hass-expose-camera-stream-source#importing-home-assistant-cameras-to-go2rtc-andor-frigate).
|
||||
More cameras, like [Tuya](https://www.home-assistant.io/integrations/tuya/), and possibly others can also be imported by using [this method](https://github.com/felipecrs/hass-expose-camera-stream-source#importing-home-assistant-cameras-to-go2rtc-andor-frigate).
|
||||
|
||||
#### Source: ISAPI
|
||||
|
||||
@@ -572,7 +580,9 @@ POST http://localhost:1984/api/streams?dst=camera1&src=ffmpeg:http://example.com
|
||||
|
||||
### Module: API
|
||||
|
||||
The HTTP API is the main part for interacting with the application. Default address: `http://127.0.0.1:1984/`.
|
||||
The HTTP API is the main part for interacting with the application. Default address: `http://localhost:1984/`.
|
||||
|
||||
Interactive [OpenAPI](https://alexxit.github.io/go2rtc/api/).
|
||||
|
||||
go2rtc has its own JS video player (`video-rtc.js`) with:
|
||||
|
||||
@@ -619,7 +629,6 @@ api:
|
||||
|
||||
**PS:**
|
||||
|
||||
- go2rtc doesn't provide HTTPS. Use [Nginx](https://nginx.org/) or [Ngrok](#module-ngrok) or [Home Assistant Add-on](#go2rtc-home-assistant-add-on) for this tasks
|
||||
- MJPEG over WebSocket plays better than native MJPEG because Chrome [bug](https://bugs.chromium.org/p/chromium/issues/detail?id=527446)
|
||||
- MP4 over WebSocket was created only for Apple iOS because it doesn't support MSE and native MP4
|
||||
|
||||
@@ -805,6 +814,7 @@ You have several options on how to add a camera to Home Assistant:
|
||||
2. Camera [any source](#module-streams) => [go2rtc config](#configuration) => [Generic Camera](https://www.home-assistant.io/integrations/generic/)
|
||||
- Install any [go2rtc](#fast-start)
|
||||
- Add your stream to [go2rtc config](#configuration)
|
||||
- Hass > Settings > Integrations > Add Integration > [ONVIF](https://my.home-assistant.io/redirect/config_flow_start/?domain=onvif) > Host: `127.0.0.1`, Port: `1984`
|
||||
- Hass > Settings > Integrations > Add Integration > [Generic Camera](https://my.home-assistant.io/redirect/config_flow_start/?domain=generic) > `rtsp://127.0.0.1:8554/camera1` (change to your stream name)
|
||||
|
||||
You have several options on how to watch the stream from the cameras in Home Assistant:
|
||||
@@ -826,7 +836,9 @@ streams:
|
||||
"camera.hall": ffmpeg:{input}#video=copy#audio=opus
|
||||
```
|
||||
|
||||
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.
|
||||
**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.
|
||||
|
||||
**PS.** There is also another nice card with go2rtc support - [Frigate Lovelace Card](https://github.com/dermotduffy/frigate-hass-card).
|
||||
|
||||
### Module: MP4
|
||||
|
||||
@@ -1049,14 +1061,20 @@ streams:
|
||||
## Projects using go2rtc
|
||||
|
||||
- [Frigate 12+](https://frigate.video/) - open source NVR built around real-time AI object detection
|
||||
- [Frigate Lovelace Card](https://github.com/dermotduffy/frigate-hass-card) - custom card for Home Assistant
|
||||
- [ring-mqtt](https://github.com/tsightler/ring-mqtt) - Ring devices to MQTT Bridge
|
||||
- [EufyP2PStream](https://github.com/oischinger/eufyp2pstream) - A small project that provides a Video/Audio Stream from Eufy cameras that don't directly support RTSP
|
||||
- [ioBroker.euSec](https://github.com/bropat/ioBroker.eusec) - [ioBroker](https://www.iobroker.net/) adapter for control Eufy security devices
|
||||
- [MMM-go2rtc](https://github.com/Anonym-tsk/MMM-go2rtc) - MagicMirror² Module
|
||||
- [Alpine Linux](https://pkgs.alpinelinux.org/packages?name=go2rtc)
|
||||
- [NixOS](https://search.nixos.org/packages?query=go2rtc)
|
||||
- [Proxmox Helper Scripts](https://tteck.github.io/Proxmox/)
|
||||
- [Unraid](https://unraid.net/community/apps?q=go2rtc)
|
||||
|
||||
## Cameras experience
|
||||
|
||||
- [Dahua](https://www.dahuasecurity.com/) - reference implementation streaming protocols, a lot of settings, high stream quality, multiple streaming clients
|
||||
- [EZVIZ](https://www.ezviz.com/) - awful RTSP protocol realisation, many bugs in SDP
|
||||
- [Hikvision](https://www.hikvision.com/) - a lot of proprietary streaming technologies
|
||||
- [Reolink](https://reolink.com/) - some models has awful unusable RTSP realisation and not best HTTP-FLV alternative (I recommend that you contact Reolink support for new firmware), few settings
|
||||
- [Sonoff](https://sonoff.tech/) - very low stream quality, no settings, not best protocol implementation
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
openapi: 3.0.0
|
||||
|
||||
info:
|
||||
title: go2rtc
|
||||
license: { name: MIT,url: https://opensource.org/licenses/MIT }
|
||||
version: 1.0.0
|
||||
contact: { url: https://github.com/AlexxIT/go2rtc }
|
||||
description: |
|
||||
Ultimate camera streaming application with support RTSP, RTMP, HTTP-FLV, WebRTC, MSE, HLS, MP4, MJPEG, HomeKit, FFmpeg, etc.
|
||||
|
||||
servers:
|
||||
- url: http://localhost:1984
|
||||
|
||||
components:
|
||||
parameters:
|
||||
stream_src_path:
|
||||
name: src
|
||||
in: path
|
||||
description: Source stream name
|
||||
required: true
|
||||
schema: { type: string }
|
||||
example: camera1
|
||||
stream_dst_path:
|
||||
name: dst
|
||||
in: path
|
||||
description: Destination stream name
|
||||
required: true
|
||||
schema: { type: string }
|
||||
example: camera1
|
||||
stream_src_query:
|
||||
name: src
|
||||
in: query
|
||||
description: Source stream name
|
||||
required: true
|
||||
schema: { type: string }
|
||||
example: camera1
|
||||
mp4_filter:
|
||||
name: mp4
|
||||
in: query
|
||||
description: MP4 codecs filter
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum: [ "", flac, all ]
|
||||
example: flac
|
||||
video_filter:
|
||||
name: video
|
||||
in: query
|
||||
description: Video codecs filter
|
||||
schema:
|
||||
type: string
|
||||
enum: [ "", all, h264, h265, mjpeg ]
|
||||
example: h264,h265
|
||||
audio_filter:
|
||||
name: audio
|
||||
in: query
|
||||
description: Audio codecs filter
|
||||
schema:
|
||||
type: string
|
||||
enum: [ "", all, aac, opus, pcm, pcmu, pcma ]
|
||||
example: aac
|
||||
responses:
|
||||
discovery:
|
||||
description: ""
|
||||
content:
|
||||
application/json:
|
||||
example: { streams: [ { "name": "Camera 1","url": "..." } ] }
|
||||
webtorrent:
|
||||
description: ""
|
||||
content:
|
||||
application/json:
|
||||
example: { share: AKDypPy4zz, pwd: H0Km1HLTTP }
|
||||
|
||||
tags:
|
||||
- name: Application
|
||||
description: "[Module: API](https://github.com/AlexxIT/go2rtc#module-api)"
|
||||
- name: Config
|
||||
description: "[Configuration](https://github.com/AlexxIT/go2rtc#configuration)"
|
||||
- name: Streams list
|
||||
description: "[Module: Streams](https://github.com/AlexxIT/go2rtc#module-streams)"
|
||||
- name: Consume stream
|
||||
- name: Snapshot
|
||||
- name: Produce stream
|
||||
- name: Discovery
|
||||
- name: ONVIF
|
||||
- name: RTSPtoWebRTC
|
||||
- name: WebTorrent
|
||||
description: "[Module: WebTorrent](https://github.com/AlexxIT/go2rtc#module-webtorrent)"
|
||||
- name: Debug
|
||||
|
||||
paths:
|
||||
/api:
|
||||
get:
|
||||
summary: Get application info
|
||||
tags: [ Application ]
|
||||
responses:
|
||||
"200":
|
||||
description: ""
|
||||
content:
|
||||
application/json:
|
||||
example: { config_path: "/config/go2rtc.yaml",host: "192.168.1.123:1984",rtsp: { listen: ":8554",default_query: "video&audio" },version: "1.5.0" }
|
||||
|
||||
/api/exit:
|
||||
post:
|
||||
summary: Close application
|
||||
tags: [ Application ]
|
||||
parameters:
|
||||
- name: code
|
||||
in: query
|
||||
description: Application exit code
|
||||
required: false
|
||||
schema: { type: integer }
|
||||
example: 100
|
||||
responses: { }
|
||||
|
||||
|
||||
|
||||
/api/config:
|
||||
get:
|
||||
summary: Get main config file content
|
||||
tags: [ Config ]
|
||||
responses:
|
||||
"200":
|
||||
description: ""
|
||||
content:
|
||||
application/yaml: { example: "streams:..." }
|
||||
post:
|
||||
summary: Rewrite main config file
|
||||
tags: [ Config ]
|
||||
requestBody:
|
||||
content:
|
||||
"*/*": { example: "streams:..." }
|
||||
responses: { }
|
||||
patch:
|
||||
summary: Merge changes to main config file
|
||||
tags: [ Config ]
|
||||
requestBody:
|
||||
content:
|
||||
"*/*": { example: "streams:..." }
|
||||
responses: { }
|
||||
|
||||
|
||||
|
||||
/api/streams:
|
||||
get:
|
||||
summary: Get all streams info
|
||||
tags: [ Streams list ]
|
||||
responses:
|
||||
"200":
|
||||
description: ""
|
||||
content:
|
||||
application/json: { example: { camera1: { producers: [ ],consumers: [ ] } } }
|
||||
put:
|
||||
summary: Create new stream
|
||||
tags: [ Streams list ]
|
||||
parameters:
|
||||
- name: src
|
||||
in: query
|
||||
description: Stream source (URI)
|
||||
required: true
|
||||
schema: { type: string }
|
||||
example: "rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0"
|
||||
- name: name
|
||||
in: query
|
||||
description: Stream name
|
||||
required: false
|
||||
schema: { type: string }
|
||||
example: camera1
|
||||
responses: { }
|
||||
patch:
|
||||
summary: Update stream source
|
||||
tags: [ Streams list ]
|
||||
parameters:
|
||||
- name: src
|
||||
in: query
|
||||
description: Stream source (URI)
|
||||
required: true
|
||||
schema: { type: string }
|
||||
example: "rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0"
|
||||
- name: name
|
||||
in: query
|
||||
description: Stream name
|
||||
required: true
|
||||
schema: { type: string }
|
||||
example: camera1
|
||||
responses: { }
|
||||
delete:
|
||||
summary: Delete stream
|
||||
tags: [ Streams list ]
|
||||
parameters:
|
||||
- name: src
|
||||
in: query
|
||||
description: Stream name
|
||||
required: true
|
||||
schema: { type: string }
|
||||
example: camera1
|
||||
responses: { }
|
||||
post:
|
||||
summary: Send stream from source to destination
|
||||
description: "[Stream to camera](https://github.com/AlexxIT/go2rtc#stream-to-camera)"
|
||||
tags: [ Streams list ]
|
||||
parameters:
|
||||
- name: src
|
||||
in: query
|
||||
description: Stream source (URI)
|
||||
required: true
|
||||
schema: { type: string }
|
||||
example: "ffmpeg:http://example.com/song.mp3#audio=pcma#input=file"
|
||||
- name: dst
|
||||
in: query
|
||||
description: Destination stream name
|
||||
required: true
|
||||
schema: { type: string }
|
||||
example: camera1
|
||||
responses: { }
|
||||
|
||||
|
||||
|
||||
/api/streams?src={src}:
|
||||
get:
|
||||
summary: Get stream info in JSON format
|
||||
tags: [ Consume stream ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_src_path"
|
||||
responses:
|
||||
"200":
|
||||
description: ""
|
||||
content:
|
||||
application/json:
|
||||
example: { producers: [ { url: "rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0" } ], consumers: [ ] }
|
||||
|
||||
/api/webrtc?src={src}:
|
||||
post:
|
||||
summary: Get stream in WebRTC format (WHEP)
|
||||
description: "[Module: WebRTC](https://github.com/AlexxIT/go2rtc#module-webrtc)"
|
||||
tags: [ Consume stream ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_src_path"
|
||||
requestBody:
|
||||
description: |
|
||||
Support:
|
||||
- JSON format (`Content-Type: application/json`)
|
||||
- WHEP standard (`Content-Type: application/sdp`)
|
||||
- raw SDP (`Content-Type: anything`)
|
||||
required: true
|
||||
content:
|
||||
application/json: { example: { type: offer, sdp: "v=0..." } }
|
||||
"application/sdp": { example: "v=0..." }
|
||||
"*/*": { example: "v=0..." }
|
||||
|
||||
responses:
|
||||
"200":
|
||||
description: "Response on JSON or raw SDP"
|
||||
content:
|
||||
application/json: { example: { type: answer, sdp: "v=0..." } }
|
||||
application/sdp: { example: "v=0..." }
|
||||
"201":
|
||||
description: "Response on `Content-Type: application/sdp`"
|
||||
content:
|
||||
application/sdp: { example: "v=0..." }
|
||||
|
||||
/api/stream.mp4?src={src}:
|
||||
get:
|
||||
summary: Get stream in MP4 format (HTTP progressive)
|
||||
description: "[Module: MP4](https://github.com/AlexxIT/go2rtc#module-mp4)"
|
||||
tags: [ Consume stream ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_src_path"
|
||||
- name: duration
|
||||
in: query
|
||||
description: Limit the length of the stream in seconds
|
||||
required: false
|
||||
schema: { type: string }
|
||||
example: 15
|
||||
- name: filename
|
||||
in: query
|
||||
description: Download as a file with this name
|
||||
required: false
|
||||
schema: { type: string }
|
||||
example: camera1.mp4
|
||||
- $ref: "#/components/parameters/mp4_filter"
|
||||
- $ref: "#/components/parameters/video_filter"
|
||||
- $ref: "#/components/parameters/audio_filter"
|
||||
responses:
|
||||
200:
|
||||
description: ""
|
||||
content: { video/mp4: { example: "" } }
|
||||
|
||||
/api/stream.m3u8?src={src}:
|
||||
get:
|
||||
summary: Get stream in HLS format
|
||||
description: "[Module: HLS](https://github.com/AlexxIT/go2rtc#module-hls)"
|
||||
tags: [ Consume stream ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_src_path"
|
||||
- $ref: "#/components/parameters/mp4_filter"
|
||||
- $ref: "#/components/parameters/video_filter"
|
||||
- $ref: "#/components/parameters/audio_filter"
|
||||
responses:
|
||||
200:
|
||||
description: ""
|
||||
content: { application/vnd.apple.mpegurl: { example: "" } }
|
||||
|
||||
/api/stream.mjpeg?src={src}:
|
||||
get:
|
||||
summary: Get stream in MJPEG format
|
||||
description: "[Module: MJPEG](https://github.com/AlexxIT/go2rtc#module-mjpeg)"
|
||||
tags: [ Consume stream ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_src_path"
|
||||
responses:
|
||||
200:
|
||||
description: ""
|
||||
content: { multipart/x-mixed-replace: { example: "" } }
|
||||
|
||||
|
||||
|
||||
/api/frame.jpeg?src={src}:
|
||||
get:
|
||||
summary: Get snapshot in JPEG format
|
||||
description: "[Module: MJPEG](https://github.com/AlexxIT/go2rtc#module-mjpeg)"
|
||||
tags: [ Snapshot ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_src_path"
|
||||
responses:
|
||||
200:
|
||||
description: ""
|
||||
content: { image/jpeg: { example: "" } }
|
||||
/api/frame.mp4?src={src}:
|
||||
get:
|
||||
summary: Get snapshot in MP4 format
|
||||
description: "[Module: MP4](https://github.com/AlexxIT/go2rtc#module-mp4)"
|
||||
tags: [ Snapshot ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_src_path"
|
||||
responses:
|
||||
200:
|
||||
description: ""
|
||||
content: { video/mp4: { example: "" } }
|
||||
|
||||
|
||||
|
||||
/api/webrtc?dst={dst}:
|
||||
post:
|
||||
summary: Post stream in WebRTC format
|
||||
description: "[Incoming: WebRTC/WHIP](https://github.com/AlexxIT/go2rtc#incoming-webrtcwhip)"
|
||||
tags: [ Produce stream ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_dst_path"
|
||||
responses: { }
|
||||
/api/stream.flv?dst={dst}:
|
||||
post:
|
||||
summary: Post stream in FLV format
|
||||
description: "[Incoming sources](https://github.com/AlexxIT/go2rtc#incoming-sources)"
|
||||
tags: [ Produce stream ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_dst_path"
|
||||
responses: { }
|
||||
/api/stream.ts?dst={dst}:
|
||||
post:
|
||||
summary: Post stream in MPEG-TS format
|
||||
description: "[Incoming sources](https://github.com/AlexxIT/go2rtc#incoming-sources)"
|
||||
tags: [ Produce stream ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_dst_path"
|
||||
responses: { }
|
||||
/api/stream.mjpeg?dst={dst}:
|
||||
post:
|
||||
summary: Post stream in MJPEG format
|
||||
description: "[Incoming sources](https://github.com/AlexxIT/go2rtc#incoming-sources)"
|
||||
tags: [ Produce stream ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_dst_path"
|
||||
responses: { }
|
||||
|
||||
|
||||
|
||||
/api/dvrip:
|
||||
get:
|
||||
summary: DVRIP cameras discovery
|
||||
description: "[Source: DVRIP](https://github.com/AlexxIT/go2rtc#source-dvrip)"
|
||||
tags: [ Discovery ]
|
||||
responses: { }
|
||||
|
||||
/api/ffmpeg/devices:
|
||||
get:
|
||||
summary: FFmpeg USB devices discovery
|
||||
description: "[Source: FFmpeg Device](https://github.com/AlexxIT/go2rtc#source-ffmpeg-device)"
|
||||
tags: [ Discovery ]
|
||||
responses: { }
|
||||
/api/ffmpeg/hardware:
|
||||
get:
|
||||
summary: FFmpeg hardware transcoding discovery
|
||||
description: "[Hardware acceleration](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration)"
|
||||
tags: [ Discovery ]
|
||||
responses: { }
|
||||
/api/hass:
|
||||
get:
|
||||
summary: Home Assistant cameras discovery
|
||||
description: "[Source: Hass](https://github.com/AlexxIT/go2rtc#source-hass)"
|
||||
tags: [ Discovery ]
|
||||
responses: { }
|
||||
/api/homekit:
|
||||
get:
|
||||
summary: HomeKit cameras discovery
|
||||
description: "[Source: HomeKit](https://github.com/AlexxIT/go2rtc#source-homekit)"
|
||||
tags: [ Discovery ]
|
||||
responses: { }
|
||||
/api/nest:
|
||||
get:
|
||||
summary: Nest cameras discovery
|
||||
tags: [ Discovery ]
|
||||
responses: { }
|
||||
/api/onvif:
|
||||
get:
|
||||
summary: ONVIF cameras discovery
|
||||
description: "[Source: ONVIF](https://github.com/AlexxIT/go2rtc#source-onvif)"
|
||||
tags: [ Discovery ]
|
||||
responses: { }
|
||||
/api/roborock:
|
||||
get:
|
||||
summary: Roborock vacuums discovery
|
||||
description: "[Source: Roborock](https://github.com/AlexxIT/go2rtc#source-roborock)"
|
||||
tags: [ Discovery ]
|
||||
responses: { }
|
||||
|
||||
|
||||
|
||||
/onvif/:
|
||||
get:
|
||||
summary: ONVIF server implementation
|
||||
description: Simple realisation of the ONVIF protocol. Accepts any suburl requests
|
||||
tags: [ ONVIF ]
|
||||
responses: { }
|
||||
|
||||
|
||||
|
||||
/stream/:
|
||||
get:
|
||||
summary: RTSPtoWebRTC server implementation
|
||||
description: Simple API for support [RTSPtoWebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) integration
|
||||
tags: [ RTSPtoWebRTC ]
|
||||
responses: { }
|
||||
|
||||
|
||||
|
||||
/api/webtorrent?src={src}:
|
||||
get:
|
||||
summary: Get WebTorrent share info
|
||||
tags: [ WebTorrent ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_src_path"
|
||||
responses:
|
||||
200: { $ref: "#/components/responses/webtorrent" }
|
||||
post:
|
||||
summary: Add WebTorrent share
|
||||
tags: [ WebTorrent ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_src_path"
|
||||
responses:
|
||||
200: { $ref: "#/components/responses/webtorrent" }
|
||||
|
||||
delete:
|
||||
summary: Delete WebTorrent share
|
||||
tags: [ WebTorrent ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_src_path"
|
||||
responses: { }
|
||||
|
||||
/api/webtorrent:
|
||||
get:
|
||||
summary: Get all WebTorrent shares info
|
||||
tags: [ WebTorrent ]
|
||||
responses:
|
||||
200: { $ref: "#/components/responses/discovery" }
|
||||
|
||||
|
||||
|
||||
/api/stack:
|
||||
get:
|
||||
summary: Show list unknown goroutines
|
||||
tags: [ Debug ]
|
||||
responses:
|
||||
200:
|
||||
description: ""
|
||||
content: { text/plain: { example: "" } }
|
||||
@@ -0,0 +1,20 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/hass"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app.Init()
|
||||
streams.Init()
|
||||
|
||||
api.Init()
|
||||
|
||||
hass.Init()
|
||||
|
||||
shell.RunUntilSignal()
|
||||
}
|
||||
@@ -4,9 +4,7 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -15,9 +13,5 @@ func main() {
|
||||
|
||||
rtsp.Init()
|
||||
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigs
|
||||
|
||||
println("exit OK")
|
||||
shell.RunUntilSignal()
|
||||
}
|
||||
|
||||
@@ -6,54 +6,50 @@ require (
|
||||
github.com/brutella/hap v0.0.17
|
||||
github.com/deepch/vdk v0.0.19
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/hashicorp/mdns v1.0.5
|
||||
github.com/pion/ice/v2 v2.3.1
|
||||
github.com/pion/interceptor v0.1.12
|
||||
github.com/miekg/dns v1.1.55
|
||||
github.com/pion/ice/v2 v2.3.9
|
||||
github.com/pion/interceptor v0.1.17
|
||||
github.com/pion/rtcp v1.2.10
|
||||
github.com/pion/rtp v1.7.13
|
||||
github.com/pion/sdp/v3 v3.0.6
|
||||
github.com/pion/srtp/v2 v2.0.12
|
||||
github.com/pion/stun v0.4.0
|
||||
github.com/pion/webrtc/v3 v3.1.58
|
||||
github.com/rs/zerolog v1.29.0
|
||||
github.com/pion/srtp/v2 v2.0.15
|
||||
github.com/pion/stun v0.6.1
|
||||
github.com/pion/webrtc/v3 v3.2.12
|
||||
github.com/rs/zerolog v1.29.1
|
||||
github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3
|
||||
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f
|
||||
github.com/stretchr/testify v1.8.2
|
||||
github.com/stretchr/testify v1.8.4
|
||||
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/brutella/dnssd v1.2.5 // indirect
|
||||
github.com/brutella/dnssd v1.2.9 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/go-chi/chi v1.5.4 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/kr/pretty v0.2.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||
github.com/miekg/dns v1.1.52 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/pion/datachannel v1.5.5 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.6 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.7 // indirect
|
||||
github.com/pion/logging v0.2.2 // indirect
|
||||
github.com/pion/mdns v0.0.7 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/sctp v1.8.6 // indirect
|
||||
github.com/pion/transport/v2 v2.0.2 // indirect
|
||||
github.com/pion/turn/v2 v2.1.0 // indirect
|
||||
github.com/pion/udp/v2 v2.0.1 // indirect
|
||||
github.com/pion/sctp v1.8.7 // indirect
|
||||
github.com/pion/transport/v2 v2.2.1 // indirect
|
||||
github.com/pion/turn/v2 v2.1.2 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/xiam/to v0.0.0-20200126224905-d60d31e03561 // indirect
|
||||
golang.org/x/crypto v0.7.0 // indirect
|
||||
golang.org/x/mod v0.9.0 // indirect
|
||||
golang.org/x/net v0.8.0 // indirect
|
||||
golang.org/x/sys v0.6.0 // indirect
|
||||
golang.org/x/text v0.8.0 // indirect
|
||||
golang.org/x/tools v0.7.0 // indirect
|
||||
golang.org/x/crypto v0.11.0 // indirect
|
||||
golang.org/x/mod v0.12.0 // indirect
|
||||
golang.org/x/net v0.12.0 // indirect
|
||||
golang.org/x/sys v0.10.0 // indirect
|
||||
golang.org/x/text v0.11.0 // indirect
|
||||
golang.org/x/tools v0.11.0 // indirect
|
||||
)
|
||||
|
||||
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.20221108133010-d8a45b7a7045
|
||||
// fix reading AAC config bytes
|
||||
|
||||
@@ -3,9 +3,9 @@ github.com/AlexxIT/hap v0.0.15-0.20221108133010-d8a45b7a7045/go.mod h1:QNA3sm16z
|
||||
github.com/AlexxIT/vdk v0.0.18-0.20221108193131-6168555b4f92 h1:cIeYMGaAirSZnrKRDTb5VgZDDYqPLhYiczElMg4sQW0=
|
||||
github.com/AlexxIT/vdk v0.0.18-0.20221108193131-6168555b4f92/go.mod h1:7ydHfSkflMZxBXfWR79dMjrT54xzvLxnPaByOa9Jpzg=
|
||||
github.com/brutella/dnssd v1.2.3/go.mod h1:JoW2sJUrmVIef25G6lrLj7HS6Xdwh6q8WUIvMkkBYXs=
|
||||
github.com/brutella/dnssd v1.2.5 h1:b8syhho41/5ikw3X2X4baR9NWEBSlpZnfQgujsv7bk4=
|
||||
github.com/brutella/dnssd v1.2.5/go.mod h1:JoW2sJUrmVIef25G6lrLj7HS6Xdwh6q8WUIvMkkBYXs=
|
||||
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/brutella/dnssd v1.2.9 h1:eUqO0qXZAMaFN4W4Ms1AAO/OtAbNoh9U87GAlN+1FCs=
|
||||
github.com/brutella/dnssd v1.2.9/go.mod h1:yZ+GHHbGhtp5yJeKTnppdFGiy6OhiPoxs0WHW1KUcFA=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -32,8 +32,6 @@ github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/mdns v1.0.5 h1:1M5hW1cunYeoXOqHwEb/GBDDHAFo0Yqb/uz/beC6LbE=
|
||||
github.com/hashicorp/mdns v1.0.5/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
||||
@@ -46,12 +44,11 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME=
|
||||
github.com/miekg/dns v1.1.52 h1:Bmlc/qsNNULOe6bpXcUTsuOajd0DzRHwup6D9k1An0c=
|
||||
github.com/miekg/dns v1.1.52/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
|
||||
github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo=
|
||||
github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
@@ -63,12 +60,12 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
|
||||
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||
github.com/pion/datachannel v1.5.5 h1:10ef4kwdjije+M9d7Xm9im2Y3O6A6ccQb0zcqZcJew8=
|
||||
github.com/pion/datachannel v1.5.5/go.mod h1:iMz+lECmfdCMqFRhXhcA/219B0SQlbpoR2V118yimL0=
|
||||
github.com/pion/dtls/v2 v2.2.6 h1:yXMxKr0Skd+Ub6A8UqXTRLSywskx93ooMRHsQUtd+Z4=
|
||||
github.com/pion/dtls/v2 v2.2.6/go.mod h1:t8fWJCIquY5rlQZwA2yWxUS1+OCrAdXrhVKXB5oD/wY=
|
||||
github.com/pion/ice/v2 v2.3.1 h1:FQCmUfZe2Jpe7LYStVBOP6z1DiSzbIateih3TztgTjc=
|
||||
github.com/pion/ice/v2 v2.3.1/go.mod h1:aq2kc6MtYNcn4XmMhobAv6hTNJiHzvD0yXRz80+bnP8=
|
||||
github.com/pion/interceptor v0.1.12 h1:CslaNriCFUItiXS5o+hh5lpL0t0ytQkFnUcbbCs2Zq8=
|
||||
github.com/pion/interceptor v0.1.12/go.mod h1:bDtgAD9dRkBZpWHGKaoKb42FhDHTG2rX8Ii9LRALLVA=
|
||||
github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8=
|
||||
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
||||
github.com/pion/ice/v2 v2.3.9 h1:7yZpHf3PhPxJGT4JkMj1Y8Rl5cQ6fB709iz99aeMd/U=
|
||||
github.com/pion/ice/v2 v2.3.9/go.mod h1:lT3kv5uUIlHfXHU/ZRD7uKD/ufM202+eTa3C/umgGf4=
|
||||
github.com/pion/interceptor v0.1.17 h1:prJtgwFh/gB8zMqGZoOgJPHivOwVAp61i2aG61Du/1w=
|
||||
github.com/pion/interceptor v0.1.17/go.mod h1:SY8kpmfVBvrbUzvj2bsXz7OJt5JvmVNZ+4Kjq7FcwrI=
|
||||
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
|
||||
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
||||
github.com/pion/mdns v0.0.7 h1:P0UB4Sr6xDWEox0kTVxF0LmQihtCbSAdW0H2nEgkA3U=
|
||||
@@ -80,31 +77,31 @@ github.com/pion/rtcp v1.2.10/go.mod h1:ztfEwXZNLGyF1oQDttz/ZKIBaeeg/oWbRYqzBM9TL
|
||||
github.com/pion/rtp v1.7.13 h1:qcHwlmtiI50t1XivvoawdCGTP4Uiypzfrsap+bijcoA=
|
||||
github.com/pion/rtp v1.7.13/go.mod h1:bDb5n+BFZxXx0Ea7E5qe+klMuqiBrP+w8XSjiWtCUko=
|
||||
github.com/pion/sctp v1.8.5/go.mod h1:SUFFfDpViyKejTAdwD1d/HQsCu+V/40cCs2nZIvC3s0=
|
||||
github.com/pion/sctp v1.8.6 h1:CUex11Vkt9YS++VhLf8b55O3VqKrWL6W3SDwX4jAqsI=
|
||||
github.com/pion/sctp v1.8.6/go.mod h1:SUFFfDpViyKejTAdwD1d/HQsCu+V/40cCs2nZIvC3s0=
|
||||
github.com/pion/sctp v1.8.7 h1:JnABvFakZueGAn4KU/4PSKg+GWbF6QWbKTWZOSGJjXw=
|
||||
github.com/pion/sctp v1.8.7/go.mod h1:g1Ul+ARqZq5JEmoFy87Q/4CePtKnTJ1QCL9dBBdN6AU=
|
||||
github.com/pion/sdp/v3 v3.0.6 h1:WuDLhtuFUUVpTfus9ILC4HRyHsW6TdugjEX/QY9OiUw=
|
||||
github.com/pion/sdp/v3 v3.0.6/go.mod h1:iiFWFpQO8Fy3S5ldclBkpXqmWy02ns78NOKoLLL0YQw=
|
||||
github.com/pion/srtp/v2 v2.0.12 h1:WrmiVCubGMOAObBU1vwWjG0H3VSyQHawKeer2PVA5rY=
|
||||
github.com/pion/srtp/v2 v2.0.12/go.mod h1:C3Ep44hlOo2qEYaq4ddsmK5dL63eLehXFbHaZ9F5V9Y=
|
||||
github.com/pion/stun v0.4.0 h1:vgRrbBE2htWHy7l3Zsxckk7rkjnjOsSM7PHZnBwo8rk=
|
||||
github.com/pion/stun v0.4.0/go.mod h1:QPsh1/SbXASntw3zkkrIk3ZJVKz4saBY2G7S10P3wCw=
|
||||
github.com/pion/srtp/v2 v2.0.15 h1:+tqRtXGsGwHC0G0IUIAzRmdkHvriF79IHVfZGfHrQoA=
|
||||
github.com/pion/srtp/v2 v2.0.15/go.mod h1:b/pQOlDrbB0HEH5EUAQXzSYxikFbNcNuKmF8tM0hCtw=
|
||||
github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4=
|
||||
github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8=
|
||||
github.com/pion/transport v0.14.1 h1:XSM6olwW+o8J4SCmOBb/BpwZypkHeyM0PGFCxNQBr40=
|
||||
github.com/pion/transport v0.14.1/go.mod h1:4tGmbk00NeYA3rUa9+n+dzCCoKkcy3YlYb99Jn2fNnI=
|
||||
github.com/pion/transport/v2 v2.0.0/go.mod h1:HS2MEBJTwD+1ZI2eSXSvHJx/HnzQqRy2/LXxt6eVMHc=
|
||||
github.com/pion/transport/v2 v2.0.2 h1:St+8o+1PEzPT51O9bv+tH/KYYLMNR5Vwm5Z3Qkjsywg=
|
||||
github.com/pion/transport/v2 v2.0.2/go.mod h1:vrz6bUbFr/cjdwbnxq8OdDDzHf7JJfGsIRkxfpZoTA0=
|
||||
github.com/pion/turn/v2 v2.1.0 h1:5wGHSgGhJhP/RpabkUb/T9PdsAjkGLS6toYz5HNzoSI=
|
||||
github.com/pion/turn/v2 v2.1.0/go.mod h1:yrT5XbXSGX1VFSF31A3c1kCNB5bBZgk/uu5LET162qs=
|
||||
github.com/pion/udp/v2 v2.0.1 h1:xP0z6WNux1zWEjhC7onRA3EwwSliXqu1ElUZAQhUP54=
|
||||
github.com/pion/udp/v2 v2.0.1/go.mod h1:B7uvTMP00lzWdyMr/1PVZXtV3wpPIxBRd4Wl6AksXn8=
|
||||
github.com/pion/webrtc/v3 v3.1.58 h1:husXqiKQuk6gbOqJlPHs185OskAyxUW6iAEgHghgCrc=
|
||||
github.com/pion/webrtc/v3 v3.1.58/go.mod h1:jJdqoqGBlZiE3y8Z1tg1fjSkyEDCZLL+foypUBn0Lhk=
|
||||
github.com/pion/transport/v2 v2.1.0/go.mod h1:AdSw4YBZVDkZm8fpoz+fclXyQwANWmZAlDuQdctTThQ=
|
||||
github.com/pion/transport/v2 v2.2.0/go.mod h1:AdSw4YBZVDkZm8fpoz+fclXyQwANWmZAlDuQdctTThQ=
|
||||
github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c=
|
||||
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
|
||||
github.com/pion/turn/v2 v2.1.2 h1:wj0cAoGKltaZ790XEGW9HwoUewqjliwmhtxCuB2ApyM=
|
||||
github.com/pion/turn/v2 v2.1.2/go.mod h1:1kjnPkBcex3dhCU2Am+AAmxDcGhLX3WnMfmkNpvSTQU=
|
||||
github.com/pion/webrtc/v3 v3.2.12 h1:pVqz5NdtTqyhKIhMcXR8bPp709kCf9blyAhDjoVRLvA=
|
||||
github.com/pion/webrtc/v3 v3.2.12/go.mod h1:/Oz6K95CGWaN+3No+Z0NYvgOPOr3aY8UyTlMm/dec3A=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w=
|
||||
github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
|
||||
github.com/rs/zerolog v1.29.1 h1:cO+d60CHkknCbvzEWxP0S9K6KqyTjrCNUy1LdQLCGPc=
|
||||
github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU=
|
||||
github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
|
||||
github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3 h1:aQKxg3+2p+IFXXg97McgDGT5zcMrQoi0EICZs8Pgchs=
|
||||
github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA=
|
||||
@@ -118,8 +115,10 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 h1:aeN+ghOV0b2VCmKKO3gqnDQ8mLbpABZgRR2FVYx4ouI=
|
||||
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9/go.mod h1:roo6cZ/uqpwKMuvPG0YmzI5+AmUiMWfjCBZpGXqbTxE=
|
||||
github.com/xiam/to v0.0.0-20200126224905-d60d31e03561 h1:SVoNK97S6JlaYlHcaC+79tg3JUlQABcc0dH2VQ4Y+9s=
|
||||
@@ -132,15 +131,16 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
|
||||
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
|
||||
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
||||
golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
|
||||
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
|
||||
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs=
|
||||
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -148,7 +148,6 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
@@ -156,15 +155,19 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
||||
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
|
||||
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ=
|
||||
golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
|
||||
golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -175,7 +178,6 @@ golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -189,13 +191,21 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -203,15 +213,19 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
|
||||
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.7.0 h1:W4OVu8VVOaIO0yzWMNdepAulS7YfoS3Zabrm8DOXXU4=
|
||||
golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.11.0 h1:EMCa6U9S2LtZXLAMoWiR/R8dAQFRqbAitmbJ2UKhoi8=
|
||||
golang.org/x/tools v0.11.0/go.mod h1:anzJrxPjNtfgiYQYirP2CPGzGLxrH2u2QBhn6Bf3qY8=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
+7
-1
@@ -12,7 +12,13 @@ FROM ngrok/ngrok:${NGROK_VERSION} AS ngrok
|
||||
|
||||
|
||||
# 1. Build go2rtc binary
|
||||
FROM go AS build
|
||||
FROM --platform=$BUILDPLATFORM go AS build
|
||||
ARG TARGETPLATFORM
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
ENV GOOS=${TARGETOS}
|
||||
ENV GOARCH=${TARGETARCH}
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
## Go
|
||||
|
||||
```
|
||||
go mod why github.com/pion/rtcp
|
||||
go list -deps .\cmd\go2rtc_rtsp\
|
||||
```
|
||||
|
||||
## Useful links
|
||||
|
||||
- https://github.com/golang-standards/project-layout
|
||||
- https://github.com/micro/micro
|
||||
@@ -0,0 +1,4 @@
|
||||
## Exit codes
|
||||
|
||||
- https://tldp.org/LDP/abs/html/exitcodes.html
|
||||
- https://komodor.com/learn/exit-codes-in-containers-and-kubernetes-the-complete-guide/
|
||||
+72
-10
@@ -1,7 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/rs/zerolog"
|
||||
"net"
|
||||
@@ -21,6 +23,9 @@ func Init() {
|
||||
BasePath string `yaml:"base_path"`
|
||||
StaticDir string `yaml:"static_dir"`
|
||||
Origin string `yaml:"origin"`
|
||||
TLSListen string `yaml:"tls_listen"`
|
||||
TLSCert string `yaml:"tls_cert"`
|
||||
TLSKey string `yaml:"tls_key"`
|
||||
} `yaml:"api"`
|
||||
}
|
||||
|
||||
@@ -38,12 +43,10 @@ func Init() {
|
||||
log = app.GetLogger("api")
|
||||
|
||||
initStatic(cfg.Mod.StaticDir)
|
||||
initWS(cfg.Mod.Origin)
|
||||
|
||||
HandleFunc("api", apiHandler)
|
||||
HandleFunc("api/config", configHandler)
|
||||
HandleFunc("api/exit", exitHandler)
|
||||
HandleFunc("api/ws", apiWS)
|
||||
|
||||
// ensure we can listen without errors
|
||||
listener, err := net.Listen("tcp", cfg.Mod.Listen)
|
||||
@@ -75,8 +78,43 @@ func Init() {
|
||||
log.Fatal().Err(err).Msg("[api] serve")
|
||||
}
|
||||
}()
|
||||
|
||||
// Initialize the HTTPS server
|
||||
if cfg.Mod.TLSListen != "" && cfg.Mod.TLSCert != "" && cfg.Mod.TLSKey != "" {
|
||||
cert, err := tls.X509KeyPair([]byte(cfg.Mod.TLSCert), []byte(cfg.Mod.TLSKey))
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
tlsListener, err := net.Listen("tcp4", cfg.Mod.TLSListen)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().Str("addr", cfg.Mod.TLSListen).Msg("[api] tls listen")
|
||||
|
||||
tlsServer := &http.Server{
|
||||
Handler: Handler,
|
||||
TLSConfig: &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
},
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := tlsServer.ServeTLS(tlsListener, "", ""); err != nil {
|
||||
log.Fatal().Err(err).Msg("[api] tls serve")
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
MimeJSON = "application/json"
|
||||
MimeText = "text/plain"
|
||||
)
|
||||
|
||||
var Handler http.Handler
|
||||
|
||||
// HandleFunc handle pattern with relative path:
|
||||
@@ -90,6 +128,33 @@ func HandleFunc(pattern string, handler http.HandlerFunc) {
|
||||
http.HandleFunc(pattern, handler)
|
||||
}
|
||||
|
||||
// ResponseJSON important always add Content-Type
|
||||
// so go won't need to call http.DetectContentType
|
||||
func ResponseJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", MimeJSON)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func ResponsePrettyJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", MimeJSON)
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(v)
|
||||
}
|
||||
|
||||
func Response(w http.ResponseWriter, body any, contentType string) {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
|
||||
switch v := body.(type) {
|
||||
case []byte:
|
||||
_, _ = w.Write(v)
|
||||
case string:
|
||||
_, _ = w.Write([]byte(v))
|
||||
default:
|
||||
_, _ = fmt.Fprint(w, body)
|
||||
}
|
||||
}
|
||||
|
||||
const StreamNotFound = "stream not found"
|
||||
|
||||
var basePath string
|
||||
@@ -133,9 +198,7 @@ func apiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
app.Info["host"] = r.Host
|
||||
mu.Unlock()
|
||||
|
||||
if err := json.NewEncoder(w).Encode(app.Info); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
ResponseJSON(w, app.Info)
|
||||
}
|
||||
|
||||
func exitHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -160,11 +223,10 @@ func ResponseStreams(w http.ResponseWriter, streams []Stream) {
|
||||
return
|
||||
}
|
||||
|
||||
var response struct {
|
||||
var response = struct {
|
||||
Streams []Stream `json:"streams"`
|
||||
}{
|
||||
Streams: streams,
|
||||
}
|
||||
response.Streams = streams
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
ResponseJSON(w, response)
|
||||
}
|
||||
|
||||
@@ -21,9 +21,8 @@ func configHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if _, err = w.Write(data); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
// https://www.ietf.org/archive/id/draft-ietf-httpapi-yaml-mediatypes-00.html
|
||||
Response(w, data, "application/yaml")
|
||||
|
||||
case "POST", "PATCH":
|
||||
data, err := io.ReadAll(r.Body)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package api
|
||||
package ws
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/rs/zerolog/log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -9,6 +12,20 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var cfg struct {
|
||||
Mod struct {
|
||||
Origin string `yaml:"origin"`
|
||||
} `yaml:"api"`
|
||||
}
|
||||
|
||||
app.LoadConfig(&cfg)
|
||||
|
||||
initWS(cfg.Mod.Origin)
|
||||
|
||||
api.HandleFunc("api/ws", apiWS)
|
||||
}
|
||||
|
||||
// Message - struct for data exchange in Web API
|
||||
type Message struct {
|
||||
Type string `json:"type"`
|
||||
@@ -33,7 +50,7 @@ func (m *Message) GetString(key string) string {
|
||||
|
||||
type WSHandler func(tr *Transport, msg *Message) error
|
||||
|
||||
func HandleWS(msgType string, handler WSHandler) {
|
||||
func HandleFunc(msgType string, handler WSHandler) {
|
||||
wsHandlers[msgType] = handler
|
||||
}
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var Version = "1.5.0"
|
||||
var Version = "1.6.0"
|
||||
var UserAgent = "go2rtc/" + Version
|
||||
|
||||
var ConfigPath string
|
||||
|
||||
@@ -3,6 +3,7 @@ package debug
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"net/http"
|
||||
"runtime"
|
||||
)
|
||||
@@ -51,7 +52,5 @@ func stackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
"Total: %d, Skipped: %d", runtime.NumGoroutine(), skipped),
|
||||
)
|
||||
|
||||
if _, err := w.Write(buf[:i]); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
api.Response(w, buf[:i], api.MimeText)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
package dvrip
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/dvrip"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("dvrip", handle)
|
||||
|
||||
// DVRIP client autodiscovery
|
||||
api.HandleFunc("api/dvrip", apiDvrip)
|
||||
}
|
||||
|
||||
func handle(url string) (core.Producer, error) {
|
||||
@@ -23,3 +35,146 @@ func handle(url string) (core.Producer, error) {
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
const Port = 34569 // UDP port number for dvrip discovery
|
||||
|
||||
func apiDvrip(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := discover()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
api.ResponseStreams(w, items)
|
||||
}
|
||||
|
||||
func discover() ([]api.Stream, error) {
|
||||
addr := &net.UDPAddr{
|
||||
Port: Port,
|
||||
IP: net.IP{239, 255, 255, 250},
|
||||
}
|
||||
|
||||
conn, err := net.ListenUDP("udp4", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
go sendBroadcasts(conn)
|
||||
|
||||
var items []api.Stream
|
||||
|
||||
for _, info := range getResponses(conn) {
|
||||
if info.HostIP == "" || info.HostName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
host, err := hexToDecimalBytes(info.HostIP)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
items = append(items, api.Stream{
|
||||
Name: info.HostName,
|
||||
URL: "dvrip://user:pass@" + host + "?channel=0&subtype=0",
|
||||
})
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func sendBroadcasts(conn *net.UDPConn) {
|
||||
// broadcasting the same multiple times because the devies some times don't answer
|
||||
data, err := hex.DecodeString("ff00000000000000000000000000fa0500000000")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
addr := &net.UDPAddr{
|
||||
Port: Port,
|
||||
IP: net.IP{255, 255, 255, 255},
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
if _, err = conn.WriteToUDP(data, addr); err != nil {
|
||||
log.Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
NetCommon NetCommon `json:"NetWork.NetCommon"`
|
||||
Ret int `json:"Ret"`
|
||||
SessionID string `json:"SessionID"`
|
||||
}
|
||||
|
||||
type NetCommon struct {
|
||||
BuildDate string `json:"BuildDate"`
|
||||
ChannelNum int `json:"ChannelNum"`
|
||||
DeviceType int `json:"DeviceType"`
|
||||
GateWay string `json:"GateWay"`
|
||||
HostIP string `json:"HostIP"`
|
||||
HostName string `json:"HostName"`
|
||||
HttpPort int `json:"HttpPort"`
|
||||
MAC string `json:"MAC"`
|
||||
MonMode string `json:"MonMode"`
|
||||
NetConnectState int `json:"NetConnectState"`
|
||||
OtherFunction string `json:"OtherFunction"`
|
||||
SN string `json:"SN"`
|
||||
SSLPort int `json:"SSLPort"`
|
||||
Submask string `json:"Submask"`
|
||||
TCPMaxConn int `json:"TCPMaxConn"`
|
||||
TCPPort int `json:"TCPPort"`
|
||||
UDPPort int `json:"UDPPort"`
|
||||
UseHSDownLoad bool `json:"UseHSDownLoad"`
|
||||
Version string `json:"Version"`
|
||||
}
|
||||
|
||||
func getResponses(conn *net.UDPConn) (infos []*NetCommon) {
|
||||
if err := conn.SetReadDeadline(time.Now().Add(time.Second * 2)); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var ips []net.IP // processed IPs
|
||||
|
||||
b := make([]byte, 4096)
|
||||
loop:
|
||||
for {
|
||||
n, addr, err := conn.ReadFromUDP(b)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
for _, ip := range ips {
|
||||
if ip.Equal(addr.IP) {
|
||||
continue loop
|
||||
}
|
||||
}
|
||||
|
||||
if n <= 20+1 {
|
||||
continue
|
||||
}
|
||||
|
||||
var msg Message
|
||||
|
||||
if err = json.Unmarshal(b[20:n-1], &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
infos = append(infos, &msg.NetCommon)
|
||||
ips = append(ips, addr.IP)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func hexToDecimalBytes(hexIP string) (string, error) {
|
||||
b, err := hex.DecodeString(hexIP[2:]) // remove the '0x' prefix
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%d.%d.%d.%d", b[3], b[2], b[1], b[0]), nil
|
||||
}
|
||||
|
||||
@@ -3,24 +3,41 @@ package device
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// https://trac.ffmpeg.org/wiki/Capture/Webcam
|
||||
const deviceInputPrefix = "-f avfoundation"
|
||||
func queryToInput(query url.Values) string {
|
||||
video := query.Get("video")
|
||||
audio := query.Get("audio")
|
||||
|
||||
func deviceInputSuffix(video, audio string) string {
|
||||
switch {
|
||||
case video != "" && audio != "":
|
||||
return `"` + video + `:` + audio + `"`
|
||||
case video != "":
|
||||
return `"` + video + `"`
|
||||
case audio != "":
|
||||
return `":` + audio + `"`
|
||||
if video == "" && audio == "" {
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
|
||||
// https://ffmpeg.org/ffmpeg-devices.html#avfoundation
|
||||
input := "-f avfoundation"
|
||||
|
||||
if video != "" {
|
||||
video = indexToItem(videos, video)
|
||||
|
||||
for key, value := range query {
|
||||
switch key {
|
||||
case "resolution":
|
||||
input += " -video_size " + value[0]
|
||||
case "pixel_format", "framerate", "video_size", "capture_cursor", "capture_mouse_clicks", "capture_raw_data":
|
||||
input += " -" + key + " " + value[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if audio != "" {
|
||||
audio = indexToItem(audios, audio)
|
||||
}
|
||||
|
||||
return input + ` -i "` + video + `:` + audio + `"`
|
||||
}
|
||||
|
||||
func initDevices() {
|
||||
|
||||
@@ -3,19 +3,36 @@ package device
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// https://trac.ffmpeg.org/wiki/Capture/Webcam
|
||||
const deviceInputPrefix = "-f v4l2"
|
||||
func queryToInput(query url.Values) string {
|
||||
if video := query.Get("video"); video != "" {
|
||||
// https://ffmpeg.org/ffmpeg-devices.html#video4linux2_002c-v4l2
|
||||
input := "-f v4l2"
|
||||
|
||||
func deviceInputSuffix(video, audio string) string {
|
||||
if video != "" {
|
||||
return video
|
||||
for key, value := range query {
|
||||
switch key {
|
||||
case "resolution":
|
||||
input += " -video_size " + value[0]
|
||||
case "video_size", "pixel_format", "input_format", "framerate", "use_libv4l2":
|
||||
input += " -" + key + " " + value[0]
|
||||
}
|
||||
}
|
||||
|
||||
return input + " -i " + indexToItem(videos, video)
|
||||
}
|
||||
|
||||
if audio := query.Get("audio"); audio != "" {
|
||||
input := "-f alsa"
|
||||
|
||||
return input + " -i " + indexToItem(audios, audio)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -57,4 +74,15 @@ func initDevices() {
|
||||
streams = append(streams, stream)
|
||||
}
|
||||
}
|
||||
|
||||
err = exec.Command(Bin, "-f", "alsa", "-i", "default", "-t", "1", "-f", "null", "-").Run()
|
||||
if err == nil {
|
||||
stream := api.Stream{
|
||||
Name: "ALSA default",
|
||||
URL: "ffmpeg:device?audio=default#audio=opus",
|
||||
}
|
||||
|
||||
audios = append(audios, "default")
|
||||
streams = append(streams, stream)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,58 @@ package device
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// https://trac.ffmpeg.org/wiki/DirectShow
|
||||
const deviceInputPrefix = "-f dshow"
|
||||
func queryToInput(query url.Values) string {
|
||||
video := query.Get("video")
|
||||
audio := query.Get("audio")
|
||||
|
||||
if video == "" && audio == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// https://ffmpeg.org/ffmpeg-devices.html#dshow
|
||||
input := "-f dshow"
|
||||
|
||||
if video != "" {
|
||||
video = indexToItem(videos, video)
|
||||
|
||||
for key, value := range query {
|
||||
switch key {
|
||||
case "resolution":
|
||||
input += " -video_size " + value[0]
|
||||
case "video_size", "framerate", "pixel_format":
|
||||
input += " -" + key + " " + value[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if audio != "" {
|
||||
audio = indexToItem(audios, audio)
|
||||
|
||||
for key, value := range query {
|
||||
switch key {
|
||||
case "sample_rate", "sample_size", "channels", "audio_buffer_size":
|
||||
input += " -" + key + " " + value[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if video != "" {
|
||||
input += ` -i video="` + video + `"`
|
||||
|
||||
if audio != "" {
|
||||
input += `:audio="` + audio + `"`
|
||||
}
|
||||
} else {
|
||||
input += ` -i audio="` + audio + `"`
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
func deviceInputSuffix(video, audio string) string {
|
||||
switch {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -16,45 +17,23 @@ func Init(bin string) {
|
||||
}
|
||||
|
||||
func GetInput(src string) (string, error) {
|
||||
i := strings.IndexByte(src, '?')
|
||||
if i < 0 {
|
||||
return "", errors.New("empty query: " + src)
|
||||
}
|
||||
|
||||
query, err := url.ParseQuery(src[i+1:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
runonce.Do(initDevices)
|
||||
|
||||
input := deviceInputPrefix
|
||||
|
||||
var video, audio string
|
||||
|
||||
if i := strings.IndexByte(src, '?'); i > 0 {
|
||||
query, err := url.ParseQuery(src[i+1:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for key, value := range query {
|
||||
switch key {
|
||||
case "video":
|
||||
video = value[0]
|
||||
case "audio":
|
||||
audio = value[0]
|
||||
case "resolution":
|
||||
input += " -video_size " + value[0]
|
||||
default: // "input_format", "framerate", "video_size"
|
||||
input += " -" + key + " " + value[0]
|
||||
}
|
||||
}
|
||||
if input := queryToInput(query); input != "" {
|
||||
return input, nil
|
||||
}
|
||||
|
||||
if video != "" {
|
||||
if i, err := strconv.Atoi(video); err == nil && i < len(videos) {
|
||||
video = videos[i]
|
||||
}
|
||||
}
|
||||
if audio != "" {
|
||||
if i, err := strconv.Atoi(audio); err == nil && i < len(audios) {
|
||||
audio = audios[i]
|
||||
}
|
||||
}
|
||||
|
||||
input += " -i " + deviceInputSuffix(video, audio)
|
||||
|
||||
return input, nil
|
||||
return "", errors.New("wrong query: " + src)
|
||||
}
|
||||
|
||||
var Bin string
|
||||
@@ -68,3 +47,10 @@ func apiDevices(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
api.ResponseStreams(w, streams)
|
||||
}
|
||||
|
||||
func indexToItem(items []string, index string) string {
|
||||
if i, err := strconv.Atoi(index); err == nil && i < len(items) {
|
||||
return items[i]
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
+40
-29
@@ -2,6 +2,9 @@ package ffmpeg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/ffmpeg/device"
|
||||
"github.com/AlexxIT/go2rtc/internal/ffmpeg/hardware"
|
||||
@@ -9,8 +12,6 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/ffmpeg"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
@@ -45,7 +46,7 @@ var defaults = map[string]string{
|
||||
// inputs
|
||||
"file": "-re -i {input}",
|
||||
"http": "-fflags nobuffer -flags low_delay -i {input}",
|
||||
"rtsp": "-fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_transport tcp -i {input}",
|
||||
"rtsp": "-fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i {input}",
|
||||
|
||||
"rtsp/udp": "-fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -i {input}",
|
||||
|
||||
@@ -62,7 +63,9 @@ var defaults = map[string]string{
|
||||
//"mjpeg": "-c:v mjpeg -force_duplicated_matrix:v 1 -huffman:v 0 -pix_fmt:v yuvj420p",
|
||||
|
||||
// https://ffmpeg.org/ffmpeg-codecs.html#libopus-1
|
||||
"opus": "-c:a libopus -ar:a 48000 -ac:a 2 -application:a voip -compression_level:a 0",
|
||||
// https://github.com/pion/webrtc/issues/1514
|
||||
// `-af adelay=0|0` - force frame_size=960, important for WebRTC audio quality
|
||||
"opus": "-c:a libopus -ar:a 48000 -ac:a 2 -application:a voip -af adelay=0|0",
|
||||
"pcmu": "-c:a pcm_mulaw -ar:a 8000 -ac:a 1",
|
||||
"pcmu/16000": "-c:a pcm_mulaw -ar:a 16000 -ac:a 1",
|
||||
"pcmu/48000": "-c:a pcm_mulaw -ar:a 48000 -ac:a 1",
|
||||
@@ -89,8 +92,8 @@ var defaults = map[string]string{
|
||||
|
||||
// hardware NVidia on Linux and Windows
|
||||
// preset=p2 - faster, tune=ll - low latency
|
||||
"h264/cuda": "-c:v h264_nvenc -g 50 -profile:v high -level:v auto -preset:v p2 -tune:v ll",
|
||||
"h265/cuda": "-c:v hevc_nvenc -g 50 -profile:v high -level:v auto",
|
||||
"h264/cuda": "-c:v h264_nvenc -g 50 -bf 0 -profile:v high -level:v auto -preset:v p2 -tune:v ll",
|
||||
"h265/cuda": "-c:v hevc_nvenc -g 50 -bf 0 -profile:v high -level:v auto",
|
||||
|
||||
// hardware Intel on Windows
|
||||
"h264/dxva2": "-c:v h264_qsv -g 50 -bf 0 -profile:v high -level:v 4.1 -async_depth:v 1",
|
||||
@@ -102,15 +105,21 @@ var defaults = map[string]string{
|
||||
"h265/videotoolbox": "-c:v hevc_videotoolbox -g 50 -bf 0 -profile:v high -level:v 5.1",
|
||||
}
|
||||
|
||||
// configTemplate - return template from config (defaults) if exist or return raw template
|
||||
func configTemplate(template string) string {
|
||||
if s := defaults[template]; s != "" {
|
||||
return s
|
||||
}
|
||||
return template
|
||||
}
|
||||
|
||||
// inputTemplate - select input template from YAML config by template name
|
||||
// if query has input param - select another tempalte by this name
|
||||
// if query has input param - select another template by this name
|
||||
// if there is no another template - use input param as template
|
||||
func inputTemplate(name, s string, query url.Values) string {
|
||||
var template string
|
||||
if input := query.Get("input"); input != "" {
|
||||
if template = defaults[input]; template == "" {
|
||||
template = input
|
||||
}
|
||||
template = configTemplate(input)
|
||||
} else {
|
||||
template = defaults[name]
|
||||
}
|
||||
@@ -127,7 +136,7 @@ func parseArgs(s string) *ffmpeg.Args {
|
||||
|
||||
var query url.Values
|
||||
if i := strings.IndexByte(s, '#'); i > 0 {
|
||||
query = parseQuery(s[i+1:])
|
||||
query = streams.ParseQuery(s[i+1:])
|
||||
args.Video = len(query["video"])
|
||||
args.Audio = len(query["audio"])
|
||||
s = s[:i]
|
||||
@@ -191,6 +200,8 @@ func parseArgs(s string) *ffmpeg.Args {
|
||||
if query != nil {
|
||||
// 1. Process raw params for FFmpeg
|
||||
for _, raw := range query["raw"] {
|
||||
// support templates https://github.com/AlexxIT/go2rtc/issues/487
|
||||
raw = configTemplate(raw)
|
||||
args.AddCodec(raw)
|
||||
}
|
||||
|
||||
@@ -226,6 +237,18 @@ func parseArgs(s string) *ffmpeg.Args {
|
||||
}
|
||||
}
|
||||
|
||||
for _, drawtext := range query["drawtext"] {
|
||||
// support templates https://github.com/AlexxIT/go2rtc/issues/487
|
||||
drawtext = configTemplate(drawtext)
|
||||
|
||||
// support default timestamp format
|
||||
if !strings.Contains(drawtext, "text=") {
|
||||
drawtext += `:text='%{localtime\:%Y-%m-%d %X}'`
|
||||
}
|
||||
|
||||
args.AddFilter("drawtext=" + drawtext)
|
||||
}
|
||||
|
||||
// 3. Process video codecs
|
||||
if args.Video > 0 {
|
||||
for _, video := range query["video"] {
|
||||
@@ -239,8 +262,6 @@ func parseArgs(s string) *ffmpeg.Args {
|
||||
args.AddCodec("-c:v copy")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
args.AddCodec("-vn")
|
||||
}
|
||||
|
||||
// 4. Process audio codecs
|
||||
@@ -256,8 +277,6 @@ func parseArgs(s string) *ffmpeg.Args {
|
||||
args.AddCodec("-c:a copy")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
args.AddCodec("-an")
|
||||
}
|
||||
|
||||
if query["hardware"] != nil {
|
||||
@@ -265,8 +284,13 @@ func parseArgs(s string) *ffmpeg.Args {
|
||||
}
|
||||
}
|
||||
|
||||
if args.Codecs == nil {
|
||||
switch {
|
||||
case args.Video == 0 && args.Audio == 0:
|
||||
args.AddCodec("-c copy")
|
||||
case args.Video == 0:
|
||||
args.AddCodec("-vn")
|
||||
case args.Audio == 0:
|
||||
args.AddCodec("-an")
|
||||
}
|
||||
|
||||
// transcoding to only mjpeg
|
||||
@@ -278,16 +302,3 @@ func parseArgs(s string) *ffmpeg.Args {
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
func parseQuery(s string) map[string][]string {
|
||||
query := map[string][]string{}
|
||||
for _, key := range strings.Split(s, "#") {
|
||||
var value string
|
||||
i := strings.IndexByte(key, '=')
|
||||
if i > 0 {
|
||||
key, value = key[:i], key[i+1:]
|
||||
}
|
||||
query[key] = append(query[key], value)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
+198
-12
@@ -1,23 +1,209 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseArgs(t *testing.T) {
|
||||
args := parseArgs("rtsp://example.com#video=h264#rotate=180")
|
||||
assert.Equal(t, "ffmpeg -hide_banner -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_transport tcp -i rtsp://example.com -c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuvj420p -an -vf transpose=1,transpose=1 -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}", args.String())
|
||||
func TestParseArgsFile(t *testing.T) {
|
||||
// [FILE] all tracks will be copied without transcoding codecs
|
||||
args := parseArgs("/media/bbb.mp4")
|
||||
require.Equal(t, `ffmpeg -hide_banner -re -i /media/bbb.mp4 -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
args = parseArgs("rtsp://example.com#video=h264#rotate=180#hardware=vaapi")
|
||||
assert.Equal(t, "ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_transport tcp -i rtsp://example.com -c:v h264_vaapi -g 50 -bf 0 -profile:v high -level:v 4.1 -sei:v 0 -an -vf format=vaapi|nv12,hwupload,transpose_vaapi=4 -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}", args.String())
|
||||
// [FILE] video will be transcoded to H264, audio will be skipped
|
||||
args = parseArgs("/media/bbb.mp4#video=h264")
|
||||
require.Equal(t, `ffmpeg -hide_banner -re -i /media/bbb.mp4 -c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuvj420p -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [FILE] video will be copied, audio will be transcoded to pcmu
|
||||
args = parseArgs("/media/bbb.mp4#video=copy#audio=pcmu")
|
||||
require.Equal(t, `ffmpeg -hide_banner -re -i /media/bbb.mp4 -c:v copy -c:a pcm_mulaw -ar:a 8000 -ac:a 1 -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [FILE] video will be transcoded to H265 and rotate 270º, audio will be skipped
|
||||
args = parseArgs("/media/bbb.mp4#video=h265#rotate=-90")
|
||||
require.Equal(t, `ffmpeg -hide_banner -re -i /media/bbb.mp4 -c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency -an -vf "transpose=2" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [FILE] video will be output for MJPEG to pipe, audio will be skipped
|
||||
args = parseArgs("/media/bbb.mp4#video=mjpeg")
|
||||
assert.Equal(t, "ffmpeg -hide_banner -re -i /media/bbb.mp4 -c:v mjpeg -an -f mjpeg -", args.String())
|
||||
require.Equal(t, `ffmpeg -hide_banner -re -i /media/bbb.mp4 -c:v mjpeg -an -f mjpeg -`, args.String())
|
||||
|
||||
args = parseArgs("/media/bbb.mp4#video=mjpeg#hardware=vaapi")
|
||||
assert.Equal(t, "ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -re -i /media/bbb.mp4 -c:v mjpeg_vaapi -an -vf format=vaapi|nv12,hwupload -f mjpeg -", args.String())
|
||||
|
||||
args = parseArgs("device?video=0&input_format=mjpeg&video_size=1920x1080")
|
||||
assert.Equal(t, `ffmpeg -hide_banner -f dshow -input_format mjpeg -video_size 1920x1080 -i video="0" -c copy -f mjpeg -`, args.String())
|
||||
// https://github.com/AlexxIT/go2rtc/issues/509
|
||||
args = parseArgs("ffmpeg:test.mp4#raw=-ss 00:00:20")
|
||||
require.Equal(t, `ffmpeg -hide_banner -re -i ffmpeg:test.mp4 -ss 00:00:20 -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
}
|
||||
|
||||
func TestParseArgsDevice(t *testing.T) {
|
||||
// [DEVICE] video will be output for MJPEG to pipe, with size 1920x1080
|
||||
args := parseArgs("device?video=0&video_size=1920x1080")
|
||||
require.Equal(t, `ffmpeg -hide_banner -f dshow -video_size 1920x1080 -i video="0" -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [DEVICE] video will be transcoded to H265 with framerate 20, audio will be skipped
|
||||
args = parseArgs("device?video=0&video_size=1280x720&framerate=20#video=h265#audio=pcma")
|
||||
require.Equal(t, `ffmpeg -hide_banner -f dshow -video_size 1280x720 -framerate 20 -i video="0" -c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency -c:a pcm_alaw -ar:a 8000 -ac:a 1 -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
}
|
||||
|
||||
func TestParseArgsIpCam(t *testing.T) {
|
||||
// [HTTP] video will be copied
|
||||
args := parseArgs("http://example.com")
|
||||
require.Equal(t, `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -i http://example.com -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [HTTP-MJPEG] video will be transcoded to H264
|
||||
args = parseArgs("http://example.com#video=h264")
|
||||
require.Equal(t, `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -i http://example.com -c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuvj420p -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [HLS] video will be copied, audio will be skipped
|
||||
args = parseArgs("https://example.com#video=copy")
|
||||
require.Equal(t, `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -i https://example.com -c:v copy -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTSP] video will be copied without transcoding codecs
|
||||
args = parseArgs("rtsp://example.com")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types video+audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTSP] video with resize to 1280x720, should be transcoded, so select H265
|
||||
args = parseArgs("rtsp://example.com#video=h265#width=1280#height=720")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency -an -vf "scale=1280:720" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTSP] video will be copied, changing RTSP transport from TCP to UDP+TCP
|
||||
args = parseArgs("rtsp://example.com#input=rtsp/udp")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types video+audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -i rtsp://example.com -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTMP] video will be copied, changing RTSP transport from TCP to UDP+TCP
|
||||
args = parseArgs("rtmp://example.com#input=rtsp/udp")
|
||||
require.Equal(t, `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -i rtmp://example.com -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
}
|
||||
|
||||
func TestParseArgsAudio(t *testing.T) {
|
||||
// [AUDIO] audio will be transcoded to AAC, video will be skipped
|
||||
args := parseArgs("rtsp:///example.com#audio=aac")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp:///example.com -c:a aac -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [AUDIO] audio will be transcoded to AAC/16000, video will be skipped
|
||||
args = parseArgs("rtsp:///example.com#audio=aac/16000")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp:///example.com -c:a aac -ar:a 16000 -ac:a 1 -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [AUDIO] audio will be transcoded to OPUS, video will be skipped
|
||||
args = parseArgs("rtsp:///example.com#audio=opus")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp:///example.com -c:a libopus -ar:a 48000 -ac:a 2 -application:a voip -af adelay=0|0 -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [AUDIO] audio will be transcoded to PCMU, video will be skipped
|
||||
args = parseArgs("rtsp:///example.com#audio=pcmu")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp:///example.com -c:a pcm_mulaw -ar:a 8000 -ac:a 1 -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [AUDIO] audio will be transcoded to PCMU/16000, video will be skipped
|
||||
args = parseArgs("rtsp:///example.com#audio=pcmu/16000")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp:///example.com -c:a pcm_mulaw -ar:a 16000 -ac:a 1 -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [AUDIO] audio will be transcoded to PCMU/48000, video will be skipped
|
||||
args = parseArgs("rtsp:///example.com#audio=pcmu/48000")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp:///example.com -c:a pcm_mulaw -ar:a 48000 -ac:a 1 -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [AUDIO] audio will be transcoded to PCMA, video will be skipped
|
||||
args = parseArgs("rtsp:///example.com#audio=pcma")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp:///example.com -c:a pcm_alaw -ar:a 8000 -ac:a 1 -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [AUDIO] audio will be transcoded to PCMA/16000, video will be skipped
|
||||
args = parseArgs("rtsp:///example.com#audio=pcma/16000")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp:///example.com -c:a pcm_alaw -ar:a 16000 -ac:a 1 -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [AUDIO] audio will be transcoded to PCMA/48000, video will be skipped
|
||||
args = parseArgs("rtsp:///example.com#audio=pcma/48000")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp:///example.com -c:a pcm_alaw -ar:a 48000 -ac:a 1 -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
}
|
||||
|
||||
func TestParseArgsHwVaapi(t *testing.T) {
|
||||
// [HTTP-MJPEG] video will be transcoded to H264
|
||||
args := parseArgs("http:///example.com#video=h264#hardware=vaapi")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -fflags nobuffer -flags low_delay -i http:///example.com -c:v h264_vaapi -g 50 -bf 0 -profile:v high -level:v 4.1 -sei:v 0 -an -vf "format=vaapi|nv12,hwupload" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTSP] video with rotation, should be transcoded, so select H264
|
||||
args = parseArgs("rtsp://example.com#video=h264#rotate=180#hardware=vaapi")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v h264_vaapi -g 50 -bf 0 -profile:v high -level:v 4.1 -sei:v 0 -an -vf "format=vaapi|nv12,hwupload,transpose_vaapi=4" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTSP] video with resize to 1280x720, should be transcoded, so select H265
|
||||
args = parseArgs("rtsp://example.com#video=h265#width=1280#height=720#hardware=vaapi")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v hevc_vaapi -g 50 -bf 0 -profile:v high -level:v 5.1 -sei:v 0 -an -vf "format=vaapi|nv12,hwupload,scale_vaapi=1280:720" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [FILE] video will be output for MJPEG to pipe, audio will be skipped
|
||||
args = parseArgs("/media/bbb.mp4#video=mjpeg#hardware=vaapi")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -re -i /media/bbb.mp4 -c:v mjpeg_vaapi -an -vf "format=vaapi|nv12,hwupload" -f mjpeg -`, args.String())
|
||||
|
||||
// [DEVICE] MJPEG video with size 1920x1080 will be transcoded to H265
|
||||
args = parseArgs("device?video=0&video_size=1920x1080#video=h265#hardware=vaapi")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -f dshow -video_size 1920x1080 -i video="0" -c:v hevc_vaapi -g 50 -bf 0 -profile:v high -level:v 5.1 -sei:v 0 -an -vf "format=vaapi|nv12,hwupload" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
}
|
||||
|
||||
func TestParseArgsHwV4l2m2m(t *testing.T) {
|
||||
// [HTTP-MJPEG] video will be transcoded to H264
|
||||
args := parseArgs("http:///example.com#video=h264#hardware=v4l2m2m")
|
||||
require.Equal(t, `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -i http:///example.com -c:v h264_v4l2m2m -g 50 -bf 0 -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTSP] video with rotation, should be transcoded, so select H264
|
||||
args = parseArgs("rtsp://example.com#video=h264#rotate=180#hardware=v4l2m2m")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v h264_v4l2m2m -g 50 -bf 0 -an -vf "transpose=1,transpose=1" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTSP] video with resize to 1280x720, should be transcoded, so select H265
|
||||
args = parseArgs("rtsp://example.com#video=h265#width=1280#height=720#hardware=v4l2m2m")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v hevc_v4l2m2m -g 50 -bf 0 -an -vf "scale=1280:720" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [DEVICE] MJPEG video with size 1920x1080 will be transcoded to H265
|
||||
args = parseArgs("device?video=0&video_size=1920x1080#video=h265#hardware=v4l2m2m")
|
||||
require.Equal(t, `ffmpeg -hide_banner -f dshow -video_size 1920x1080 -i video="0" -c:v hevc_v4l2m2m -g 50 -bf 0 -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
}
|
||||
|
||||
func TestParseArgsHwCuda(t *testing.T) {
|
||||
// [HTTP-MJPEG] video will be transcoded to H264
|
||||
args := parseArgs("http:///example.com#video=h264#hardware=cuda")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel cuda -hwaccel_output_format cuda -fflags nobuffer -flags low_delay -i http:///example.com -c:v h264_nvenc -g 50 -bf 0 -profile:v high -level:v auto -preset:v p2 -tune:v ll -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTSP] video with rotation, should be transcoded, so select H264
|
||||
args = parseArgs("rtsp://example.com#video=h264#rotate=180#hardware=cuda")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel cuda -hwaccel_output_format nv12 -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v h264_nvenc -g 50 -bf 0 -profile:v high -level:v auto -preset:v p2 -tune:v ll -an -vf "transpose=1,transpose=1,hwupload" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTSP] video with resize to 1280x720, should be transcoded, so select H265
|
||||
args = parseArgs("rtsp://example.com#video=h265#width=1280#height=720#hardware=cuda")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel cuda -hwaccel_output_format cuda -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v hevc_nvenc -g 50 -bf 0 -profile:v high -level:v auto -an -vf "scale_cuda=1280:720" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [DEVICE] MJPEG video with size 1920x1080 will be transcoded to H265
|
||||
args = parseArgs("device?video=0&video_size=1920x1080#video=h265#hardware=cuda")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel cuda -hwaccel_output_format cuda -f dshow -video_size 1920x1080 -i video="0" -c:v hevc_nvenc -g 50 -bf 0 -profile:v high -level:v auto -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
}
|
||||
|
||||
func TestParseArgsHwDxva2(t *testing.T) {
|
||||
// [HTTP-MJPEG] video will be transcoded to H264
|
||||
args := parseArgs("http:///example.com#video=h264#hardware=dxva2")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel dxva2 -hwaccel_output_format dxva2_vld -fflags nobuffer -flags low_delay -i http:///example.com -c:v h264_qsv -g 50 -bf 0 -profile:v high -level:v 4.1 -async_depth:v 1 -an -vf "hwmap=derive_device=qsv,format=qsv" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTSP] video with rotation, should be transcoded, so select H264
|
||||
args = parseArgs("rtsp://example.com#video=h264#rotate=180#hardware=dxva2")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel dxva2 -hwaccel_output_format dxva2_vld -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v h264_qsv -g 50 -bf 0 -profile:v high -level:v 4.1 -async_depth:v 1 -an -vf "hwmap=derive_device=qsv,format=qsv,transpose=1,transpose=1" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTSP] video with resize to 1280x720, should be transcoded, so select H265
|
||||
args = parseArgs("rtsp://example.com#video=h265#width=1280#height=720#hardware=dxva2")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel dxva2 -hwaccel_output_format dxva2_vld -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v hevc_qsv -g 50 -bf 0 -profile:v high -level:v 5.1 -async_depth:v 1 -an -vf "hwmap=derive_device=qsv,format=qsv,scale_qsv=1280:720" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [FILE] video will be output for MJPEG to pipe, audio will be skipped
|
||||
args = parseArgs("/media/bbb.mp4#video=mjpeg#hardware=dxva2")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel dxva2 -hwaccel_output_format dxva2_vld -re -i /media/bbb.mp4 -c:v mjpeg_qsv -profile:v high -level:v 5.1 -an -vf "hwmap=derive_device=qsv,format=qsv" -f mjpeg -`, args.String())
|
||||
|
||||
// [DEVICE] MJPEG video with size 1920x1080 will be transcoded to H265
|
||||
args = parseArgs("device?video=0&video_size=1920x1080#video=h265#hardware=dxva2")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel dxva2 -hwaccel_output_format dxva2_vld -f dshow -video_size 1920x1080 -i video="0" -c:v hevc_qsv -g 50 -bf 0 -profile:v high -level:v 5.1 -async_depth:v 1 -an -vf "hwmap=derive_device=qsv,format=qsv" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
}
|
||||
|
||||
func TestParseArgsHwVideotoolbox(t *testing.T) {
|
||||
// [HTTP-MJPEG] video will be transcoded to H264
|
||||
args := parseArgs("http:///example.com#video=h264#hardware=videotoolbox")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel videotoolbox -hwaccel_output_format videotoolbox_vld -fflags nobuffer -flags low_delay -i http:///example.com -c:v h264_videotoolbox -g 50 -bf 0 -profile:v high -level:v 4.1 -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTSP] video with rotation, should be transcoded, so select H264
|
||||
args = parseArgs("rtsp://example.com#video=h264#rotate=180#hardware=videotoolbox")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel videotoolbox -hwaccel_output_format videotoolbox_vld -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v h264_videotoolbox -g 50 -bf 0 -profile:v high -level:v 4.1 -an -vf "transpose=1,transpose=1" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTSP] video with resize to 1280x720, should be transcoded, so select H265
|
||||
args = parseArgs("rtsp://example.com#video=h265#width=1280#height=720#hardware=videotoolbox")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel videotoolbox -hwaccel_output_format videotoolbox_vld -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v hevc_videotoolbox -g 50 -bf 0 -profile:v high -level:v 5.1 -an -vf "scale=1280:720" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [DEVICE] MJPEG video with size 1920x1080 will be transcoded to H265
|
||||
args = parseArgs("device?video=0&video_size=1920x1080#video=h265#hardware=videotoolbox")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel videotoolbox -hwaccel_output_format videotoolbox_vld -f dshow -video_size 1920x1080 -i video="0" -c:v hevc_videotoolbox -g 50 -bf 0 -profile:v high -level:v 5.1 -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
}
|
||||
|
||||
@@ -55,33 +55,51 @@ func MakeHardware(args *ffmpeg.Args, engine string, defaults map[string]string)
|
||||
|
||||
switch engine {
|
||||
case EngineVAAPI:
|
||||
args.Input = "-hwaccel vaapi -hwaccel_output_format vaapi " + args.Input
|
||||
args.Codecs[i] = defaults[name+"/"+engine]
|
||||
|
||||
for i, filter := range args.Filters {
|
||||
if strings.HasPrefix(filter, "scale=") {
|
||||
args.Filters[i] = "scale_vaapi=" + filter[6:]
|
||||
}
|
||||
if strings.HasPrefix(filter, "transpose=") {
|
||||
if filter == "transpose=1,transpose=1" { // 180 degrees half-turn
|
||||
args.Filters[i] = "transpose_vaapi=4" // reversal
|
||||
} else {
|
||||
args.Filters[i] = "transpose_vaapi=" + filter[10:]
|
||||
if !args.HasFilters("drawtext=") {
|
||||
args.Input = "-hwaccel vaapi -hwaccel_output_format vaapi " + args.Input
|
||||
|
||||
for i, filter := range args.Filters {
|
||||
if strings.HasPrefix(filter, "scale=") {
|
||||
args.Filters[i] = "scale_vaapi=" + filter[6:]
|
||||
}
|
||||
if strings.HasPrefix(filter, "transpose=") {
|
||||
if filter == "transpose=1,transpose=1" { // 180 degrees half-turn
|
||||
args.Filters[i] = "transpose_vaapi=4" // reversal
|
||||
} else {
|
||||
args.Filters[i] = "transpose_vaapi=" + filter[10:]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fix if input doesn't support hwaccel, do nothing when support
|
||||
// insert as first filter before hardware scale and transpose
|
||||
args.InsertFilter("format=vaapi|nv12,hwupload")
|
||||
} else {
|
||||
// enable software pixel for drawtext, scale and transpose
|
||||
args.Input = "-hwaccel vaapi -hwaccel_output_format nv12 " + args.Input
|
||||
|
||||
args.AddFilter("hwupload")
|
||||
}
|
||||
|
||||
// fix if input doesn't support hwaccel, do nothing when support
|
||||
args.InsertFilter("format=vaapi|nv12,hwupload")
|
||||
|
||||
case EngineCUDA:
|
||||
args.Input = "-hwaccel cuda -hwaccel_output_format cuda -extra_hw_frames 2 " + args.Input
|
||||
args.Codecs[i] = defaults[name+"/"+engine]
|
||||
|
||||
for i, filter := range args.Filters {
|
||||
if strings.HasPrefix(filter, "scale=") {
|
||||
args.Filters[i] = "scale_cuda=" + filter[6:]
|
||||
// CUDA doesn't support hardware transpose
|
||||
// https://github.com/AlexxIT/go2rtc/issues/389
|
||||
if !args.HasFilters("drawtext=", "transpose=") {
|
||||
args.Input = "-hwaccel cuda -hwaccel_output_format cuda " + args.Input
|
||||
|
||||
for i, filter := range args.Filters {
|
||||
if strings.HasPrefix(filter, "scale=") {
|
||||
args.Filters[i] = "scale_cuda=" + filter[6:]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
args.Input = "-hwaccel cuda -hwaccel_output_format nv12 " + args.Input
|
||||
|
||||
args.AddFilter("hwupload")
|
||||
}
|
||||
|
||||
case EngineDXVA2:
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
)
|
||||
|
||||
const ProbeVideoToolboxH264 = "-f lavfi -i testsrc2 -t 1 -c h264_videotoolbox -f null -"
|
||||
const ProbeVideoToolboxH265 = "-f lavfi -i testsrc2 -t 1 -c hevc_videotoolbox -f null -"
|
||||
const ProbeVideoToolboxH264 = "-f lavfi -i testsrc2=size=svga -t 1 -c h264_videotoolbox -f null -"
|
||||
const ProbeVideoToolboxH265 = "-f lavfi -i testsrc2=size=svga -t 1 -c hevc_videotoolbox -f null -"
|
||||
|
||||
func ProbeAll(bin string) []api.Stream {
|
||||
return []api.Stream{
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
func TranscodeToJPEG(b []byte) ([]byte, error) {
|
||||
cmd := exec.Command("ffmpeg", "-hide_banner", "-i", "-", "-f", "mjpeg", "-")
|
||||
cmd := exec.Command(defaults["bin"], "-hide_banner", "-i", "-", "-f", "mjpeg", "-")
|
||||
cmd.Stdin = bytes.NewBuffer(b)
|
||||
return cmd.Output()
|
||||
}
|
||||
|
||||
+60
-71
@@ -11,79 +11,68 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func initAPI() {
|
||||
ok := func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":1,"payload":{}}`))
|
||||
}
|
||||
func apiOK(w http.ResponseWriter, r *http.Request) {
|
||||
api.Response(w, `{"status":1,"payload":{}}`, api.MimeJSON)
|
||||
}
|
||||
|
||||
// support https://www.home-assistant.io/integrations/rtsp_to_webrtc/
|
||||
api.HandleFunc("/static", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
api.HandleFunc("/streams", ok)
|
||||
|
||||
// api from RTSPtoWeb
|
||||
api.HandleFunc("/stream/", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
// /stream/{id}/add
|
||||
case strings.HasSuffix(r.RequestURI, "/add"):
|
||||
var v addJSON
|
||||
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// we can get three types of links:
|
||||
// 1. link to go2rtc stream: rtsp://...:8554/{stream_name}
|
||||
// 2. static link to Hass camera
|
||||
// 3. dynamic link to Hass camera
|
||||
stream := streams.Get(v.Name)
|
||||
if stream == nil {
|
||||
stream = streams.NewTemplate(v.Name, v.Channels.First.Url)
|
||||
}
|
||||
|
||||
stream.SetSource(v.Channels.First.Url)
|
||||
|
||||
ok(w, r)
|
||||
|
||||
// /stream/{id}/channel/0/webrtc
|
||||
default:
|
||||
i := strings.IndexByte(r.RequestURI[8:], '/')
|
||||
if i <= 0 {
|
||||
log.Warn().Msgf("wrong request: %s", r.RequestURI)
|
||||
return
|
||||
}
|
||||
name := r.RequestURI[8 : 8+i]
|
||||
|
||||
stream := streams.Get(name)
|
||||
if stream == nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] parse form")
|
||||
return
|
||||
}
|
||||
|
||||
s := r.FormValue("data")
|
||||
offer, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] sdp64 decode")
|
||||
return
|
||||
}
|
||||
|
||||
s, err = webrtc.ExchangeSDP(stream, string(offer), "WebRTC/Hass sync", r.UserAgent())
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] exchange SDP")
|
||||
return
|
||||
}
|
||||
|
||||
s = base64.StdEncoding.EncodeToString([]byte(s))
|
||||
_, _ = w.Write([]byte(s))
|
||||
func apiStream(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
// /stream/{id}/add
|
||||
case strings.HasSuffix(r.RequestURI, "/add"):
|
||||
var v addJSON
|
||||
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
// we can get three types of links:
|
||||
// 1. link to go2rtc stream: rtsp://...:8554/{stream_name}
|
||||
// 2. static link to Hass camera
|
||||
// 3. dynamic link to Hass camera
|
||||
stream := streams.Get(v.Name)
|
||||
if stream == nil {
|
||||
stream = streams.NewTemplate(v.Name, v.Channels.First.Url)
|
||||
}
|
||||
|
||||
stream.SetSource(v.Channels.First.Url)
|
||||
|
||||
apiOK(w, r)
|
||||
|
||||
// /stream/{id}/channel/0/webrtc
|
||||
default:
|
||||
i := strings.IndexByte(r.RequestURI[8:], '/')
|
||||
if i <= 0 {
|
||||
log.Warn().Msgf("wrong request: %s", r.RequestURI)
|
||||
return
|
||||
}
|
||||
name := r.RequestURI[8 : 8+i]
|
||||
|
||||
stream := streams.Get(name)
|
||||
if stream == nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] parse form")
|
||||
return
|
||||
}
|
||||
|
||||
s := r.FormValue("data")
|
||||
offer, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] sdp64 decode")
|
||||
return
|
||||
}
|
||||
|
||||
s, err = webrtc.ExchangeSDP(stream, string(offer), "WebRTC/Hass sync", r.UserAgent())
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] exchange SDP")
|
||||
return
|
||||
}
|
||||
|
||||
s = base64.StdEncoding.EncodeToString([]byte(s))
|
||||
_, _ = w.Write([]byte(s))
|
||||
}
|
||||
}
|
||||
|
||||
func HassioAddr() string {
|
||||
|
||||
+66
-18
@@ -9,10 +9,12 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/internal/roborock"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hass"
|
||||
"github.com/rs/zerolog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
@@ -29,10 +31,15 @@ func Init() {
|
||||
|
||||
log = app.GetLogger("hass")
|
||||
|
||||
initAPI()
|
||||
// support API for https://www.home-assistant.io/integrations/rtsp_to_webrtc/
|
||||
api.HandleFunc("/static", apiOK)
|
||||
api.HandleFunc("/streams", apiOK)
|
||||
api.HandleFunc("/stream/", apiStream)
|
||||
|
||||
// load static entries from Hass config
|
||||
if err := importConfig(conf.Mod.Config); err != nil {
|
||||
log.Debug().Msgf("[hass] can't import config: %s", err)
|
||||
|
||||
entries := importEntries(conf.Mod.Config)
|
||||
if entries == nil {
|
||||
api.HandleFunc("api/hass", func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "no hass config", http.StatusNotFound)
|
||||
})
|
||||
@@ -40,18 +47,35 @@ func Init() {
|
||||
}
|
||||
|
||||
api.HandleFunc("api/hass", func(w http.ResponseWriter, _ *http.Request) {
|
||||
once.Do(func() {
|
||||
// load WebRTC entities from Hass API, works only for add-on version
|
||||
if token := hass.SupervisorToken(); token != "" {
|
||||
if err := importWebRTC(token); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
var items []api.Stream
|
||||
for name, url := range entries {
|
||||
for name, url := range entities {
|
||||
items = append(items, api.Stream{Name: name, URL: url})
|
||||
}
|
||||
api.ResponseStreams(w, items)
|
||||
})
|
||||
|
||||
streams.HandleFunc("hass", func(url string) (core.Producer, error) {
|
||||
if hurl := entries[url[5:]]; hurl != "" {
|
||||
return streams.GetProducer(hurl)
|
||||
// check entity by name
|
||||
if url2 := entities[url[5:]]; url2 != "" {
|
||||
return streams.GetProducer(url2)
|
||||
}
|
||||
return nil, fmt.Errorf("can't get url: %s", url)
|
||||
|
||||
// support hass://supervisor?entity_id=camera.driveway_doorbell
|
||||
client, err := hass.NewClient(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client, nil
|
||||
})
|
||||
|
||||
// for Addon listen on hassio interface, so WebUI feature will work
|
||||
@@ -68,12 +92,12 @@ func Init() {
|
||||
}
|
||||
}
|
||||
|
||||
func importEntries(config string) map[string]string {
|
||||
func importConfig(config string) error {
|
||||
// support load cameras from Hass config file
|
||||
filename := path.Join(config, ".storage/core.config_entries")
|
||||
b, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
var storage struct {
|
||||
@@ -88,11 +112,9 @@ func importEntries(config string) map[string]string {
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(b, &storage); err != nil {
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
urls := map[string]string{}
|
||||
|
||||
for _, entrie := range storage.Data.Entries {
|
||||
switch entrie.Domain {
|
||||
case "generic":
|
||||
@@ -102,7 +124,7 @@ func importEntries(config string) map[string]string {
|
||||
if err = json.Unmarshal(entrie.Options, &options); err != nil {
|
||||
continue
|
||||
}
|
||||
urls[entrie.Title] = options.StreamSource
|
||||
entities[entrie.Title] = options.StreamSource
|
||||
|
||||
case "homekit_controller":
|
||||
if !bytes.Contains(entrie.Data, []byte("iOSPairingId")) {
|
||||
@@ -121,7 +143,7 @@ func importEntries(config string) map[string]string {
|
||||
if err = json.Unmarshal(entrie.Data, &data); err != nil {
|
||||
continue
|
||||
}
|
||||
urls[entrie.Title] = fmt.Sprintf(
|
||||
entities[entrie.Title] = fmt.Sprintf(
|
||||
"homekit://%s:%d?client_id=%s&client_private=%s%s&device_id=%s&device_public=%s",
|
||||
data.DeviceHost, data.DevicePort,
|
||||
data.ClientID, data.ClientPrivate, data.ClientPublic,
|
||||
@@ -143,22 +165,48 @@ func importEntries(config string) map[string]string {
|
||||
}
|
||||
|
||||
if data.Username != "" && data.Password != "" {
|
||||
urls[entrie.Title] = fmt.Sprintf(
|
||||
entities[entrie.Title] = fmt.Sprintf(
|
||||
"onvif://%s:%s@%s:%d", data.Username, data.Password, data.Host, data.Port,
|
||||
)
|
||||
} else {
|
||||
urls[entrie.Title] = fmt.Sprintf("onvif://%s:%d", data.Host, data.Port)
|
||||
entities[entrie.Title] = fmt.Sprintf("onvif://%s:%d", data.Host, data.Port)
|
||||
}
|
||||
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
log.Info().Str("url", "hass:"+entrie.Title).Msg("[hass] load stream")
|
||||
log.Debug().Str("url", "hass:"+entrie.Title).Msg("[hass] load config")
|
||||
//streams.Get("hass:" + entrie.Title)
|
||||
}
|
||||
|
||||
return urls
|
||||
return nil
|
||||
}
|
||||
|
||||
func importWebRTC(token string) error {
|
||||
hassAPI, err := hass.NewAPI("ws://supervisor/core/websocket", token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
webrtcEntities, err := hassAPI.GetWebRTCEntities()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(webrtcEntities) == 0 {
|
||||
log.Debug().Msg("[hass] webrtc cameras not found")
|
||||
}
|
||||
|
||||
for name, entityID := range webrtcEntities {
|
||||
entities[name] = "hass://supervisor?entity_id=" + entityID
|
||||
|
||||
log.Debug().Msgf("[hass] load webrtc name=%s entity_id=%s", name, entityID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var entities = map[string]string{}
|
||||
var log zerolog.Logger
|
||||
var once sync.Once
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
## Useful links
|
||||
|
||||
- https://walterebert.com/playground/video/hls/
|
||||
+60
-58
@@ -1,14 +1,15 @@
|
||||
package hls
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mp4"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mpegts"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/rs/zerolog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -16,6 +17,8 @@ import (
|
||||
)
|
||||
|
||||
func Init() {
|
||||
log = app.GetLogger("hls")
|
||||
|
||||
api.HandleFunc("api/stream.m3u8", handlerStream)
|
||||
api.HandleFunc("api/hls/playlist.m3u8", handlerPlaylist)
|
||||
|
||||
@@ -25,6 +28,8 @@ func Init() {
|
||||
// HLS (fMP4)
|
||||
api.HandleFunc("api/hls/init.mp4", handlerInit)
|
||||
api.HandleFunc("api/hls/segment.m4s", handlerSegmentMP4)
|
||||
|
||||
ws.HandleFunc("hls", handlerWSHLS)
|
||||
}
|
||||
|
||||
type Consumer interface {
|
||||
@@ -35,15 +40,7 @@ type Consumer interface {
|
||||
Start()
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
cons Consumer
|
||||
playlist string
|
||||
init []byte
|
||||
segment []byte
|
||||
seq int
|
||||
alive *time.Timer
|
||||
mu sync.Mutex
|
||||
}
|
||||
var log zerolog.Logger
|
||||
|
||||
const keepalive = 5 * time.Second
|
||||
|
||||
@@ -63,7 +60,7 @@ func handlerStream(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
src := r.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
@@ -75,6 +72,7 @@ func handlerStream(w http.ResponseWriter, r *http.Request) {
|
||||
medias := mp4.ParseQuery(r.URL.Query())
|
||||
if medias != nil {
|
||||
cons = &mp4.Consumer{
|
||||
Desc: "HLS/HTTP",
|
||||
RemoteAddr: tcp.RemoteAddr(r),
|
||||
UserAgent: r.UserAgent(),
|
||||
Medias: medias,
|
||||
@@ -86,33 +84,37 @@ func handlerStream(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
session := &Session{cons: cons}
|
||||
|
||||
cons.Listen(func(msg any) {
|
||||
if data, ok := msg.([]byte); ok {
|
||||
session.mu.Lock()
|
||||
session.segment = append(session.segment, data...)
|
||||
session.mu.Unlock()
|
||||
}
|
||||
})
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
session := &Session{cons: cons}
|
||||
|
||||
cons.Listen(func(msg any) {
|
||||
if data, ok := msg.([]byte); ok {
|
||||
session.mu.Lock()
|
||||
session.buffer = append(session.buffer, data...)
|
||||
session.mu.Unlock()
|
||||
}
|
||||
})
|
||||
|
||||
sid := core.RandString(8, 62)
|
||||
|
||||
session.alive = time.AfterFunc(keepalive, func() {
|
||||
sessionsMu.Lock()
|
||||
delete(sessions, sid)
|
||||
sessionsMu.Unlock()
|
||||
|
||||
stream.RemoveConsumer(cons)
|
||||
})
|
||||
session.init, _ = cons.Init()
|
||||
|
||||
cons.Start()
|
||||
|
||||
sid := core.RandString(8, 62)
|
||||
|
||||
// two segments important for Chromecast
|
||||
if medias != nil {
|
||||
session.playlist = `#EXTM3U
|
||||
session.template = `#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:1
|
||||
#EXT-X-MEDIA-SEQUENCE:%d
|
||||
@@ -122,7 +124,7 @@ segment.m4s?id=` + sid + `&n=%d
|
||||
#EXTINF:0.500,
|
||||
segment.m4s?id=` + sid + `&n=%d`
|
||||
} else {
|
||||
session.playlist = `#EXTM3U
|
||||
session.template = `#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:1
|
||||
#EXT-X-MEDIA-SEQUENCE:%d
|
||||
@@ -167,9 +169,7 @@ func handlerPlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
s := fmt.Sprintf(session.playlist, session.seq, session.seq, session.seq+1)
|
||||
|
||||
if _, err := w.Write([]byte(s)); err != nil {
|
||||
if _, err := w.Write([]byte(session.Playlist())); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
@@ -194,22 +194,13 @@ func handlerSegmentTS(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
session.alive.Reset(keepalive)
|
||||
|
||||
var i byte
|
||||
for len(session.segment) == 0 {
|
||||
if i++; i > 10 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
data := session.Segment()
|
||||
if data == nil {
|
||||
log.Warn().Msgf("[hls] can't get segment %s", r.URL.RawQuery)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
session.mu.Lock()
|
||||
data := session.segment
|
||||
// important to start new segment with init
|
||||
session.segment = session.init
|
||||
session.seq++
|
||||
session.mu.Unlock()
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
@@ -233,7 +224,17 @@ func handlerInit(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := w.Write(session.init); err != nil {
|
||||
data := session.init
|
||||
session.init = nil
|
||||
|
||||
session.segment0 = session.Segment()
|
||||
if session.segment0 == nil {
|
||||
log.Warn().Msgf("[hls] can't get init %s", r.URL.RawQuery)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
@@ -243,11 +244,13 @@ func handlerSegmentMP4(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Content-Type", "video/iso.segment")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET")
|
||||
return
|
||||
}
|
||||
|
||||
sid := r.URL.Query().Get("id")
|
||||
query := r.URL.Query()
|
||||
|
||||
sid := query.Get("id")
|
||||
sessionsMu.RLock()
|
||||
session := sessions[sid]
|
||||
sessionsMu.RUnlock()
|
||||
@@ -258,20 +261,19 @@ func handlerSegmentMP4(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
session.alive.Reset(keepalive)
|
||||
|
||||
var i byte
|
||||
for len(session.segment) == 0 {
|
||||
if i++; i > 10 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
var data []byte
|
||||
|
||||
if query.Get("n") != "0" {
|
||||
data = session.Segment()
|
||||
} else {
|
||||
data = session.segment0
|
||||
}
|
||||
|
||||
session.mu.Lock()
|
||||
data := session.segment
|
||||
session.segment = nil
|
||||
session.seq++
|
||||
session.mu.Unlock()
|
||||
if data == nil {
|
||||
log.Warn().Msgf("[hls] can't get segment %s", r.URL.RawQuery)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package hls
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
cons Consumer
|
||||
template string
|
||||
init []byte
|
||||
segment0 []byte
|
||||
buffer []byte
|
||||
seq int
|
||||
alive *time.Timer
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (s *Session) Playlist() string {
|
||||
return fmt.Sprintf(s.template, s.seq, s.seq, s.seq+1)
|
||||
}
|
||||
|
||||
func (s *Session) Segment() (segment []byte) {
|
||||
for i := 0; i < 60 && segment == nil; i++ {
|
||||
if i > 0 {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if len(s.buffer) > 0 {
|
||||
segment = s.buffer
|
||||
// for TS important to start new segment with init
|
||||
s.buffer = s.init
|
||||
s.seq++
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package hls
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mp4"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func handlerWSHLS(tr *ws.Transport, msg *ws.Message) error {
|
||||
src := tr.Request.URL.Query().Get("src")
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
return errors.New(api.StreamNotFound)
|
||||
}
|
||||
|
||||
codecs := msg.String()
|
||||
|
||||
log.Trace().Msgf("[hls] new ws consumer codecs=%s", codecs)
|
||||
|
||||
cons := &mp4.Consumer{
|
||||
Desc: "HLS/WebSocket",
|
||||
RemoteAddr: tcp.RemoteAddr(tr.Request),
|
||||
UserAgent: tr.Request.UserAgent(),
|
||||
Medias: mp4.ParseCodecs(codecs, true),
|
||||
}
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return err
|
||||
}
|
||||
|
||||
session := &Session{cons: cons}
|
||||
|
||||
cons.Listen(func(msg any) {
|
||||
if data, ok := msg.([]byte); ok {
|
||||
session.mu.Lock()
|
||||
session.buffer = append(session.buffer, data...)
|
||||
session.mu.Unlock()
|
||||
}
|
||||
})
|
||||
|
||||
session.alive = time.AfterFunc(keepalive, func() {
|
||||
stream.RemoveConsumer(cons)
|
||||
})
|
||||
session.init, _ = cons.Init()
|
||||
|
||||
cons.Start()
|
||||
|
||||
sid := core.RandString(8, 62)
|
||||
|
||||
// two segments important for Chromecast
|
||||
session.template = `#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:1
|
||||
#EXT-X-MEDIA-SEQUENCE:%d
|
||||
#EXT-X-MAP:URI="init.mp4?id=` + sid + `"
|
||||
#EXTINF:0.500,
|
||||
segment.m4s?id=` + sid + `&n=%d
|
||||
#EXTINF:0.500,
|
||||
segment.m4s?id=` + sid + `&n=%d`
|
||||
|
||||
sessionsMu.Lock()
|
||||
sessions[sid] = session
|
||||
sessionsMu.Unlock()
|
||||
|
||||
// Apple Safari can play FLAC codec, but fail it it in m3u8 playlist
|
||||
codecs = strings.Replace(cons.MimeCodecs(), mp4.MimeFlac, mp4.MimeAAC, 1)
|
||||
|
||||
// bandwidth important for Safari, codecs useful for smooth playback
|
||||
data := `#EXTM3U
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS="` + codecs + `"
|
||||
hls/playlist.m3u8?id=` + sid
|
||||
|
||||
tr.Write(&ws.Message{Type: "hls", Value: data})
|
||||
|
||||
return nil
|
||||
}
|
||||
+20
-24
@@ -1,12 +1,11 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app/store"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/mdns"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -32,29 +31,26 @@ func apiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
for info := range mdns.GetAll() {
|
||||
if !strings.HasSuffix(info.Name, mdns.Suffix) {
|
||||
continue
|
||||
}
|
||||
name := info.Name[:len(info.Name)-len(mdns.Suffix)]
|
||||
device := Device{
|
||||
Name: strings.ReplaceAll(name, "\\", ""),
|
||||
Addr: fmt.Sprintf("%s:%d", info.AddrV4, info.Port),
|
||||
}
|
||||
for _, field := range info.InfoFields {
|
||||
switch field[:2] {
|
||||
case "id":
|
||||
device.ID = field[3:]
|
||||
case "md":
|
||||
device.Model = field[3:]
|
||||
case "sf":
|
||||
device.Paired = field[3] == '0'
|
||||
err := mdns.Discovery(mdns.ServiceHAP, func(entry *mdns.ServiceEntry) bool {
|
||||
if entry.Complete() {
|
||||
device := Device{
|
||||
Name: entry.Name,
|
||||
Addr: entry.Addr(),
|
||||
ID: entry.Info["id"],
|
||||
Model: entry.Info["md"],
|
||||
Paired: entry.Info["sf"] == "0",
|
||||
}
|
||||
items = append(items, device)
|
||||
}
|
||||
items = append(items, device)
|
||||
return false
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(items)
|
||||
api.ResponseJSON(w, items)
|
||||
|
||||
case "POST":
|
||||
// TODO: post params...
|
||||
@@ -64,14 +60,14 @@ func apiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.URL.Query().Get("name")
|
||||
if err := hkPair(id, pin, name); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
_, err = w.Write([]byte(err.Error()))
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
case "DELETE":
|
||||
src := r.URL.Query().Get("src")
|
||||
if err := hkDelete(src); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
_, err = w.Write([]byte(err.Error()))
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package mjpeg
|
||||
import (
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
||||
"github.com/AlexxIT/go2rtc/internal/ffmpeg"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
@@ -20,12 +21,12 @@ func Init() {
|
||||
api.HandleFunc("api/frame.jpeg", handlerKeyframe)
|
||||
api.HandleFunc("api/stream.mjpeg", handlerStream)
|
||||
|
||||
api.HandleWS("mjpeg", handlerWS)
|
||||
ws.HandleFunc("mjpeg", handlerWS)
|
||||
}
|
||||
|
||||
func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
|
||||
src := r.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
@@ -90,7 +91,7 @@ func handlerStream(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func outputMjpeg(w http.ResponseWriter, r *http.Request) {
|
||||
src := r.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
@@ -156,9 +157,9 @@ func inputMjpeg(w http.ResponseWriter, r *http.Request) {
|
||||
stream.RemoveProducer(client)
|
||||
}
|
||||
|
||||
func handlerWS(tr *api.Transport, _ *api.Message) error {
|
||||
func handlerWS(tr *ws.Transport, _ *ws.Message) error {
|
||||
src := tr.Request.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
return errors.New(api.StreamNotFound)
|
||||
}
|
||||
@@ -178,7 +179,7 @@ func handlerWS(tr *api.Transport, _ *api.Message) error {
|
||||
return err
|
||||
}
|
||||
|
||||
tr.Write(&api.Message{Type: "mjpeg"})
|
||||
tr.Write(&ws.Message{Type: "mjpeg"})
|
||||
|
||||
tr.OnClose(func() {
|
||||
stream.RemoveConsumer(cons)
|
||||
|
||||
+22
-9
@@ -1,6 +1,7 @@
|
||||
package mp4
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -17,8 +18,8 @@ import (
|
||||
func Init() {
|
||||
log = app.GetLogger("mp4")
|
||||
|
||||
api.HandleWS("mse", handlerWSMSE)
|
||||
api.HandleWS("mp4", handlerWSMP4)
|
||||
ws.HandleFunc("mse", handlerWSMSE)
|
||||
ws.HandleFunc("mp4", handlerWSMP4)
|
||||
|
||||
api.HandleFunc("api/frame.mp4", handlerKeyframe)
|
||||
api.HandleFunc("api/stream.mp4", handlerMP4)
|
||||
@@ -37,8 +38,9 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
src := r.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
query := r.URL.Query()
|
||||
src := query.Get("src")
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
@@ -68,8 +70,13 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
|
||||
stream.RemoveConsumer(cons)
|
||||
|
||||
// Apple Safari won't show frame without length
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
w.Header().Set("Content-Type", cons.MimeType)
|
||||
header := w.Header()
|
||||
header.Set("Content-Length", strconv.Itoa(len(data)))
|
||||
header.Set("Content-Type", cons.MimeType)
|
||||
|
||||
if filename := query.Get("filename"); filename != "" {
|
||||
header.Set("Content-Disposition", `attachment; filename="`+filename+`"`)
|
||||
}
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
@@ -94,7 +101,7 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
src := query.Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
@@ -103,6 +110,7 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) {
|
||||
exit := make(chan error, 1) // Add buffer to prevent blocking
|
||||
|
||||
cons := &mp4.Consumer{
|
||||
Desc: "MP4/HTTP",
|
||||
RemoteAddr: tcp.RemoteAddr(r),
|
||||
UserAgent: r.UserAgent(),
|
||||
Medias: mp4.ParseQuery(r.URL.Query()),
|
||||
@@ -131,8 +139,6 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
defer stream.RemoveConsumer(cons)
|
||||
|
||||
w.Header().Set("Content-Type", cons.MimeType())
|
||||
|
||||
data, err := cons.Init()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
@@ -140,6 +146,13 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
header := w.Header()
|
||||
header.Set("Content-Type", cons.MimeType())
|
||||
|
||||
if filename := query.Get("filename"); filename != "" {
|
||||
header.Set("Content-Disposition", `attachment; filename="`+filename+`"`)
|
||||
}
|
||||
|
||||
if _, err = w.Write(data); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
||||
+10
-58
@@ -3,28 +3,28 @@ package mp4
|
||||
import (
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mp4"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func handlerWSMSE(tr *api.Transport, msg *api.Message) error {
|
||||
func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error {
|
||||
src := tr.Request.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
return errors.New(api.StreamNotFound)
|
||||
}
|
||||
|
||||
cons := &mp4.Consumer{
|
||||
Desc: "MSE/WebSocket",
|
||||
RemoteAddr: tcp.RemoteAddr(tr.Request),
|
||||
UserAgent: tr.Request.UserAgent(),
|
||||
}
|
||||
|
||||
if codecs := msg.String(); codecs != "" {
|
||||
log.Trace().Str("codecs", codecs).Msgf("[mp4] new WS/MSE consumer")
|
||||
cons.Medias = parseMedias(codecs, true)
|
||||
cons.Medias = mp4.ParseCodecs(codecs, true)
|
||||
}
|
||||
|
||||
cons.Listen(func(msg any) {
|
||||
@@ -42,7 +42,7 @@ func handlerWSMSE(tr *api.Transport, msg *api.Message) error {
|
||||
stream.RemoveConsumer(cons)
|
||||
})
|
||||
|
||||
tr.Write(&api.Message{Type: "mse", Value: cons.MimeType()})
|
||||
tr.Write(&ws.Message{Type: "mse", Value: cons.MimeType()})
|
||||
|
||||
data, err := cons.Init()
|
||||
if err != nil {
|
||||
@@ -57,9 +57,9 @@ func handlerWSMSE(tr *api.Transport, msg *api.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func handlerWSMP4(tr *api.Transport, msg *api.Message) error {
|
||||
func handlerWSMP4(tr *ws.Transport, msg *ws.Message) error {
|
||||
src := tr.Request.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
return errors.New(api.StreamNotFound)
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func handlerWSMP4(tr *api.Transport, msg *api.Message) error {
|
||||
|
||||
if codecs := msg.String(); codecs != "" {
|
||||
log.Trace().Str("codecs", codecs).Msgf("[mp4] new WS/MP4 consumer")
|
||||
cons.Medias = parseMedias(codecs, false)
|
||||
cons.Medias = mp4.ParseCodecs(codecs, false)
|
||||
}
|
||||
|
||||
cons.Listen(func(msg any) {
|
||||
@@ -86,7 +86,7 @@ func handlerWSMP4(tr *api.Transport, msg *api.Message) error {
|
||||
return err
|
||||
}
|
||||
|
||||
tr.Write(&api.Message{Type: "mp4", Value: cons.MimeType})
|
||||
tr.Write(&ws.Message{Type: "mp4", Value: cons.MimeType})
|
||||
|
||||
tr.OnClose(func() {
|
||||
stream.RemoveConsumer(cons)
|
||||
@@ -94,51 +94,3 @@ func handlerWSMP4(tr *api.Transport, msg *api.Message) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseMedias(codecs string, parseAudio bool) (medias []*core.Media) {
|
||||
var videos []*core.Codec
|
||||
var audios []*core.Codec
|
||||
|
||||
for _, name := range strings.Split(codecs, ",") {
|
||||
switch name {
|
||||
case mp4.MimeH264:
|
||||
codec := &core.Codec{Name: core.CodecH264}
|
||||
videos = append(videos, codec)
|
||||
case mp4.MimeH265:
|
||||
codec := &core.Codec{Name: core.CodecH265}
|
||||
videos = append(videos, codec)
|
||||
case mp4.MimeAAC:
|
||||
codec := &core.Codec{Name: core.CodecAAC}
|
||||
audios = append(audios, codec)
|
||||
case mp4.MimeFlac:
|
||||
audios = append(audios,
|
||||
&core.Codec{Name: core.CodecPCMA},
|
||||
&core.Codec{Name: core.CodecPCMU},
|
||||
&core.Codec{Name: core.CodecPCM},
|
||||
)
|
||||
case mp4.MimeOpus:
|
||||
codec := &core.Codec{Name: core.CodecOpus}
|
||||
audios = append(audios, codec)
|
||||
}
|
||||
}
|
||||
|
||||
if videos != nil {
|
||||
media := &core.Media{
|
||||
Kind: core.KindVideo,
|
||||
Direction: core.DirectionSendonly,
|
||||
Codecs: videos,
|
||||
}
|
||||
medias = append(medias, media)
|
||||
}
|
||||
|
||||
if audios != nil && parseAudio {
|
||||
media := &core.Media{
|
||||
Kind: core.KindAudio,
|
||||
Direction: core.DirectionSendonly,
|
||||
Codecs: audios,
|
||||
}
|
||||
medias = append(medias, media)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package nest
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/nest"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("nest", streamNest)
|
||||
|
||||
api.HandleFunc("api/nest", apiNest)
|
||||
}
|
||||
|
||||
func streamNest(url string) (core.Producer, error) {
|
||||
client, err := nest.NewClient(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func apiNest(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
cliendID := query.Get("client_id")
|
||||
cliendSecret := query.Get("client_secret")
|
||||
refreshToken := query.Get("refresh_token")
|
||||
projectID := query.Get("project_id")
|
||||
|
||||
nestAPI, err := nest.NewAPI(cliendID, cliendSecret, refreshToken)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
devices, err := nestAPI.GetDevices(projectID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var items []api.Stream
|
||||
|
||||
for name, deviceID := range devices {
|
||||
query.Set("device_id", deviceID)
|
||||
|
||||
items = append(items, api.Stream{
|
||||
Name: name, URL: "nest:?" + query.Encode(),
|
||||
})
|
||||
}
|
||||
|
||||
api.ResponseStreams(w, items)
|
||||
}
|
||||
+12
-13
@@ -91,19 +91,19 @@ var log zerolog.Logger
|
||||
var handlers []Handler
|
||||
var defaultMedias []*core.Media
|
||||
|
||||
func rtspHandler(url string) (core.Producer, error) {
|
||||
backchannel := true
|
||||
func rtspHandler(rawURL string) (core.Producer, error) {
|
||||
rawURL, rawQuery, _ := strings.Cut(rawURL, "#")
|
||||
|
||||
if i := strings.IndexByte(url, '#'); i > 0 {
|
||||
if url[i+1:] == "backchannel=0" {
|
||||
backchannel = false
|
||||
}
|
||||
url = url[:i]
|
||||
}
|
||||
|
||||
conn := rtsp.NewClient(url)
|
||||
conn := rtsp.NewClient(rawURL)
|
||||
conn.Backchannel = true
|
||||
conn.UserAgent = app.UserAgent
|
||||
|
||||
if rawQuery != "" {
|
||||
query := streams.ParseQuery(rawQuery)
|
||||
conn.Backchannel = query.Get("backchannel") == "1"
|
||||
conn.Transport = query.Get("transport")
|
||||
}
|
||||
|
||||
if log.Trace().Enabled() {
|
||||
conn.Listen(func(msg any) {
|
||||
switch msg := msg.(type) {
|
||||
@@ -121,12 +121,11 @@ func rtspHandler(url string) (core.Producer, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn.Backchannel = backchannel
|
||||
if err := conn.Describe(); err != nil {
|
||||
if !backchannel {
|
||||
if !conn.Backchannel {
|
||||
return nil, err
|
||||
}
|
||||
log.Trace().Msgf("[rtsp] describe (backchannel=%t) err: %v", backchannel, err)
|
||||
log.Trace().Msgf("[rtsp] describe (backchannel=%t) err: %v", conn.Backchannel, err)
|
||||
|
||||
// second try without backchannel, we need to reconnect
|
||||
conn.Backchannel = false
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ParseQuery(s string) url.Values {
|
||||
params := url.Values{}
|
||||
for _, key := range strings.Split(s, "#") {
|
||||
var value string
|
||||
i := strings.IndexByte(key, '=')
|
||||
if i > 0 {
|
||||
key, value = key[:i], key[i+1:]
|
||||
}
|
||||
params[key] = append(params[key], value)
|
||||
}
|
||||
return params
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/app/store"
|
||||
@@ -43,7 +42,7 @@ func New(name string, source any) *Stream {
|
||||
func NewTemplate(name string, source any) *Stream {
|
||||
// check if source links to some stream name from go2rtc
|
||||
if rawURL, ok := source.(string); ok {
|
||||
if u, err := url.Parse(rawURL); err == nil && u.Scheme == "rtsp" {
|
||||
if u, err := url.Parse(rawURL); err == nil && u.Scheme == "rtsp" && len(u.Path) > 1 {
|
||||
if stream, ok := streams[u.Path[1:]]; ok {
|
||||
streams[name] = stream
|
||||
return stream
|
||||
@@ -54,20 +53,6 @@ func NewTemplate(name string, source any) *Stream {
|
||||
return New(name, "{input}")
|
||||
}
|
||||
|
||||
func GetOrNew(src string) *Stream {
|
||||
if stream, ok := streams[src]; ok {
|
||||
return stream
|
||||
}
|
||||
|
||||
if !HasProducer(src) {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Info().Str("url", src).Msg("[streams] create new stream")
|
||||
|
||||
return New(src, src)
|
||||
}
|
||||
|
||||
func GetAll() (names []string) {
|
||||
for name := range streams {
|
||||
names = append(names, name)
|
||||
@@ -81,16 +66,14 @@ func streamsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// without source - return all streams list
|
||||
if src == "" && r.Method != "POST" {
|
||||
_ = json.NewEncoder(w).Encode(streams)
|
||||
api.ResponseJSON(w, streams)
|
||||
return
|
||||
}
|
||||
|
||||
// Not sure about all this API. Should be rewrited...
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
e := json.NewEncoder(w)
|
||||
e.SetIndent("", " ")
|
||||
_ = e.Encode(streams[src])
|
||||
api.ResponsePrettyJSON(w, streams[src])
|
||||
|
||||
case "PUT":
|
||||
name := query.Get("name")
|
||||
@@ -121,7 +104,7 @@ func streamsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if err := stream.Play(src); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
} else {
|
||||
_ = json.NewEncoder(w).Encode(stream)
|
||||
api.ResponseJSON(w, stream)
|
||||
}
|
||||
} else {
|
||||
http.Error(w, "", http.StatusNotFound)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
"github.com/pion/sdp/v3"
|
||||
"strconv"
|
||||
@@ -56,7 +56,7 @@ func GetCandidates() (candidates []string) {
|
||||
return
|
||||
}
|
||||
|
||||
func asyncCandidates(tr *api.Transport, cons *webrtc.Conn) {
|
||||
func asyncCandidates(tr *ws.Transport, cons *webrtc.Conn) {
|
||||
tr.WithContext(func(ctx map[any]any) {
|
||||
if candidates, ok := ctx["candidate"].([]string); ok {
|
||||
// process candidates that receive before this moment
|
||||
@@ -74,7 +74,7 @@ func asyncCandidates(tr *api.Transport, cons *webrtc.Conn) {
|
||||
|
||||
for _, candidate := range GetCandidates() {
|
||||
log.Trace().Str("candidate", candidate).Msg("[webrtc] config")
|
||||
tr.Write(&api.Message{Type: "webrtc/candidate", Value: candidate})
|
||||
tr.Write(&ws.Message{Type: "webrtc/candidate", Value: candidate})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ func syncCanditates(answer string) (string, error) {
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func candidateHandler(tr *api.Transport, msg *api.Message) error {
|
||||
func candidateHandler(tr *ws.Transport, msg *ws.Message) error {
|
||||
// process incoming candidate in sync function
|
||||
tr.WithContext(func(ctx map[any]any) {
|
||||
candidate := msg.String()
|
||||
|
||||
+12
-12
@@ -2,7 +2,7 @@ package webrtc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
"github.com/gorilla/websocket"
|
||||
@@ -30,13 +30,13 @@ func streamsHandler(url string) (core.Producer, error) {
|
||||
// ex: ws://localhost:1984/api/ws?src=camera1
|
||||
func asyncClient(url string) (core.Producer, error) {
|
||||
// 1. Connect to signalign server
|
||||
ws, _, err := websocket.DefaultDialer.Dial(url, nil)
|
||||
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = ws.Close()
|
||||
_ = conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -55,14 +55,14 @@ func asyncClient(url string) (core.Producer, error) {
|
||||
prod.Listen(func(msg any) {
|
||||
switch msg := msg.(type) {
|
||||
case pion.PeerConnectionState:
|
||||
_ = ws.Close()
|
||||
_ = conn.Close()
|
||||
|
||||
case *pion.ICECandidate:
|
||||
sendOffer.Wait()
|
||||
|
||||
s := msg.ToJSON().Candidate
|
||||
log.Trace().Str("candidate", s).Msg("[webrtc] local")
|
||||
_ = ws.WriteJSON(&api.Message{Type: "webrtc/candidate", Value: s})
|
||||
_ = conn.WriteJSON(&ws.Message{Type: "webrtc/candidate", Value: s})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -79,15 +79,15 @@ func asyncClient(url string) (core.Producer, error) {
|
||||
}
|
||||
|
||||
// 4. Send offer
|
||||
msg := &api.Message{Type: "webrtc/offer", Value: offer}
|
||||
if err = ws.WriteJSON(msg); err != nil {
|
||||
msg := &ws.Message{Type: "webrtc/offer", Value: offer}
|
||||
if err = conn.WriteJSON(msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sendOffer.Done()
|
||||
|
||||
// 5. Get answer
|
||||
if err = ws.ReadJSON(msg); err != nil {
|
||||
if err = conn.ReadJSON(msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -104,10 +104,10 @@ func asyncClient(url string) (core.Producer, error) {
|
||||
go func() {
|
||||
for {
|
||||
// receive data from remote
|
||||
msg := new(api.Message)
|
||||
if err = ws.ReadJSON(msg); err != nil {
|
||||
msg := new(ws.Message)
|
||||
if err = conn.ReadJSON(msg); err != nil {
|
||||
if cerr, ok := err.(*websocket.CloseError); ok {
|
||||
log.Trace().Err(err).Caller().Msgf("[webrtc] ws code=%d", cerr)
|
||||
log.Trace().Err(err).Caller().Msgf("[webrtc] ws code=%d", cerr.Code)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -120,7 +120,7 @@ func asyncClient(url string) (core.Producer, error) {
|
||||
}
|
||||
}
|
||||
|
||||
_ = ws.Close()
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
return prod, nil
|
||||
|
||||
@@ -3,6 +3,7 @@ package webrtc
|
||||
import (
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
@@ -68,9 +69,9 @@ func Init() {
|
||||
}
|
||||
|
||||
// async WebRTC server (two API versions)
|
||||
api.HandleWS("webrtc", asyncHandler)
|
||||
api.HandleWS("webrtc/offer", asyncHandler)
|
||||
api.HandleWS("webrtc/candidate", candidateHandler)
|
||||
ws.HandleFunc("webrtc", asyncHandler)
|
||||
ws.HandleFunc("webrtc/offer", asyncHandler)
|
||||
ws.HandleFunc("webrtc/candidate", candidateHandler)
|
||||
|
||||
// sync WebRTC server (two API versions)
|
||||
api.HandleFunc("api/webrtc", syncHandler)
|
||||
@@ -84,13 +85,13 @@ var log zerolog.Logger
|
||||
|
||||
var PeerConnection func(active bool) (*pion.PeerConnection, error)
|
||||
|
||||
func asyncHandler(tr *api.Transport, msg *api.Message) error {
|
||||
func asyncHandler(tr *ws.Transport, msg *ws.Message) error {
|
||||
var stream *streams.Stream
|
||||
var mode core.Mode
|
||||
|
||||
query := tr.Request.URL.Query()
|
||||
if name := query.Get("src"); name != "" {
|
||||
stream = streams.GetOrNew(name)
|
||||
stream = streams.Get(name)
|
||||
mode = core.ModePassiveConsumer
|
||||
log.Debug().Str("src", name).Msg("[webrtc] new consumer")
|
||||
} else if name = query.Get("dst"); name != "" {
|
||||
@@ -134,7 +135,7 @@ func asyncHandler(tr *api.Transport, msg *api.Message) error {
|
||||
|
||||
s := msg.ToJSON().Candidate
|
||||
log.Trace().Str("candidate", s).Msg("[webrtc] local")
|
||||
tr.Write(&api.Message{Type: "webrtc/candidate", Value: s})
|
||||
tr.Write(&ws.Message{Type: "webrtc/candidate", Value: s})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -179,9 +180,9 @@ func asyncHandler(tr *api.Transport, msg *api.Message) error {
|
||||
|
||||
if apiV2 {
|
||||
desc := pion.SessionDescription{Type: pion.SDPTypeAnswer, SDP: answer}
|
||||
tr.Write(&api.Message{Type: "webrtc", Value: desc})
|
||||
tr.Write(&ws.Message{Type: "webrtc", Value: desc})
|
||||
} else {
|
||||
tr.Write(&api.Message{Type: "webrtc/answer", Value: answer})
|
||||
tr.Write(&ws.Message{Type: "webrtc/answer", Value: answer})
|
||||
}
|
||||
|
||||
sendAnswer.Done()
|
||||
|
||||
@@ -125,6 +125,8 @@ func outputWebRTC(w http.ResponseWriter, r *http.Request) {
|
||||
_, err = w.Write([]byte(answer))
|
||||
|
||||
default:
|
||||
w.Header().Set("Content-Type", mediaType)
|
||||
|
||||
_, err = w.Write([]byte(answer))
|
||||
}
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ func apiHandle(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
data := fmt.Sprintf(`{"share":%q,"pwd":%q}`, share, pwd)
|
||||
_, _ = w.Write([]byte(data))
|
||||
api.Response(w, data, api.MimeJSON)
|
||||
|
||||
case "DELETE":
|
||||
if ok {
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/debug"
|
||||
"github.com/AlexxIT/go2rtc/internal/dvrip"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/internal/mjpeg"
|
||||
"github.com/AlexxIT/go2rtc/internal/mp4"
|
||||
"github.com/AlexxIT/go2rtc/internal/mpegts"
|
||||
"github.com/AlexxIT/go2rtc/internal/nest"
|
||||
"github.com/AlexxIT/go2rtc/internal/ngrok"
|
||||
"github.com/AlexxIT/go2rtc/internal/onvif"
|
||||
"github.com/AlexxIT/go2rtc/internal/roborock"
|
||||
@@ -27,46 +29,59 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/internal/tapo"
|
||||
"github.com/AlexxIT/go2rtc/internal/webrtc"
|
||||
"github.com/AlexxIT/go2rtc/internal/webtorrent"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app.Init() // init config and logs
|
||||
api.Init() // init HTTP API server
|
||||
streams.Init() // load streams list
|
||||
onvif.Init()
|
||||
// 1. Core modules: app, api/ws, streams
|
||||
|
||||
rtsp.Init() // add support RTSP client and RTSP server
|
||||
rtmp.Init() // add support RTMP client
|
||||
exec.Init() // add support exec scheme (depends on RTSP server)
|
||||
ffmpeg.Init() // add support ffmpeg scheme (depends on exec scheme)
|
||||
hass.Init() // add support hass scheme
|
||||
echo.Init()
|
||||
ivideon.Init()
|
||||
http.Init()
|
||||
dvrip.Init()
|
||||
tapo.Init()
|
||||
isapi.Init()
|
||||
mpegts.Init()
|
||||
roborock.Init()
|
||||
app.Init() // init config and logs
|
||||
|
||||
srtp.Init()
|
||||
homekit.Init()
|
||||
api.Init() // init API before all others
|
||||
ws.Init() // init WS API endpoint
|
||||
|
||||
webrtc.Init()
|
||||
mp4.Init()
|
||||
hls.Init()
|
||||
mjpeg.Init()
|
||||
streams.Init() // streams module
|
||||
|
||||
webtorrent.Init()
|
||||
ngrok.Init()
|
||||
debug.Init()
|
||||
// 2. Main sources and servers
|
||||
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigs
|
||||
rtsp.Init() // rtsp source, RTSP server
|
||||
webrtc.Init() // webrtc source, WebRTC server
|
||||
|
||||
println("exit OK")
|
||||
// 3. Main API
|
||||
|
||||
mp4.Init() // MP4 API
|
||||
hls.Init() // HLS API
|
||||
mjpeg.Init() // MJPEG API
|
||||
|
||||
// 4. Other sources and servers
|
||||
|
||||
hass.Init() // hass source, Hass API server
|
||||
onvif.Init() // onvif source, ONVIF API server
|
||||
webtorrent.Init() // webtorrent source, WebTorrent module
|
||||
|
||||
// 5. Other sources
|
||||
|
||||
rtmp.Init() // rtmp source
|
||||
exec.Init() // exec source
|
||||
ffmpeg.Init() // ffmpeg source
|
||||
echo.Init() // echo source
|
||||
ivideon.Init() // ivideon source
|
||||
http.Init() // http/tcp source
|
||||
dvrip.Init() // dvrip source
|
||||
tapo.Init() // tapo source
|
||||
isapi.Init() // isapi source
|
||||
mpegts.Init() // mpegts passive source
|
||||
roborock.Init() // roborock source
|
||||
homekit.Init() // homekit source
|
||||
nest.Init() // nest source
|
||||
|
||||
// 6. Helper modules
|
||||
|
||||
ngrok.Init() // Ngrok module
|
||||
srtp.Init() // SRTP server
|
||||
debug.Init() // debug API
|
||||
|
||||
// 7. Go
|
||||
|
||||
shell.RunUntilSignal()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"eslint": "^8.44.0",
|
||||
"eslint-plugin-html": "^7.1.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2017,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"no-var": "error",
|
||||
"no-undef": "error",
|
||||
"no-unused-vars": "warn",
|
||||
"prefer-const": "error",
|
||||
"quotes": [
|
||||
"error",
|
||||
"single"
|
||||
],
|
||||
"semi": "error"
|
||||
},
|
||||
"plugins": [
|
||||
"html"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"*.html"
|
||||
],
|
||||
"parserOptions": {
|
||||
"sourceType": "script"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+24
-8
@@ -9,6 +9,8 @@ import (
|
||||
const RTPPacketVersionAAC = 0
|
||||
|
||||
func RTPDepay(handler core.HandlerFunc) core.HandlerFunc {
|
||||
var timestamp uint32
|
||||
|
||||
return func(packet *rtp.Packet) {
|
||||
// support ONLY 2 bytes header size!
|
||||
// streamtype=5;profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=1408
|
||||
@@ -16,15 +18,29 @@ func RTPDepay(handler core.HandlerFunc) core.HandlerFunc {
|
||||
|
||||
//log.Printf("[RTP/AAC] units: %d, size: %4d, ts: %10d, %t", headersSize/2, len(packet.Payload), packet.Timestamp, packet.Marker)
|
||||
|
||||
data := packet.Payload[2+headersSize:]
|
||||
if IsADTS(data) {
|
||||
data = data[7:]
|
||||
}
|
||||
headers := packet.Payload[2 : 2+headersSize]
|
||||
units := packet.Payload[2+headersSize:]
|
||||
|
||||
clone := *packet
|
||||
clone.Version = RTPPacketVersionAAC
|
||||
clone.Payload = data
|
||||
handler(&clone)
|
||||
for len(headers) > 0 {
|
||||
unitSize := binary.BigEndian.Uint16(headers) >> 3
|
||||
|
||||
unit := units[:unitSize]
|
||||
|
||||
headers = headers[2:]
|
||||
units = units[unitSize:]
|
||||
|
||||
timestamp += 1024
|
||||
|
||||
clone := *packet
|
||||
clone.Version = RTPPacketVersionAAC
|
||||
clone.Timestamp = timestamp
|
||||
if IsADTS(unit) {
|
||||
clone.Payload = unit[7:]
|
||||
} else {
|
||||
clone.Payload = unit
|
||||
}
|
||||
handler(&clone)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-2
@@ -10,9 +10,8 @@ import (
|
||||
)
|
||||
|
||||
// Now90000 - timestamp for Video (clock rate = 90000 samples per second)
|
||||
// same as: uint32(time.Duration(time.Now().UnixNano()) * 90000 / time.Second)
|
||||
func Now90000() uint32 {
|
||||
return uint32(time.Duration(time.Now().UnixMilli()) * 90)
|
||||
return uint32(time.Duration(time.Now().UnixNano()) * 90000 / time.Second)
|
||||
}
|
||||
|
||||
const symbols = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_"
|
||||
|
||||
+2
-2
@@ -337,7 +337,7 @@ func (c *Client) ResponseJSON() (res Response, err error) {
|
||||
func (c *Client) AddVideoTrack(mediaCode byte, payload []byte) {
|
||||
var codec *core.Codec
|
||||
switch mediaCode {
|
||||
case 2:
|
||||
case 0x02, 0x12:
|
||||
codec = &core.Codec{
|
||||
Name: core.CodecH264,
|
||||
ClockRate: 90000,
|
||||
@@ -345,7 +345,7 @@ func (c *Client) AddVideoTrack(mediaCode byte, payload []byte) {
|
||||
FmtpLine: h264.GetFmtpLine(payload),
|
||||
}
|
||||
|
||||
case 0x03, 0x13:
|
||||
case 0x03, 0x13, 0x43:
|
||||
codec = &core.Codec{
|
||||
Name: core.CodecH265,
|
||||
ClockRate: 90000,
|
||||
|
||||
+14
-1
@@ -29,6 +29,18 @@ func (a *Args) InsertFilter(filter string) {
|
||||
a.Filters = append([]string{filter}, a.Filters...)
|
||||
}
|
||||
|
||||
func (a *Args) HasFilters(filters ...string) bool {
|
||||
for _, f1 := range a.Filters {
|
||||
for _, f2 := range filters {
|
||||
if strings.HasPrefix(f1, f2) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *Args) String() string {
|
||||
b := bytes.NewBuffer(make([]byte, 0, 512))
|
||||
|
||||
@@ -65,12 +77,13 @@ func (a *Args) String() string {
|
||||
if a.Filters != nil {
|
||||
for i, filter := range a.Filters {
|
||||
if i == 0 {
|
||||
b.WriteString(" -vf ")
|
||||
b.WriteString(` -vf "`)
|
||||
} else {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
b.WriteString(filter)
|
||||
}
|
||||
b.WriteByte('"')
|
||||
}
|
||||
|
||||
b.WriteByte(' ')
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
package ps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUnmarshalSPS(t *testing.T) {
|
||||
raw := []byte{0x67, 0x42, 0x00, 0x0a, 0xf8, 0x41, 0xa2}
|
||||
s := SPS{}
|
||||
if err := s.Unmarshal(raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw2 := s.Marshal()
|
||||
if bytes.Compare(raw, raw2) != 0 {
|
||||
t.Fatal()
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalPPS(t *testing.T) {
|
||||
raw := []byte{0x68, 0xce, 0x38, 0x80}
|
||||
p := PPS{}
|
||||
if err := p.Unmarshal(raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw2 := p.Marshal()
|
||||
if bytes.Compare(raw, raw2) != 0 {
|
||||
t.Fatal()
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalPPS2(t *testing.T) {
|
||||
raw := []byte{72, 238, 60, 128}
|
||||
p := PPS{}
|
||||
if err := p.Unmarshal(raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw2 := p.Marshal()
|
||||
if bytes.Compare(raw, raw2) != 0 {
|
||||
t.Fatal()
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafari(t *testing.T) {
|
||||
// CB66, L3.1: chrome, edge, safari, android chrome
|
||||
s := EncodeProfile(0x42, 0xE0)
|
||||
t.Logf("Profile: %s, Level: %d", s, 0x1F)
|
||||
|
||||
// B66, L3.1: chrome, edge
|
||||
s = EncodeProfile(0x42, 0x00)
|
||||
t.Logf("Profile: %s, Level: %d", s, 0x1F)
|
||||
|
||||
// M77, L3.1: chrome, edge
|
||||
s = EncodeProfile(0x4D, 0x00)
|
||||
t.Logf("Profile: %s, Level: %d", s, 0x1F)
|
||||
}
|
||||
+22
-17
@@ -8,7 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/mdns"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
||||
"github.com/brutella/hap"
|
||||
"github.com/brutella/hap/chacha20poly1305"
|
||||
"github.com/brutella/hap/curve25519"
|
||||
@@ -61,28 +61,29 @@ func NewConn(rawURL string) (*Conn, error) {
|
||||
}
|
||||
|
||||
func Pair(deviceID, pin string) (*Conn, error) {
|
||||
entry := mdns.GetEntry(deviceID)
|
||||
if entry == nil {
|
||||
var addr string
|
||||
var mfi bool
|
||||
|
||||
_ = mdns.Discovery(mdns.ServiceHAP, func(entry *mdns.ServiceEntry) bool {
|
||||
if entry.Complete() && entry.Info["id"] == deviceID {
|
||||
addr = entry.Addr()
|
||||
mfi = entry.Info["ff"] == "1"
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
if addr == "" {
|
||||
return nil, errors.New("can't find device via mDNS")
|
||||
}
|
||||
|
||||
c := &Conn{
|
||||
DeviceAddress: fmt.Sprintf("%s:%d", entry.AddrV4.String(), entry.Port),
|
||||
DeviceAddress: addr,
|
||||
DeviceID: deviceID,
|
||||
ClientID: GenerateUUID(),
|
||||
ClientPrivate: GenerateKey(),
|
||||
}
|
||||
|
||||
var mfi bool
|
||||
for _, field := range entry.InfoFields {
|
||||
if field[:2] == "ff" {
|
||||
if field[3] == '1' {
|
||||
mfi = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return c, c.Pair(mfi, pin)
|
||||
}
|
||||
|
||||
@@ -106,9 +107,13 @@ func (c *Conn) DialAndServe() error {
|
||||
|
||||
func (c *Conn) Dial() error {
|
||||
// update device host before dial
|
||||
if host := mdns.GetAddress(c.DeviceID); host != "" {
|
||||
c.DeviceAddress = host
|
||||
}
|
||||
_ = mdns.Discovery(mdns.ServiceHAP, func(entry *mdns.ServiceEntry) bool {
|
||||
if entry.Complete() && entry.Info["id"] == c.DeviceID {
|
||||
c.DeviceAddress = entry.Addr()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
var err error
|
||||
c.conn, err = net.DialTimeout("tcp", c.DeviceAddress, time.Second*5)
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
package mdns
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/hashicorp/mdns"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const Suffix = "._hap._tcp.local."
|
||||
|
||||
func GetAll() chan *mdns.ServiceEntry {
|
||||
entries := make(chan *mdns.ServiceEntry)
|
||||
params := &mdns.QueryParam{
|
||||
Service: "_hap._tcp", Entries: entries, DisableIPv6: true,
|
||||
}
|
||||
|
||||
go func() {
|
||||
_ = mdns.Query(params)
|
||||
close(entries)
|
||||
}()
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
func GetAddress(deviceID string) string {
|
||||
for entry := range GetAll() {
|
||||
if strings.Contains(entry.Info, deviceID) {
|
||||
return fmt.Sprintf("%s:%d", entry.AddrV4.String(), entry.Port)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func GetEntry(deviceID string) *mdns.ServiceEntry {
|
||||
for entry := range GetAll() {
|
||||
if strings.Contains(entry.Info, deviceID) {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package mdns
|
||||
|
||||
import (
|
||||
"github.com/hashicorp/mdns"
|
||||
"net"
|
||||
)
|
||||
|
||||
const HostHeaderTail = "._hap._tcp.local"
|
||||
|
||||
func NewServer(name string, port int, ips []net.IP, txt []string) (*mdns.Server, error) {
|
||||
if ips == nil || ips[0] == nil {
|
||||
ips = LocalIPs()
|
||||
}
|
||||
|
||||
// important to set hostName manually with any value and `.local.` tail
|
||||
// important to set ips manually
|
||||
service, _ := mdns.NewMDNSService(
|
||||
name, "_hap._tcp", "", name+".local.", port, ips, txt,
|
||||
)
|
||||
|
||||
return mdns.NewServer(&mdns.Config{Zone: service})
|
||||
}
|
||||
|
||||
func LocalIPs() []net.IP {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var ips []net.IP
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagUp == 0 {
|
||||
continue // interface down
|
||||
}
|
||||
if iface.Flags&net.FlagLoopback != 0 {
|
||||
continue // loopback interface
|
||||
}
|
||||
|
||||
var addrs []net.Addr
|
||||
if addrs, err = iface.Addrs(); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
switch addr := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ips = append(ips, addr.IP)
|
||||
case *net.IPAddr:
|
||||
ips = append(ips, addr.IP)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ips
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package hass
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gorilla/websocket"
|
||||
"os"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
ws *websocket.Conn
|
||||
}
|
||||
|
||||
func NewAPI(url, token string) (*API, error) {
|
||||
ws, _, err := websocket.DefaultDialer.Dial(url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
api := &API{ws: ws}
|
||||
if err = api.Auth(token); err != nil {
|
||||
_ = ws.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return api, nil
|
||||
}
|
||||
|
||||
func (a *API) Auth(token string) error {
|
||||
var res ResponseAuth
|
||||
|
||||
if err := a.ws.ReadJSON(&res); err != nil {
|
||||
return err
|
||||
}
|
||||
if res.Type != "auth_required" {
|
||||
return errors.New("hass: wrong type: " + res.Type)
|
||||
}
|
||||
|
||||
s := `{"type":"auth","access_token":"` + token + `"}`
|
||||
if err := a.ws.WriteMessage(websocket.TextMessage, []byte(s)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.ws.ReadJSON(&res); err != nil {
|
||||
return err
|
||||
}
|
||||
if res.Type != "auth_ok" {
|
||||
return errors.New("hass: wrong type: " + res.Type)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *API) Close() error {
|
||||
return a.ws.Close()
|
||||
}
|
||||
|
||||
func (a *API) ExchangeSDP(entityID, offer string) (string, error) {
|
||||
var msg = map[string]any{
|
||||
"id": 1,
|
||||
"type": "camera/web_rtc_offer",
|
||||
"entity_id": entityID,
|
||||
"offer": offer,
|
||||
}
|
||||
if err := a.ws.WriteJSON(msg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var res ResponseOffer
|
||||
if err := a.ws.ReadJSON(&res); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if res.Type != "result" || !res.Success {
|
||||
return "", errors.New("hass: wrong response")
|
||||
}
|
||||
|
||||
return res.Result.Answer, nil
|
||||
}
|
||||
|
||||
func (a *API) GetWebRTCEntities() (map[string]string, error) {
|
||||
s := `{"id":1,"type":"get_states"}`
|
||||
if err := a.ws.WriteMessage(websocket.TextMessage, []byte(s)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var res ResponseStates
|
||||
if err := a.ws.ReadJSON(&res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Type != "result" || !res.Success {
|
||||
return nil, errors.New("hass: wrong response")
|
||||
}
|
||||
|
||||
entities := map[string]string{}
|
||||
|
||||
for _, entity := range res.Result {
|
||||
if entity.Attributes.FrontendStreamType == "web_rtc" {
|
||||
entities[entity.Attributes.FriendlyName] = entity.EntityId
|
||||
}
|
||||
}
|
||||
|
||||
return entities, nil
|
||||
}
|
||||
|
||||
type ResponseAuth struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type ResponseStates struct {
|
||||
//Id int `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Success bool `json:"success"`
|
||||
Result []struct {
|
||||
EntityId string `json:"entity_id"`
|
||||
//State string `json:"state"`
|
||||
Attributes struct {
|
||||
//ModelName string `json:"model_name"`
|
||||
//Brand string `json:"brand"`
|
||||
FrontendStreamType string `json:"frontend_stream_type"`
|
||||
FriendlyName string `json:"friendly_name"`
|
||||
//SupportedFeatures int `json:"supported_features"`
|
||||
} `json:"attributes"`
|
||||
//LastChanged time.Time `json:"last_changed"`
|
||||
//LastUpdated time.Time `json:"last_updated"`
|
||||
//Context struct {
|
||||
// Id string `json:"id"`
|
||||
// ParentId interface{} `json:"parent_id"`
|
||||
// UserId interface{} `json:"user_id"`
|
||||
//} `json:"context"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
type ResponseOffer struct {
|
||||
//Id int `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Success bool `json:"success"`
|
||||
Result struct {
|
||||
Answer string `json:"answer"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
func SupervisorToken() string {
|
||||
return os.Getenv("SUPERVISOR_TOKEN")
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package hass
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
pion "github.com/pion/webrtc/v3"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
conn *webrtc.Conn
|
||||
}
|
||||
|
||||
func NewClient(rawURL string) (*Client, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
|
||||
entityID := query.Get("entity_id")
|
||||
if entityID == "" {
|
||||
return nil, errors.New("hass: no entity_id")
|
||||
}
|
||||
|
||||
var uri, token string
|
||||
|
||||
if u.Host == "supervisor" {
|
||||
uri = "ws://supervisor/core/websocket"
|
||||
token = SupervisorToken()
|
||||
} else {
|
||||
uri = "ws://" + u.Host + "/api/websocket"
|
||||
token = query.Get("token")
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return nil, errors.New("hass: no token")
|
||||
}
|
||||
|
||||
// 1. Check connection to Hass
|
||||
hassAPI, err := NewAPI(uri, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer hassAPI.Close()
|
||||
|
||||
// 2. Create WebRTC client
|
||||
rtcAPI, err := webrtc.NewAPI("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conf := pion.Configuration{}
|
||||
pc, err := rtcAPI.NewPeerConnection(conf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn := webrtc.NewConn(pc)
|
||||
conn.Desc = "Hass"
|
||||
conn.Mode = core.ModeActiveProducer
|
||||
|
||||
// https://developers.google.com/nest/device-access/traits/device/camera-live-stream#generatewebrtcstream-request-fields
|
||||
medias := []*core.Media{
|
||||
{Kind: core.KindAudio, Direction: core.DirectionRecvonly},
|
||||
{Kind: core.KindVideo, Direction: core.DirectionRecvonly},
|
||||
{Kind: "app"}, // important for Nest
|
||||
}
|
||||
|
||||
// 3. Create offer with candidates
|
||||
offer, err := conn.CreateCompleteOffer(medias)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4. Exchange SDP via Hass
|
||||
answer, err := hassAPI.ExchangeSDP(entityID, offer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 5. Set answer with remote medias
|
||||
if err = conn.SetAnswer(answer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Client{conn: conn}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetMedias() []*core.Media {
|
||||
return c.conn.GetMedias()
|
||||
}
|
||||
|
||||
func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) {
|
||||
return c.conn.GetTrack(media, codec)
|
||||
}
|
||||
|
||||
func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error {
|
||||
return c.conn.AddTrack(media, codec, track)
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
return c.conn.Start()
|
||||
}
|
||||
|
||||
func (c *Client) Stop() error {
|
||||
return c.conn.Stop()
|
||||
}
|
||||
|
||||
func (c *Client) MarshalJSON() ([]byte, error) {
|
||||
return c.conn.MarshalJSON()
|
||||
}
|
||||
+1
-1
@@ -39,7 +39,7 @@ const (
|
||||
|
||||
SampleVideoIFrame = sampleDependsOn2
|
||||
SampleVideoNonIFrame = sampleDependsOn1 | sampleIsNonSync
|
||||
SampleAudio = sampleIsNonSync
|
||||
SampleAudio = sampleDependsOn2 //sampleIsNonSync
|
||||
)
|
||||
|
||||
func (m *Movie) WriteFileType() {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package mdns
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/miekg/dns"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const ServiceHAP = "_hap._tcp.local." // HomeKit Accessory Protocol
|
||||
|
||||
const requestTimeout = time.Millisecond * 505
|
||||
const responseTimeout = time.Second * 2
|
||||
|
||||
type ServiceEntry struct {
|
||||
Name string
|
||||
IP net.IP
|
||||
Port uint16
|
||||
Info map[string]string
|
||||
}
|
||||
|
||||
func (e *ServiceEntry) Complete() bool {
|
||||
return e.IP != nil && e.Port > 0 && e.Info != nil
|
||||
}
|
||||
|
||||
func (e *ServiceEntry) Addr() string {
|
||||
return fmt.Sprintf("%s:%d", e.IP, e.Port)
|
||||
}
|
||||
|
||||
func Discovery(service string, onentry func(*ServiceEntry) bool) error {
|
||||
addr := &net.UDPAddr{
|
||||
IP: net.IP{224, 0, 0, 251},
|
||||
Port: 5353,
|
||||
}
|
||||
|
||||
conn, err := net.ListenMulticastUDP("udp4", nil, addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
if err = conn.SetDeadline(time.Now().Add(responseTimeout)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg := &dns.Msg{
|
||||
Question: []dns.Question{
|
||||
{service, dns.TypePTR, dns.ClassINET},
|
||||
},
|
||||
}
|
||||
|
||||
b1, err := msg.Pack()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
if _, err := conn.WriteToUDP(b1, addr); err != nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(requestTimeout)
|
||||
}
|
||||
}()
|
||||
|
||||
var skipPTR []string
|
||||
|
||||
b2 := make([]byte, 1500)
|
||||
loop:
|
||||
for {
|
||||
// in the Hass docker network can receive same msg from different address
|
||||
n, _, err := conn.ReadFromUDP(b2)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if err = msg.Unpack(b2[:n]); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
ptr := GetPTR(msg)
|
||||
|
||||
if !strings.HasSuffix(ptr, service) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, s := range skipPTR {
|
||||
if s == ptr {
|
||||
continue loop
|
||||
}
|
||||
}
|
||||
|
||||
if entry := NewServiceEntry(msg); onentry(entry) {
|
||||
break
|
||||
}
|
||||
|
||||
skipPTR = append(skipPTR, ptr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetPTR(msg *dns.Msg) string {
|
||||
for _, rr := range msg.Answer {
|
||||
if rr, ok := rr.(*dns.PTR); ok {
|
||||
return rr.Ptr
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func NewServiceEntry(msg *dns.Msg) *ServiceEntry {
|
||||
entry := &ServiceEntry{}
|
||||
|
||||
records := make([]dns.RR, 0, len(msg.Answer)+len(msg.Ns)+len(msg.Extra))
|
||||
records = append(records, msg.Answer...)
|
||||
records = append(records, msg.Ns...)
|
||||
records = append(records, msg.Extra...)
|
||||
for _, record := range records {
|
||||
switch record := record.(type) {
|
||||
case *dns.PTR:
|
||||
if i := strings.IndexByte(record.Ptr, '.'); i > 0 {
|
||||
entry.Name = record.Ptr[:i]
|
||||
}
|
||||
case *dns.A:
|
||||
entry.IP = record.A
|
||||
case *dns.SRV:
|
||||
entry.Port = record.Port
|
||||
case *dns.TXT:
|
||||
entry.Info = make(map[string]string, len(record.Txt))
|
||||
for _, txt := range record.Txt {
|
||||
k, v, _ := strings.Cut(txt, "=")
|
||||
entry.Info[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package mdns
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDiscovery(t *testing.T) {
|
||||
onentry := func(entry *ServiceEntry) bool {
|
||||
return true
|
||||
}
|
||||
err := Discovery(ServiceHAP, onentry)
|
||||
//err := Discovery("_ewelink._tcp.local.", time.Second, onentry)
|
||||
// err := Discovery("_googlecast._tcp.local.", time.Second, onentry)
|
||||
require.Nil(t, err)
|
||||
}
|
||||
+20
-16
@@ -14,7 +14,9 @@ import (
|
||||
type Consumer struct {
|
||||
core.Listener
|
||||
|
||||
Medias []*core.Media
|
||||
Medias []*core.Media
|
||||
|
||||
Desc string
|
||||
UserAgent string
|
||||
RemoteAddr string
|
||||
|
||||
@@ -22,7 +24,7 @@ type Consumer struct {
|
||||
|
||||
muxer *Muxer
|
||||
mu sync.Mutex
|
||||
wait byte
|
||||
state byte
|
||||
|
||||
send int
|
||||
}
|
||||
@@ -60,18 +62,16 @@ func (c *Consumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv
|
||||
|
||||
switch track.Codec.Name {
|
||||
case core.CodecH264:
|
||||
c.wait = waitInit
|
||||
|
||||
handler.Handler = func(packet *rtp.Packet) {
|
||||
if packet.Version != h264.RTPPacketVersionAVC {
|
||||
return
|
||||
}
|
||||
|
||||
if c.wait != waitNone {
|
||||
if c.wait == waitInit || !h264.IsKeyframe(packet.Payload) {
|
||||
if c.state != stateStart {
|
||||
if c.state != stateInit || !h264.IsKeyframe(packet.Payload) {
|
||||
return
|
||||
}
|
||||
c.wait = waitNone
|
||||
c.state = stateStart
|
||||
}
|
||||
|
||||
// important to use Mutex because right fragment order
|
||||
@@ -89,18 +89,16 @@ func (c *Consumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv
|
||||
}
|
||||
|
||||
case core.CodecH265:
|
||||
c.wait = waitInit
|
||||
|
||||
handler.Handler = func(packet *rtp.Packet) {
|
||||
if packet.Version != h264.RTPPacketVersionAVC {
|
||||
return
|
||||
}
|
||||
|
||||
if c.wait != waitNone {
|
||||
if c.wait == waitInit || !h265.IsKeyframe(packet.Payload) {
|
||||
if c.state != stateStart {
|
||||
if c.state != stateInit || !h265.IsKeyframe(packet.Payload) {
|
||||
return
|
||||
}
|
||||
c.wait = waitNone
|
||||
c.state = stateStart
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
@@ -116,7 +114,7 @@ func (c *Consumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiv
|
||||
|
||||
default:
|
||||
handler.Handler = func(packet *rtp.Packet) {
|
||||
if c.wait != waitNone {
|
||||
if c.state != stateStart {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -182,14 +180,20 @@ func (c *Consumer) Init() ([]byte, error) {
|
||||
}
|
||||
|
||||
func (c *Consumer) Start() {
|
||||
if c.wait == waitInit {
|
||||
c.wait = waitKeyframe
|
||||
for _, sender := range c.senders {
|
||||
switch sender.Codec.Name {
|
||||
case core.CodecH264, core.CodecH265:
|
||||
c.state = stateInit
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.state = stateStart
|
||||
}
|
||||
|
||||
func (c *Consumer) MarshalJSON() ([]byte, error) {
|
||||
info := &core.Info{
|
||||
Type: "MP4 passive consumer",
|
||||
Type: c.Desc + " passive consumer",
|
||||
RemoteAddr: c.RemoteAddr,
|
||||
UserAgent: c.UserAgent,
|
||||
Medias: c.Medias,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package mp4
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/pion/rtp"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStartH264(t *testing.T) {
|
||||
codec := &core.Codec{Name: core.CodecH264}
|
||||
track := core.NewReceiver(nil, codec)
|
||||
|
||||
packetKey := &rtp.Packet{
|
||||
Header: rtp.Header{Marker: true},
|
||||
Payload: []byte{h264.NALUTypeIFrame, 0, 0},
|
||||
}
|
||||
|
||||
packetNotKey := &rtp.Packet{
|
||||
Header: rtp.Header{Marker: true},
|
||||
Payload: []byte{h264.NALUTypePFrame, 0, 0},
|
||||
}
|
||||
|
||||
cons := &Consumer{}
|
||||
err := cons.AddTrack(nil, nil, track)
|
||||
require.Nil(t, err)
|
||||
|
||||
track.WriteRTP(packetKey)
|
||||
time.Sleep(time.Millisecond)
|
||||
|
||||
_, err = cons.Init()
|
||||
require.Nil(t, err)
|
||||
|
||||
cons.Start()
|
||||
|
||||
track.WriteRTP(packetNotKey)
|
||||
time.Sleep(time.Millisecond)
|
||||
|
||||
require.Zero(t, cons.send)
|
||||
|
||||
track.WriteRTP(packetKey)
|
||||
time.Sleep(time.Millisecond)
|
||||
|
||||
require.NotZero(t, cons.send)
|
||||
}
|
||||
|
||||
func TestStartOPUS(t *testing.T) {
|
||||
// Test for fix this issue
|
||||
// https://github.com/AlexxIT/go2rtc/issues/404
|
||||
codec := &core.Codec{Name: core.CodecOpus}
|
||||
track := core.NewReceiver(nil, codec)
|
||||
|
||||
cons := &Consumer{}
|
||||
err := cons.AddTrack(nil, nil, track)
|
||||
require.Nil(t, err)
|
||||
|
||||
track.WriteRTP(&rtp.Packet{
|
||||
Payload: []byte{0},
|
||||
})
|
||||
time.Sleep(time.Millisecond)
|
||||
|
||||
require.Zero(t, cons.send)
|
||||
|
||||
_, err = cons.Init()
|
||||
require.Nil(t, err)
|
||||
|
||||
cons.Start()
|
||||
|
||||
track.WriteRTP(&rtp.Packet{
|
||||
Payload: []byte{0},
|
||||
})
|
||||
time.Sleep(time.Millisecond)
|
||||
|
||||
require.NotZero(t, cons.send)
|
||||
}
|
||||
+60
-4
@@ -1,6 +1,9 @@
|
||||
package mp4
|
||||
|
||||
import "github.com/AlexxIT/go2rtc/pkg/core"
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseQuery - like usual parse, but with mp4 param handler
|
||||
func ParseQuery(query map[string][]string) []*core.Media {
|
||||
@@ -48,8 +51,61 @@ func ParseQuery(query map[string][]string) []*core.Media {
|
||||
return core.ParseQuery(query)
|
||||
}
|
||||
|
||||
func ParseCodecs(codecs string, parseAudio bool) (medias []*core.Media) {
|
||||
var videos []*core.Codec
|
||||
var audios []*core.Codec
|
||||
|
||||
loop:
|
||||
for _, name := range strings.Split(codecs, ",") {
|
||||
switch name {
|
||||
case MimeH264:
|
||||
codec := &core.Codec{Name: core.CodecH264}
|
||||
videos = append(videos, codec)
|
||||
case MimeH265:
|
||||
codec := &core.Codec{Name: core.CodecH265}
|
||||
videos = append(videos, codec)
|
||||
case MimeAAC:
|
||||
codec := &core.Codec{Name: core.CodecAAC}
|
||||
audios = append(audios, codec)
|
||||
case "null":
|
||||
// this means that the browser is lying about the codecs it can play
|
||||
// and we are not supposed to believe that it can flac or opus
|
||||
break loop
|
||||
case MimeFlac:
|
||||
audios = append(audios,
|
||||
&core.Codec{Name: core.CodecPCMA},
|
||||
&core.Codec{Name: core.CodecPCMU},
|
||||
&core.Codec{Name: core.CodecPCM},
|
||||
)
|
||||
case MimeOpus:
|
||||
codec := &core.Codec{Name: core.CodecOpus}
|
||||
audios = append(audios, codec)
|
||||
}
|
||||
}
|
||||
|
||||
if videos != nil {
|
||||
media := &core.Media{
|
||||
Kind: core.KindVideo,
|
||||
Direction: core.DirectionSendonly,
|
||||
Codecs: videos,
|
||||
}
|
||||
medias = append(medias, media)
|
||||
}
|
||||
|
||||
if audios != nil && parseAudio {
|
||||
media := &core.Media{
|
||||
Kind: core.KindAudio,
|
||||
Direction: core.DirectionSendonly,
|
||||
Codecs: audios,
|
||||
}
|
||||
medias = append(medias, media)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const (
|
||||
waitNone byte = iota
|
||||
waitKeyframe
|
||||
waitInit
|
||||
stateNone byte = iota
|
||||
stateInit
|
||||
stateStart
|
||||
)
|
||||
|
||||
+14
-10
@@ -151,19 +151,14 @@ func (m *Muxer) Reset() {
|
||||
func (m *Muxer) Marshal(trackID byte, packet *rtp.Packet) []byte {
|
||||
codec := m.codecs[trackID]
|
||||
|
||||
m.fragIndex++
|
||||
|
||||
duration := packet.Timestamp - m.pts[trackID]
|
||||
m.pts[trackID] = packet.Timestamp
|
||||
|
||||
// minumum duration important for MSE in Apple Safari
|
||||
if duration == 0 || duration > codec.ClockRate {
|
||||
duration = codec.ClockRate/1000 + 1
|
||||
m.pts[trackID] += duration
|
||||
}
|
||||
|
||||
size := len(packet.Payload)
|
||||
|
||||
// flags important for Apple Finder video preview
|
||||
var flags uint32
|
||||
|
||||
switch codec.Name {
|
||||
case core.CodecH264:
|
||||
if h264.IsKeyframe(packet.Payload) {
|
||||
@@ -177,11 +172,20 @@ func (m *Muxer) Marshal(trackID byte, packet *rtp.Packet) []byte {
|
||||
} else {
|
||||
flags = iso.SampleVideoNonIFrame
|
||||
}
|
||||
case core.CodecAAC:
|
||||
duration = 1024 // important for Apple Finder and QuickTime
|
||||
flags = iso.SampleAudio // not important?
|
||||
default:
|
||||
flags = iso.SampleAudio // not important
|
||||
flags = iso.SampleAudio // important for FLAC on Android Telegram
|
||||
}
|
||||
|
||||
m.fragIndex++
|
||||
// minumum duration important for MSE in Apple Safari
|
||||
if duration == 0 || duration > codec.ClockRate {
|
||||
duration = codec.ClockRate/1000 + 1
|
||||
m.pts[trackID] += duration
|
||||
}
|
||||
|
||||
size := len(packet.Payload)
|
||||
|
||||
mv := iso.NewMovie(1024 + size)
|
||||
mv.WriteMovieFragment(
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package mpegts
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTime(t *testing.T) {
|
||||
w := NewWriter()
|
||||
w.WriteTime(0xFFFFFFFF)
|
||||
assert.Equal(t, []byte{0x27, 0xFF, 0xFF, 0xFF, 0xFF}, w.Bytes())
|
||||
ts := ParseTime(w.Bytes())
|
||||
assert.Equal(t, uint32(0xFFFFFFFF), ts)
|
||||
}
|
||||
|
||||
func dec(s string) []byte {
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
b, _ := hex.DecodeString(s)
|
||||
return b
|
||||
}
|
||||
|
||||
//func TestStream(t *testing.T) {
|
||||
// // ffmpeg
|
||||
// annexb := dec("00000001 09f0 00000001 6764001fac2484014016ec0440000003004000000c23c60c92 00000001 68ee32c8b0 000001 6588808003 00000001 09")
|
||||
// avc, i := ParseAVC(annexb)
|
||||
// assert.Equal(t, dec("00000019 6764001fac2484014016ec0440000003004000000c23c60c92 00000005 68ee32c8b0 00000005 6588808003"), avc)
|
||||
// assert.Equal(t, dec("00000001 09"), annexb[i:])
|
||||
//
|
||||
// // http mpeg ts
|
||||
// annexb = dec("00000001 0950 000001 6764001facd2014016e8400000fa400030e081 000001 68ea8f2c 000001 65b8400eff 00000001 09")
|
||||
// avc, i = ParseAVC(annexb)
|
||||
// assert.Equal(t, dec("00000013 6764001facd2014016e8400000fa400030e081 00000004 68ea8f2c 00000005 65b8400eff"), avc)
|
||||
// assert.Equal(t, dec("00000001 09"), annexb[i:])
|
||||
//
|
||||
// // tapo TC60
|
||||
// annexb = dec("00000001 67640028ac1ad00a00b74dc0404050000003001000000301e8f1422a 00000001 68ee04c92240 00000001 45b80000d0 00000001 67")
|
||||
// avc, i = ParseAVC(annexb)
|
||||
// assert.Equal(t, dec("0000001C 67640028ac1ad00a00b74dc0404050000003001000000301e8f1422a 00000006 68ee04c92240 00000005 45b80000d0"), avc)
|
||||
// assert.Equal(t, dec("00000001 67"), annexb[i:])
|
||||
//
|
||||
// // Tapo ?
|
||||
// annexb = dec("00000001 674d0032e90048014742000007d2000138d108 00000001 68ea8f20 00000001 65b8400cff 00000001 67")
|
||||
// avc, i = ParseAVC(annexb)
|
||||
// assert.Equal(t, dec("00000013 674d0032e90048014742000007d2000138d108 00000004 68ea8f20 00000005 65b8400cff"), avc)
|
||||
// assert.Equal(t, dec("00000001 67"), annexb[i:])
|
||||
//}
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
package nest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
AccessToken string
|
||||
}
|
||||
|
||||
var cache = map[string]*API{}
|
||||
var cacheMu sync.Mutex
|
||||
|
||||
func NewAPI(clientID, clientSecret, refreshToken string) (*API, error) {
|
||||
cacheMu.Lock()
|
||||
defer cacheMu.Unlock()
|
||||
|
||||
key := clientID + ":" + clientSecret + ":" + refreshToken
|
||||
now := time.Now()
|
||||
|
||||
if api := cache[key]; api != nil && now.Before(api.ExpiresAt) {
|
||||
return api, nil
|
||||
}
|
||||
|
||||
data := url.Values{
|
||||
"grant_type": []string{"refresh_token"},
|
||||
"client_id": []string{clientID},
|
||||
"client_secret": []string{clientSecret},
|
||||
"refresh_token": []string{refreshToken},
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: time.Second * 5000}
|
||||
res, err := client.PostForm("https://www.googleapis.com/oauth2/v4/token", data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
return nil, errors.New("nest: wrong status: " + res.Status)
|
||||
}
|
||||
|
||||
var resv struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn time.Duration `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
|
||||
if err = json.NewDecoder(res.Body).Decode(&resv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
api := &API{
|
||||
Token: resv.AccessToken,
|
||||
ExpiresAt: now.Add(resv.ExpiresIn * time.Second),
|
||||
}
|
||||
|
||||
cache[key] = api
|
||||
|
||||
return api, nil
|
||||
}
|
||||
|
||||
func (a *API) GetDevices(projectID string) (map[string]string, error) {
|
||||
uri := "https://smartdevicemanagement.googleapis.com/v1/enterprises/" + projectID + "/devices"
|
||||
req, err := http.NewRequest("GET", uri, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+a.Token)
|
||||
|
||||
client := &http.Client{Timeout: time.Second * 5000}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if res.StatusCode != 200 {
|
||||
return nil, errors.New("nest: wrong status: " + res.Status)
|
||||
}
|
||||
|
||||
var resv struct {
|
||||
Devices []Device
|
||||
}
|
||||
|
||||
if err = json.NewDecoder(res.Body).Decode(&resv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
devices := map[string]string{}
|
||||
|
||||
for _, device := range resv.Devices {
|
||||
if len(device.Traits.SdmDevicesTraitsCameraLiveStream.SupportedProtocols) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if device.Traits.SdmDevicesTraitsCameraLiveStream.SupportedProtocols[0] != "WEB_RTC" {
|
||||
continue
|
||||
}
|
||||
|
||||
i := strings.LastIndexByte(device.Name, '/')
|
||||
if i <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
name := device.Traits.SdmDevicesTraitsInfo.CustomName
|
||||
devices[name] = device.Name[i+1:]
|
||||
}
|
||||
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
func (a *API) ExchangeSDP(projectID, deviceID, offer string) (string, error) {
|
||||
var reqv struct {
|
||||
Command string `json:"command"`
|
||||
Params struct {
|
||||
Offer string `json:"offerSdp"`
|
||||
} `json:"params"`
|
||||
}
|
||||
reqv.Command = "sdm.devices.commands.CameraLiveStream.GenerateWebRtcStream"
|
||||
reqv.Params.Offer = offer
|
||||
|
||||
b, err := json.Marshal(reqv)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
uri := "https://smartdevicemanagement.googleapis.com/v1/enterprises/" +
|
||||
projectID + "/devices/" + deviceID + ":executeCommand"
|
||||
req, err := http.NewRequest("POST", uri, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+a.Token)
|
||||
|
||||
client := &http.Client{Timeout: time.Second * 5000}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if res.StatusCode != 200 {
|
||||
return "", errors.New("nest: wrong status: " + res.Status)
|
||||
}
|
||||
|
||||
var resv struct {
|
||||
Results struct {
|
||||
Answer string `json:"answerSdp"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
MediaSessionId string `json:"mediaSessionId"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
if err = json.NewDecoder(res.Body).Decode(&resv); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return resv.Results.Answer, nil
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
//Assignee string `json:"assignee"`
|
||||
Traits struct {
|
||||
SdmDevicesTraitsInfo struct {
|
||||
CustomName string `json:"customName"`
|
||||
} `json:"sdm.devices.traits.Info"`
|
||||
SdmDevicesTraitsCameraLiveStream struct {
|
||||
VideoCodecs []string `json:"videoCodecs"`
|
||||
AudioCodecs []string `json:"audioCodecs"`
|
||||
SupportedProtocols []string `json:"supportedProtocols"`
|
||||
} `json:"sdm.devices.traits.CameraLiveStream"`
|
||||
//SdmDevicesTraitsCameraImage struct {
|
||||
// MaxImageResolution struct {
|
||||
// Width int `json:"width"`
|
||||
// Height int `json:"height"`
|
||||
// } `json:"maxImageResolution"`
|
||||
//} `json:"sdm.devices.traits.CameraImage"`
|
||||
//SdmDevicesTraitsCameraPerson struct {
|
||||
//} `json:"sdm.devices.traits.CameraPerson"`
|
||||
//SdmDevicesTraitsCameraMotion struct {
|
||||
//} `json:"sdm.devices.traits.CameraMotion"`
|
||||
//SdmDevicesTraitsDoorbellChime struct {
|
||||
//} `json:"sdm.devices.traits.DoorbellChime"`
|
||||
//SdmDevicesTraitsCameraClipPreview struct {
|
||||
//} `json:"sdm.devices.traits.CameraClipPreview"`
|
||||
} `json:"traits"`
|
||||
//ParentRelations []struct {
|
||||
// Parent string `json:"parent"`
|
||||
// DisplayName string `json:"displayName"`
|
||||
//} `json:"parentRelations"`
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package nest
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
pion "github.com/pion/webrtc/v3"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
conn *webrtc.Conn
|
||||
}
|
||||
|
||||
func NewClient(rawURL string) (*Client, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
cliendID := query.Get("client_id")
|
||||
cliendSecret := query.Get("client_secret")
|
||||
refreshToken := query.Get("refresh_token")
|
||||
projectID := query.Get("project_id")
|
||||
deviceID := query.Get("device_id")
|
||||
|
||||
if cliendID == "" || cliendSecret == "" || refreshToken == "" || projectID == "" || deviceID == "" {
|
||||
return nil, errors.New("nest: wrong query")
|
||||
}
|
||||
|
||||
nestAPI, err := NewAPI(cliendID, cliendSecret, refreshToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rtcAPI, err := webrtc.NewAPI("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conf := pion.Configuration{}
|
||||
pc, err := rtcAPI.NewPeerConnection(conf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn := webrtc.NewConn(pc)
|
||||
conn.Desc = "Nest"
|
||||
conn.Mode = core.ModeActiveProducer
|
||||
|
||||
// https://developers.google.com/nest/device-access/traits/device/camera-live-stream#generatewebrtcstream-request-fields
|
||||
medias := []*core.Media{
|
||||
{Kind: core.KindAudio, Direction: core.DirectionRecvonly},
|
||||
{Kind: core.KindVideo, Direction: core.DirectionRecvonly},
|
||||
{Kind: "app"}, // important for Nest
|
||||
}
|
||||
|
||||
// 3. Create offer with candidates
|
||||
offer, err := conn.CreateCompleteOffer(medias)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4. Exchange SDP via Hass
|
||||
answer, err := nestAPI.ExchangeSDP(projectID, deviceID, offer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 5. Set answer with remote medias
|
||||
if err = conn.SetAnswer(answer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Client{conn: conn}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetMedias() []*core.Media {
|
||||
return c.conn.GetMedias()
|
||||
}
|
||||
|
||||
func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) {
|
||||
return c.conn.GetTrack(media, codec)
|
||||
}
|
||||
|
||||
func (c *Client) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error {
|
||||
return c.conn.AddTrack(media, codec, track)
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
return c.conn.Start()
|
||||
}
|
||||
|
||||
func (c *Client) Stop() error {
|
||||
return c.conn.Stop()
|
||||
}
|
||||
|
||||
func (c *Client) MarshalJSON() ([]byte, error) {
|
||||
return c.conn.MarshalJSON()
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const PathDevice = "/onvif/device_service"
|
||||
|
||||
type Client struct {
|
||||
url *url.URL
|
||||
|
||||
|
||||
@@ -9,10 +9,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
PathDevice = "/onvif/device_service"
|
||||
)
|
||||
|
||||
func FindTagValue(b []byte, tag string) string {
|
||||
re := regexp.MustCompile(`<[^/>]*` + tag + `[^>]*>([^<]+)`)
|
||||
m := re.FindSubmatch(b)
|
||||
@@ -34,6 +30,8 @@ func DiscoveryStreamingURLs() ([]string, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
// https://www.onvif.org/wp-content/uploads/2016/12/ONVIF_Feature_Discovery_Specification_16.07.pdf
|
||||
// 5.3 Discovery Procedure:
|
||||
msg := `<?xml version="1.0" ?>
|
||||
@@ -45,7 +43,7 @@ func DiscoveryStreamingURLs() ([]string, error) {
|
||||
</s:Header>
|
||||
<s:Body>
|
||||
<d:Probe xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery">
|
||||
<d:Types />
|
||||
<d:Types />
|
||||
<d:Scopes />
|
||||
</d:Probe>
|
||||
</s:Body>
|
||||
|
||||
+9
-9
@@ -45,12 +45,12 @@ func GetRequestAction(b []byte) string {
|
||||
func GetCapabilitiesResponse(host string) string {
|
||||
return `<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
|
||||
<s:Body>
|
||||
<tds:GetCapabilitiesResponse xmlns:tds="http://www.onvif.org/ver10/device/wsdl">
|
||||
<tds:Capabilities xmlns:tt="http://www.onvif.org/ver10/schema">
|
||||
<tt:Device>
|
||||
<tt:XAddr>http://` + host + `/onvif/device_service</tt:XAddr>
|
||||
</tt:Device>
|
||||
<s:Body>
|
||||
<tds:GetCapabilitiesResponse xmlns:tds="http://www.onvif.org/ver10/device/wsdl">
|
||||
<tds:Capabilities xmlns:tt="http://www.onvif.org/ver10/schema">
|
||||
<tt:Device>
|
||||
<tt:XAddr>http://` + host + `/onvif/device_service</tt:XAddr>
|
||||
</tt:Device>
|
||||
<tt:Media>
|
||||
<tt:XAddr>http://` + host + `/onvif/media_service</tt:XAddr>
|
||||
<tt:StreamingCapabilities>
|
||||
@@ -59,9 +59,9 @@ func GetCapabilitiesResponse(host string) string {
|
||||
<tt:RTP_RTSP_TCP>true</tt:RTP_RTSP_TCP>
|
||||
</tt:StreamingCapabilities>
|
||||
</tt:Media>
|
||||
</tds:Capabilities>
|
||||
</tds:GetCapabilitiesResponse>
|
||||
</s:Body>
|
||||
</tds:Capabilities>
|
||||
</tds:GetCapabilitiesResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>`
|
||||
}
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package pcm
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
func RepackBackchannel(handler core.HandlerFunc) core.HandlerFunc {
|
||||
var buf []byte
|
||||
var seq uint16
|
||||
|
||||
return func(packet *rtp.Packet) {
|
||||
buf = append(buf, packet.Payload...)
|
||||
if len(buf) < 1024 {
|
||||
return
|
||||
}
|
||||
|
||||
pkt := &rtp.Packet{
|
||||
Header: rtp.Header{
|
||||
Version: 2,
|
||||
Marker: true, // should be true
|
||||
PayloadType: packet.PayloadType, // will be owerwriten
|
||||
SequenceNumber: seq,
|
||||
Timestamp: 0, // should be always zero
|
||||
SSRC: packet.SSRC,
|
||||
},
|
||||
Payload: buf[:1024],
|
||||
}
|
||||
|
||||
handler(pkt)
|
||||
|
||||
buf = buf[1024:]
|
||||
seq++
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package pcm
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/pion/rtp"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func Resample(codec *core.Codec, sampleRate uint32, handler core.HandlerFunc) core.HandlerFunc {
|
||||
@@ -114,3 +115,54 @@ func DownsamplePCM(fromPCM func(int16) byte, n float32, handler core.HandlerFunc
|
||||
handler(&clone)
|
||||
}
|
||||
}
|
||||
|
||||
// RepackG711 - Repack G.711 PCMA/PCMU into frames of size 1024
|
||||
// 1. Fixes WebRTC audio quality issue (monotonic timestamp)
|
||||
// 2. Fixes Reolink Doorbell backchannel issue (zero timestamp)
|
||||
// https://github.com/AlexxIT/go2rtc/issues/331
|
||||
func RepackG711(zeroTS bool, handler core.HandlerFunc) core.HandlerFunc {
|
||||
const PacketSize = 1024
|
||||
|
||||
var buf []byte
|
||||
var seq uint16
|
||||
var ts uint32
|
||||
|
||||
// fix https://github.com/AlexxIT/go2rtc/issues/432
|
||||
var mu sync.Mutex
|
||||
|
||||
return func(packet *rtp.Packet) {
|
||||
mu.Lock()
|
||||
|
||||
buf = append(buf, packet.Payload...)
|
||||
if len(buf) < PacketSize {
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
pkt := &rtp.Packet{
|
||||
Header: rtp.Header{
|
||||
Version: 2,
|
||||
Marker: true, // should be true
|
||||
PayloadType: packet.PayloadType, // will be owerwriten
|
||||
SequenceNumber: seq,
|
||||
SSRC: packet.SSRC,
|
||||
},
|
||||
Payload: buf[:PacketSize],
|
||||
}
|
||||
|
||||
seq++
|
||||
|
||||
// don't know if zero TS important for Reolink Doorbell
|
||||
// don't have this strange devices for tests
|
||||
if !zeroTS {
|
||||
pkt.Timestamp = ts
|
||||
ts += PacketSize
|
||||
}
|
||||
|
||||
buf = buf[PacketSize:]
|
||||
|
||||
mu.Unlock()
|
||||
|
||||
handler(pkt)
|
||||
}
|
||||
}
|
||||
|
||||
+12
-23
@@ -2,9 +2,9 @@ package rtsp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp/websocket"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -23,41 +23,30 @@ func NewClient(uri string) *Conn {
|
||||
}
|
||||
|
||||
func (c *Conn) Dial() (err error) {
|
||||
if c.URL, err = url.Parse(c.uri); err != nil {
|
||||
return
|
||||
var conn net.Conn
|
||||
|
||||
if c.Transport == "" {
|
||||
conn, err = Dial(c.uri)
|
||||
} else {
|
||||
conn, err = websocket.Dial(c.Transport)
|
||||
}
|
||||
|
||||
if strings.IndexByte(c.URL.Host, ':') < 0 {
|
||||
c.URL.Host += ":554"
|
||||
}
|
||||
|
||||
c.conn, err = net.DialTimeout("tcp", c.URL.Host, time.Second*5)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var tlsConf *tls.Config
|
||||
switch c.URL.Scheme {
|
||||
case "rtsps":
|
||||
tlsConf = &tls.Config{ServerName: c.URL.Hostname()}
|
||||
case "rtspx":
|
||||
c.URL.Scheme = "rtsps"
|
||||
tlsConf = &tls.Config{InsecureSkipVerify: true}
|
||||
}
|
||||
if tlsConf != nil {
|
||||
tlsConn := tls.Client(c.conn, tlsConf)
|
||||
if err = tlsConn.Handshake(); err != nil {
|
||||
return err
|
||||
}
|
||||
c.conn = tlsConn
|
||||
if c.URL, err = url.Parse(c.uri); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// remove UserInfo from URL
|
||||
c.auth = tcp.NewAuth(c.URL.User)
|
||||
c.URL.User = nil
|
||||
|
||||
c.reader = bufio.NewReader(c.conn)
|
||||
c.conn = conn
|
||||
c.reader = bufio.NewReader(conn)
|
||||
c.session = ""
|
||||
c.sequence = 0
|
||||
c.state = StateConn
|
||||
|
||||
return nil
|
||||
|
||||
@@ -88,7 +88,7 @@ Session: 1
|
||||
require.Nil(t, err)
|
||||
require.Len(t, client.Medias, 3)
|
||||
|
||||
ch, err := client.SetupMedia(client.Medias[2], true)
|
||||
ch, err := client.SetupMedia(client.Medias[2])
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, ch, byte(4))
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ type Conn struct {
|
||||
Backchannel bool
|
||||
PacketSize uint16
|
||||
SessionName string
|
||||
Transport string // custom transport support, ex. RTSP over WebSocket
|
||||
|
||||
Medias []*core.Media
|
||||
UserAgent string
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
func (c *Conn) GetMedias() []*core.Media {
|
||||
core.Assert(c.Medias != nil)
|
||||
//core.Assert(c.Medias != nil)
|
||||
return c.Medias
|
||||
}
|
||||
|
||||
@@ -62,9 +62,9 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv
|
||||
// important to send original codec for valid IsRTP check
|
||||
sender.Handler = c.packetWriter(track.Codec, channel, codec.PayloadType)
|
||||
|
||||
// https://github.com/AlexxIT/go2rtc/issues/331
|
||||
if c.mode == core.ModeActiveProducer && track.Codec.Name == core.CodecPCMA {
|
||||
sender.Handler = pcm.RepackBackchannel(sender.Handler)
|
||||
// Fix Reolink Doorbell https://github.com/AlexxIT/go2rtc/issues/331
|
||||
sender.Handler = pcm.RepackG711(true, sender.Handler)
|
||||
}
|
||||
|
||||
sender.HandleRTP(track)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package rtsp
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Dial(uri string) (net.Conn, error) {
|
||||
u, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch u.Scheme {
|
||||
case "rtsp":
|
||||
return dialTCP(u.Host, nil)
|
||||
case "rtsps":
|
||||
tlsConf := &tls.Config{ServerName: u.Hostname()}
|
||||
return dialTCP(u.Host, tlsConf)
|
||||
case "rtspx":
|
||||
tlsConf := &tls.Config{InsecureSkipVerify: true}
|
||||
return dialTCP(u.Host, tlsConf)
|
||||
}
|
||||
|
||||
return nil, errors.New("unsupported scheme: " + u.Scheme)
|
||||
}
|
||||
|
||||
func dialTCP(address string, tlsConf *tls.Config) (net.Conn, error) {
|
||||
if strings.IndexByte(address, ':') < 0 {
|
||||
address += ":554"
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", address, time.Second*5)
|
||||
if tlsConf == nil || err != nil {
|
||||
return conn, err
|
||||
}
|
||||
|
||||
tlsConn := tls.Client(conn, tlsConf)
|
||||
return tlsConn, tlsConn.Handshake()
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package shell
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ReplaceEnvVars(text string) string {
|
||||
re := regexp.MustCompile(`\${([^}{]+)}`)
|
||||
return re.ReplaceAllStringFunc(text, func(match string) string {
|
||||
key := match[2 : len(match)-1]
|
||||
|
||||
var def string
|
||||
var dok bool
|
||||
|
||||
if i := strings.IndexByte(key, ':'); i > 0 {
|
||||
key, def = key[:i], key[i+1:]
|
||||
dok = true
|
||||
}
|
||||
|
||||
if value, vok := os.LookupEnv(key); vok {
|
||||
return value
|
||||
}
|
||||
|
||||
if dok {
|
||||
return def
|
||||
}
|
||||
|
||||
return match
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
package shell
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"regexp"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func QuoteSplit(s string) []string {
|
||||
@@ -39,3 +43,34 @@ func QuoteSplit(s string) []string {
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func ReplaceEnvVars(text string) string {
|
||||
re := regexp.MustCompile(`\${([^}{]+)}`)
|
||||
return re.ReplaceAllStringFunc(text, func(match string) string {
|
||||
key := match[2 : len(match)-1]
|
||||
|
||||
var def string
|
||||
var dok bool
|
||||
|
||||
if i := strings.IndexByte(key, ':'); i > 0 {
|
||||
key, def = key[:i], key[i+1:]
|
||||
dok = true
|
||||
}
|
||||
|
||||
if value, vok := os.LookupEnv(key); vok {
|
||||
return value
|
||||
}
|
||||
|
||||
if dok {
|
||||
return def
|
||||
}
|
||||
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
||||
func RunUntilSignal() {
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||
println("exit with signal:", (<-sigs).String())
|
||||
}
|
||||
|
||||
+15
-13
@@ -46,21 +46,13 @@ func (s *Server) Serve(conn net.PacketConn) error {
|
||||
// Multiplexing RTP Data and Control Packets on a Single Port
|
||||
// https://datatracker.ietf.org/doc/html/rfc5761
|
||||
|
||||
var handle func([]byte) error
|
||||
|
||||
// this is default position for SSRC in RTP packet
|
||||
ssrc := binary.BigEndian.Uint32(buf[8:])
|
||||
session, ok := s.sessions[ssrc]
|
||||
if ok {
|
||||
if session.Write == nil {
|
||||
session.Write = func(b []byte) (int, error) {
|
||||
return conn.WriteTo(b, addr)
|
||||
}
|
||||
}
|
||||
|
||||
atomic.AddUint32(&session.Recv, uint32(n))
|
||||
|
||||
if err = session.HandleRTP(buf[:n]); err != nil {
|
||||
return err
|
||||
}
|
||||
handle = session.HandleRTP
|
||||
} else {
|
||||
// this is default position for SSRC in RTCP packet
|
||||
ssrc = binary.BigEndian.Uint32(buf[4:])
|
||||
@@ -68,9 +60,19 @@ func (s *Server) Serve(conn net.PacketConn) error {
|
||||
continue // skip unknown ssrc
|
||||
}
|
||||
|
||||
if err = session.HandleRTCP(buf[:n]); err != nil {
|
||||
return err
|
||||
handle = session.HandleRTCP
|
||||
}
|
||||
|
||||
if session.Write == nil {
|
||||
session.Write = func(b []byte) (int, error) {
|
||||
return conn.WriteTo(b, addr)
|
||||
}
|
||||
}
|
||||
|
||||
atomic.AddUint32(&session.Recv, uint32(n))
|
||||
|
||||
if err = handle(buf[:n]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-4
@@ -93,13 +93,10 @@ func (s *Session) HandleRTCP(data []byte) (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
var packets []rtcp.Packet
|
||||
if packets, err = rtcp.Unmarshal(data); err != nil {
|
||||
if _, err = rtcp.Unmarshal(data); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = packets
|
||||
|
||||
if header.Type == rtcp.TypeSenderReport {
|
||||
err = s.KeepAlive()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
cryptorand "crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
const BinaryMessage = 2
|
||||
|
||||
type Client struct {
|
||||
conn net.Conn
|
||||
remain int
|
||||
}
|
||||
|
||||
func NewClient(conn net.Conn) *Client {
|
||||
return &Client{conn: conn}
|
||||
}
|
||||
|
||||
const finalBit = 0x80
|
||||
const maskBit = 0x80
|
||||
|
||||
func (w *Client) Read(b []byte) (n int, err error) {
|
||||
if w.remain == 0 {
|
||||
b2 := make([]byte, 2)
|
||||
if _, err = io.ReadFull(w.conn, b2); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
frameType := b2[0] & 0xF
|
||||
w.remain = int(b2[1] & 0x7F)
|
||||
|
||||
switch frameType {
|
||||
case BinaryMessage:
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported frame type: %d", frameType)
|
||||
}
|
||||
|
||||
switch w.remain {
|
||||
case 126:
|
||||
if _, err = io.ReadFull(w.conn, b2); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
w.remain = int(binary.BigEndian.Uint16(b2))
|
||||
case 127:
|
||||
b8 := make([]byte, 8)
|
||||
if _, err = io.ReadFull(w.conn, b8); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
w.remain = int(binary.BigEndian.Uint64(b8))
|
||||
}
|
||||
}
|
||||
|
||||
if w.remain > len(b) {
|
||||
n, err = io.ReadFull(w.conn, b)
|
||||
w.remain -= n
|
||||
return
|
||||
}
|
||||
|
||||
n, err = io.ReadFull(w.conn, b[:w.remain])
|
||||
w.remain = 0
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (w *Client) Write(b []byte) (n int, err error) {
|
||||
var data []byte
|
||||
var start byte
|
||||
|
||||
size := len(b)
|
||||
|
||||
switch {
|
||||
case size > 65535:
|
||||
start = 10
|
||||
data = make([]byte, size+14)
|
||||
data[1] = maskBit | 127
|
||||
binary.BigEndian.PutUint64(data[2:], uint64(size))
|
||||
case size > 125:
|
||||
start = 4
|
||||
data = make([]byte, size+8)
|
||||
data[1] = maskBit | 126
|
||||
binary.BigEndian.PutUint16(data[2:], uint16(size))
|
||||
default:
|
||||
start = 2
|
||||
data = make([]byte, size+6)
|
||||
data[1] = maskBit | byte(size)
|
||||
}
|
||||
|
||||
data[0] = BinaryMessage | finalBit
|
||||
|
||||
mask := data[start : start+4]
|
||||
msg := data[start+4:]
|
||||
|
||||
if _, err = cryptorand.Read(mask); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
for i := 0; i < len(b); i++ {
|
||||
msg[i] = b[i] ^ mask[i%4]
|
||||
}
|
||||
|
||||
return w.conn.Write(data)
|
||||
}
|
||||
|
||||
func (w *Client) Close() error {
|
||||
return w.conn.Close()
|
||||
}
|
||||
|
||||
func (w *Client) LocalAddr() net.Addr {
|
||||
return w.conn.LocalAddr()
|
||||
}
|
||||
|
||||
func (w *Client) RemoteAddr() net.Addr {
|
||||
return w.conn.RemoteAddr()
|
||||
}
|
||||
|
||||
func (w *Client) SetDeadline(t time.Time) error {
|
||||
return w.conn.SetDeadline(t)
|
||||
}
|
||||
|
||||
func (w *Client) SetReadDeadline(t time.Time) error {
|
||||
return w.conn.SetReadDeadline(t)
|
||||
}
|
||||
|
||||
func (w *Client) SetWriteDeadline(t time.Time) error {
|
||||
return w.conn.SetWriteDeadline(t)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
cryptorand "crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Dial(address string) (net.Conn, error) {
|
||||
if strings.HasPrefix(address, "ws") {
|
||||
address = "http" + address[2:] // support http and https
|
||||
}
|
||||
|
||||
// using custom client for support Digest Auth
|
||||
// https://github.com/AlexxIT/go2rtc/issues/415
|
||||
ctx, pconn := tcp.WithConn()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", address, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key, accept := GetKeyAccept()
|
||||
|
||||
// Version, Key, Protocol important for Axis cameras
|
||||
req.Header.Set("Connection", "Upgrade")
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
req.Header.Set("Sec-WebSocket-Version", "13")
|
||||
req.Header.Set("Sec-WebSocket-Key", key)
|
||||
req.Header.Set("Sec-WebSocket-Protocol", "binary")
|
||||
|
||||
res, err := tcp.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusSwitchingProtocols {
|
||||
return nil, errors.New("wrong status: " + res.Status)
|
||||
}
|
||||
|
||||
if res.Header.Get("Sec-Websocket-Accept") != accept {
|
||||
return nil, errors.New("wrong websocket accept")
|
||||
}
|
||||
|
||||
return NewClient(*pconn), nil
|
||||
}
|
||||
|
||||
func GetKeyAccept() (key, accept string) {
|
||||
b := make([]byte, 16)
|
||||
_, _ = cryptorand.Read(b)
|
||||
key = base64.StdEncoding.EncodeToString(b)
|
||||
|
||||
h := sha1.New()
|
||||
h.Write([]byte(key))
|
||||
h.Write([]byte("258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))
|
||||
accept = base64.StdEncoding.EncodeToString(h.Sum(nil))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -24,6 +24,9 @@ func (c *Conn) CreateOffer(medias []*core.Media) (string, error) {
|
||||
case core.DirectionSendRecv:
|
||||
// default transceiver is sendrecv
|
||||
_, err = c.pc.AddTransceiverFromTrack(NewTrack(media.Kind))
|
||||
default:
|
||||
// Nest cameras require data channel
|
||||
_, err = c.pc.CreateDataChannel(media.Kind, nil)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -148,6 +148,17 @@ func (c *Conn) getTranseiver(mid string) *webrtc.RTPTransceiver {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Conn) getSenderTrack(mid string) *Track {
|
||||
if tr := c.getTranseiver(mid); tr != nil {
|
||||
if s := tr.Sender(); s != nil {
|
||||
if t := s.Track().(*Track); t != nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Conn) getMediaCodec(remote *webrtc.TrackRemote) (*core.Media, *core.Codec) {
|
||||
for _, tr := range c.pc.GetTransceivers() {
|
||||
// search Transeiver for this TrackRemote
|
||||
|
||||
@@ -2,6 +2,7 @@ package webrtc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h265"
|
||||
@@ -31,7 +32,11 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv
|
||||
panic(core.Caller())
|
||||
}
|
||||
|
||||
localTrack := c.getTranseiver(media.ID).Sender().Track().(*Track)
|
||||
localTrack := c.getSenderTrack(media.ID)
|
||||
if localTrack == nil {
|
||||
return errors.New("webrtc: can't get track")
|
||||
}
|
||||
|
||||
payloadType := codec.PayloadType
|
||||
|
||||
sender := core.NewSender(media, codec)
|
||||
@@ -66,6 +71,9 @@ func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiv
|
||||
codec.ClockRate = 8000
|
||||
sender.Handler = pcm.Resample(track.Codec, 8000, sender.Handler)
|
||||
}
|
||||
|
||||
// Fix audio quality https://github.com/AlexxIT/WebRTC/issues/500
|
||||
sender.Handler = pcm.RepackG711(false, sender.Handler)
|
||||
}
|
||||
|
||||
sender.HandleRTP(track)
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"github.com/pion/ice/v2"
|
||||
"github.com/pion/sdp/v3"
|
||||
"github.com/pion/webrtc/v3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCandidates(t *testing.T) {
|
||||
conf := &ice.CandidateHostConfig{
|
||||
Network: "udp",
|
||||
Address: "192.168.1.123",
|
||||
Port: 8555,
|
||||
Component: ice.ComponentRTP,
|
||||
}
|
||||
cand, err := ice.NewCandidateHost(conf)
|
||||
require.Nil(t, err)
|
||||
assert.Equal(t, "candidate:"+cand.Marshal(), CandidateManualHostUDP(conf.Address, conf.Port))
|
||||
|
||||
conf = &ice.CandidateHostConfig{
|
||||
Network: "tcp",
|
||||
Address: "192.168.1.123",
|
||||
Port: 8555,
|
||||
Component: ice.ComponentRTP,
|
||||
TCPType: ice.TCPTypePassive,
|
||||
}
|
||||
cand, err = ice.NewCandidateHost(conf)
|
||||
require.Nil(t, err)
|
||||
assert.Equal(t, "candidate:"+cand.Marshal(), CandidateManualHostTCPPassive(conf.Address, conf.Port))
|
||||
}
|
||||
|
||||
func TestPublicIP(t *testing.T) {
|
||||
ip, err := GetPublicIP()
|
||||
assert.Nil(t, err)
|
||||
assert.NotNil(t, ip)
|
||||
t.Logf("your public IP: %s", ip.String())
|
||||
}
|
||||
|
||||
func TestMedia(t *testing.T) {
|
||||
codec := webrtc.RTPCodecParameters{
|
||||
RTPCodecCapability: webrtc.RTPCodecCapability{
|
||||
MimeType: webrtc.MimeTypeH264,
|
||||
ClockRate: 90000,
|
||||
SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f",
|
||||
},
|
||||
PayloadType: 96,
|
||||
}
|
||||
|
||||
md := &sdp.MediaDescription{
|
||||
MediaName: sdp.MediaName{
|
||||
Media: "video", Protos: []string{"RTP", "AVP"},
|
||||
},
|
||||
}
|
||||
md.WithCodec(
|
||||
uint8(codec.PayloadType), codec.MimeType[6:], codec.ClockRate,
|
||||
codec.Channels, codec.SDPFmtpLine,
|
||||
)
|
||||
|
||||
sd := &sdp.SessionDescription{
|
||||
MediaDescriptions: []*sdp.MediaDescription{md},
|
||||
}
|
||||
data, err := sd.Marshal()
|
||||
assert.Nil(t, err)
|
||||
assert.NotNil(t, data)
|
||||
}
|
||||
@@ -7,6 +7,15 @@
|
||||
- `aarch64` = `arm64`
|
||||
- `armv7` = `arm`
|
||||
|
||||
## Go
|
||||
|
||||
```
|
||||
go get -u
|
||||
go mod tidy
|
||||
go mod why github.com/pion/rtcp
|
||||
go list -deps .\cmd\go2rtc_rtsp\
|
||||
```
|
||||
|
||||
## Virus
|
||||
|
||||
- https://go.dev/doc/faq#virus
|
||||
@@ -14,6 +23,8 @@
|
||||
|
||||
## Useful links
|
||||
|
||||
- https://github.com/golang-standards/project-layout
|
||||
- https://github.com/micro/micro
|
||||
- https://github.com/golang/go/wiki/GoArm
|
||||
- https://gist.github.com/asukakenji/f15ba7e588ac42795f421b48b8aede63
|
||||
- https://en.wikipedia.org/wiki/AArch64
|
||||
|
||||
@@ -36,6 +36,12 @@ go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME%
|
||||
@SET FILENAME=go2rtc_linux_arm
|
||||
go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME%
|
||||
|
||||
@SET GOOS=linux
|
||||
@SET GOARCH=arm
|
||||
@SET GOARM=6
|
||||
@SET FILENAME=go2rtc_linux_armv6
|
||||
go build -ldflags "-s -w" -trimpath -o %FILENAME% && upx %FILENAME%
|
||||
|
||||
@SET GOOS=linux
|
||||
@SET GOARCH=mipsle
|
||||
@SET FILENAME=go2rtc_linux_mipsel
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>go2rtc - API</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<redoc spec-url="https://raw.githubusercontent.com/AlexxIT/go2rtc/master/api/openapi.yaml"></redoc>
|
||||
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+13
-1
@@ -1,4 +1,14 @@
|
||||
# HTML5
|
||||
## Browser support
|
||||
|
||||
[ECMAScript 2019 (ES10)](https://caniuse.com/?search=es10) supported by [iOS 12](https://en.wikipedia.org/wiki/IOS_12) (iPhone 5S, iPad Air, iPad Mini 2, etc.).
|
||||
|
||||
But [ECMAScript 2017 (ES8)](https://caniuse.com/?search=es8) almost fine (`es6 + async`) and recommended for [React+TypeScript](https://github.com/typescript-cheatsheets/react).
|
||||
|
||||
## Known problems
|
||||
|
||||
- Autoplay doesn't work for WebRTC in Safari [read more](https://developer.apple.com/documentation/webkit/delivering_video_content_for_safari/).
|
||||
|
||||
## HTML5
|
||||
|
||||
**1. Autoplay video tag**
|
||||
|
||||
@@ -11,6 +21,8 @@
|
||||
<video id="video" autoplay controls playsinline muted></video>
|
||||
```
|
||||
|
||||
- https://developer.apple.com/documentation/webkit/delivering_video_content_for_safari/
|
||||
|
||||
**2. [Safari] pc.createOffer**
|
||||
|
||||
Don't work in Desktop Safari:
|
||||
|
||||
+104
-63
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Add Stream</title>
|
||||
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1">
|
||||
@@ -59,20 +59,20 @@
|
||||
<script src="main.js"></script>
|
||||
<script>
|
||||
async function getStreams(url, tableID) {
|
||||
const table = document.getElementById(tableID)
|
||||
table.innerText = 'loading...'
|
||||
const table = document.getElementById(tableID);
|
||||
table.innerText = 'loading...';
|
||||
|
||||
const r = typeof url === 'string' ? await fetch(url, {cache: 'no-cache'}) : url
|
||||
const r = typeof url === 'string' ? await fetch(url, {cache: 'no-cache'}) : url;
|
||||
if (!r.ok) {
|
||||
table.innerText = await r.text()
|
||||
return
|
||||
table.innerText = await r.text();
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {{streams:Array<{name:string,url:string}>}} */
|
||||
const data = await r.json()
|
||||
const data = await r.json();
|
||||
table.innerHTML = data.streams.reduce((html, item) => {
|
||||
return html + `<tr><td>${item.name}</td><td>${item.url}</td></tr>`
|
||||
}, '<thead><tr><th>Name</th><th>Source</th></tr></thead><tbody>') + '</tbody>'
|
||||
return html + `<tr><td>${item.name}</td><td>${item.url}</td></tr>`;
|
||||
}, '<thead><tr><th>Name</th><th>Source</th></tr></thead><tbody>') + '</tbody>';
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -87,19 +87,19 @@
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('stream').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'block'
|
||||
})
|
||||
ev.target.nextElementSibling.style.display = 'block';
|
||||
});
|
||||
|
||||
document.getElementById('stream-form').addEventListener('submit', async ev => {
|
||||
ev.preventDefault()
|
||||
ev.preventDefault();
|
||||
|
||||
const url = new URL('api/streams', location.href)
|
||||
url.searchParams.set('name', ev.target.elements['name'].value)
|
||||
url.searchParams.set('src', ev.target.elements['src'].value)
|
||||
const url = new URL('api/streams', location.href);
|
||||
url.searchParams.set('name', ev.target.elements['name'].value);
|
||||
url.searchParams.set('src', ev.target.elements['src'].value);
|
||||
|
||||
const r = await fetch(url, {method: 'PUT'})
|
||||
alert(r.ok ? 'OK' : 'ERROR')
|
||||
})
|
||||
const r = await fetch(url, {method: 'PUT'});
|
||||
alert(r.ok ? 'OK' : 'ERROR');
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -124,53 +124,65 @@
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('homekit').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'block'
|
||||
const r = await fetch('api/homekit', {cache: 'no-cache'})
|
||||
ev.target.nextElementSibling.style.display = 'block';
|
||||
const r = await fetch('api/homekit', {cache: 'no-cache'});
|
||||
|
||||
/** @type {Array<{id:string,name:string,addr:string,model:string,paired:boolean}>} */
|
||||
const data = await r.json()
|
||||
const data = await r.json();
|
||||
|
||||
const tbody = document.getElementById('homekit-body')
|
||||
const tbody = document.getElementById('homekit-body');
|
||||
tbody.innerHTML =
|
||||
data.reduce((res, item) => {
|
||||
let commands = ''
|
||||
if (item.id === "") {
|
||||
commands = `<a href="#" onclick="unpair('${item.name}')">unpair</a>`
|
||||
let commands = '';
|
||||
if (item.id === '') {
|
||||
commands = `<a href="#" onclick="unpair('${item.name}')">unpair</a>`;
|
||||
} else if (item.paired === false) {
|
||||
commands = `<a href="#" onclick="pair('${item.id}','${item.name}')">pair</a>`
|
||||
commands = `<a href="#" onclick="pair('${item.id}','${item.name}')">pair</a>`;
|
||||
}
|
||||
return res + `<tr>
|
||||
<td>${item.name}</td>
|
||||
<td>${item.addr}</td>
|
||||
<td>${item.model}</td>
|
||||
<td>${commands}</td>
|
||||
</tr>`
|
||||
}, '')
|
||||
})
|
||||
</tr>`;
|
||||
}, '');
|
||||
});
|
||||
|
||||
function pair(id, name) {
|
||||
const pin = document.querySelector('#pin').value
|
||||
const pin = document.querySelector('#pin').value;
|
||||
fetch(`api/homekit?id=${id}&name=${name}&pin=${pin}`, {method: 'POST'})
|
||||
.then(r => r.text())
|
||||
.then(data => {
|
||||
if (data.length > 0) alert(data)
|
||||
else window.location.reload()
|
||||
if (data.length > 0) alert(data);
|
||||
else window.location.reload();
|
||||
})
|
||||
.catch(console.error)
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
function unpair(src) {
|
||||
fetch(`api/homekit?src=${src}`, {method: 'DELETE'})
|
||||
.then(r => r.text())
|
||||
.then(data => {
|
||||
if (data.length > 0) alert(data)
|
||||
else window.location.reload()
|
||||
if (data.length > 0) alert(data);
|
||||
else window.location.reload();
|
||||
})
|
||||
.catch(console.error)
|
||||
.catch(console.error);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<button id="dvrip">DVRIP</button>
|
||||
<div class="module">
|
||||
<table id="dvrip-table"></table>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('dvrip').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'block';
|
||||
await getStreams('api/dvrip', 'dvrip-table');
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<button id="devices">FFmpeg Devices (USB)</button>
|
||||
<div class="module">
|
||||
<table id="devices-table">
|
||||
@@ -178,9 +190,9 @@
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('devices').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'block'
|
||||
await getStreams('api/ffmpeg/devices', 'devices-table')
|
||||
})
|
||||
ev.target.nextElementSibling.style.display = 'block';
|
||||
await getStreams('api/ffmpeg/devices', 'devices-table');
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -191,9 +203,38 @@
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('hardware').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'block'
|
||||
await getStreams('api/ffmpeg/hardware', 'hardware-table')
|
||||
})
|
||||
ev.target.nextElementSibling.style.display = 'block';
|
||||
await getStreams('api/ffmpeg/hardware', 'hardware-table');
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<button id="nest">Google Nest</button>
|
||||
<div class="module">
|
||||
<form id="nest-form" style="margin-bottom: 10px">
|
||||
<input type="text" name="client_id" placeholder="client_id">
|
||||
<input type="text" name="client_secret" placeholder="client_secret">
|
||||
<input type="text" name="refresh_token" placeholder="refresh_token">
|
||||
<input type="text" name="project_id" placeholder="project_id">
|
||||
<input type="submit" value="Login">
|
||||
</form>
|
||||
<table id="nest-table">
|
||||
</table>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('nest').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'block';
|
||||
});
|
||||
|
||||
document.getElementById('nest-form').addEventListener('submit', async ev => {
|
||||
ev.preventDefault();
|
||||
|
||||
const query = new URLSearchParams(new FormData(ev.target));
|
||||
const url = new URL('api/nest?' + query.toString(), location.href);
|
||||
|
||||
const r = await fetch(url, {cache: 'no-cache'});
|
||||
await getStreams(r, 'nest-table');
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -203,9 +244,9 @@
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('hass').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'block'
|
||||
await getStreams('api/hass', 'hass-table')
|
||||
})
|
||||
ev.target.nextElementSibling.style.display = 'block';
|
||||
await getStreams('api/hass', 'hass-table');
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -219,18 +260,18 @@
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('onvif').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'block'
|
||||
await getStreams('api/onvif', 'onvif-table')
|
||||
})
|
||||
ev.target.nextElementSibling.style.display = 'block';
|
||||
await getStreams('api/onvif', 'onvif-table');
|
||||
});
|
||||
|
||||
document.getElementById('onvif-form').addEventListener('submit', async ev => {
|
||||
ev.preventDefault()
|
||||
ev.preventDefault();
|
||||
|
||||
const url = new URL('api/onvif', location.href)
|
||||
url.searchParams.set('src', ev.target.elements['src'].value)
|
||||
const url = new URL('api/onvif', location.href);
|
||||
url.searchParams.set('src', ev.target.elements['src'].value);
|
||||
|
||||
await getStreams(url.toString(), 'onvif-table')
|
||||
})
|
||||
await getStreams(url.toString(), 'onvif-table');
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -246,15 +287,15 @@
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('roborock').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'block'
|
||||
await getStreams('api/roborock', 'roborock-table')
|
||||
})
|
||||
ev.target.nextElementSibling.style.display = 'block';
|
||||
await getStreams('api/roborock', 'roborock-table');
|
||||
});
|
||||
|
||||
document.getElementById('roborock-form').addEventListener('submit', async ev => {
|
||||
ev.preventDefault()
|
||||
const r = await fetch('api/roborock', {method: 'POST', body: new FormData(ev.target)})
|
||||
await getStreams(r, 'roborock-table')
|
||||
})
|
||||
ev.preventDefault();
|
||||
const r = await fetch('api/roborock', {method: 'POST', body: new FormData(ev.target)});
|
||||
await getStreams(r, 'roborock-table');
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -264,9 +305,9 @@
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('webtorrent').addEventListener('click', async ev => {
|
||||
ev.target.nextElementSibling.style.display = 'block'
|
||||
await getStreams('api/webtorrent', 'webtorrent-table')
|
||||
})
|
||||
ev.target.nextElementSibling.style.display = 'block';
|
||||
await getStreams('api/webtorrent', 'webtorrent-table');
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
+18
-14
@@ -18,21 +18,21 @@
|
||||
<body>
|
||||
<div id="out"></div>
|
||||
<script>
|
||||
const out = document.getElementById("out");
|
||||
const out = document.getElementById('out');
|
||||
|
||||
const print = (name, caps) => {
|
||||
out.innerText += name + "\n";
|
||||
out.innerText += name + '\n';
|
||||
caps.codecs.forEach((codec) => {
|
||||
out.innerText += [codec.mimeType, codec.channels, codec.clockRate, codec.sdpFmtpLine] + "\n";
|
||||
out.innerText += [codec.mimeType, codec.channels, codec.clockRate, codec.sdpFmtpLine] + '\n';
|
||||
});
|
||||
out.innerText += "\n";
|
||||
}
|
||||
out.innerText += '\n';
|
||||
};
|
||||
|
||||
if (RTCRtpReceiver.getCapabilities) {
|
||||
print("receiver video", RTCRtpReceiver.getCapabilities("video"));
|
||||
print("receiver audio", RTCRtpReceiver.getCapabilities("audio"));
|
||||
print("sender video", RTCRtpSender.getCapabilities("video"));
|
||||
print("sender audio", RTCRtpSender.getCapabilities("audio"));
|
||||
print('receiver video', RTCRtpReceiver.getCapabilities('video'));
|
||||
print('receiver audio', RTCRtpReceiver.getCapabilities('audio'));
|
||||
print('sender video', RTCRtpSender.getCapabilities('video'));
|
||||
print('sender audio', RTCRtpSender.getCapabilities('audio'));
|
||||
}
|
||||
|
||||
const types = [
|
||||
@@ -48,14 +48,18 @@
|
||||
'video/mp4; codecs="hvc1.1.6.L93.B0"',
|
||||
'video/mp4; codecs="hev1.1.6.L93.B0"',
|
||||
'video/mp4; codecs="hev1.2.4.L120.B0"',
|
||||
'video/mp4; codecs="flac"',
|
||||
'video/mp4; codecs="opus"',
|
||||
'video/mp4; codecs="mp3"',
|
||||
'video/mp4; codecs="null"',
|
||||
'application/vnd.apple.mpegurl',
|
||||
];
|
||||
|
||||
const video = document.createElement("video");
|
||||
out.innerText += "video.canPlayType\n";
|
||||
const video = document.createElement('video');
|
||||
out.innerText += 'video.canPlayType\n';
|
||||
types.forEach(type => {
|
||||
out.innerText += `${type} = ${MediaSource.isTypeSupported(type)} / ${video.canPlayType(type)}\n`;
|
||||
})
|
||||
|
||||
out.innerText += `${type} = ${'MediaSource' in window && MediaSource.isTypeSupported(type)} / ${video.canPlayType(type)}\n`;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+5
-5
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>File Editor</title>
|
||||
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1">
|
||||
@@ -32,8 +32,8 @@
|
||||
<div id="config"></div>
|
||||
<script>
|
||||
ace.config.set('basePath', 'https://cdnjs.cloudflare.com/ajax/libs/ace/1.14.0/');
|
||||
const editor = ace.edit("config", {
|
||||
mode: "ace/mode/yaml",
|
||||
const editor = ace.edit('config', {
|
||||
mode: 'ace/mode/yaml',
|
||||
});
|
||||
|
||||
document.getElementById('save').addEventListener('click', () => {
|
||||
@@ -42,7 +42,7 @@
|
||||
}).then(r => {
|
||||
if (r.ok) {
|
||||
alert('OK');
|
||||
fetch('api/exit', {method: 'POST'});
|
||||
fetch('api/exit?code=100', {method: 'POST'});
|
||||
} else {
|
||||
r.text().then(alert);
|
||||
}
|
||||
@@ -63,7 +63,7 @@
|
||||
alert(`Unknown error: ${r.statusText} (${r.status})`);
|
||||
}
|
||||
});
|
||||
})
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+31
-35
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1">
|
||||
@@ -46,10 +46,6 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 5px 5px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
padding: 5px;
|
||||
@@ -67,18 +63,18 @@
|
||||
<button>stream</button>
|
||||
<label><input type="checkbox" name="webrtc" checked>webrtc</label>
|
||||
<label><input type="checkbox" name="mse" checked>mse</label>
|
||||
<label><input type="checkbox" name="mp4" checked>mp4</label>
|
||||
<label><input type="checkbox" name="hls" checked>hls</label>
|
||||
<label><input type="checkbox" name="mjpeg" checked>mjpeg</label>
|
||||
</div>
|
||||
<table id="streams">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input id="selectall" type="checkbox">Name</th>
|
||||
<th><label><input id="selectall" type="checkbox">Name</label></th>
|
||||
<th>Online</th>
|
||||
<th>Commands</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody id="streams">
|
||||
</tbody>
|
||||
</table>
|
||||
<script>
|
||||
@@ -88,55 +84,55 @@
|
||||
'<a href="#" data-name="{name}">delete</a>',
|
||||
];
|
||||
|
||||
document.querySelector(".controls > button")
|
||||
.addEventListener("click", () => {
|
||||
const url = new URL("stream.html", location.href);
|
||||
document.querySelector('.controls > button')
|
||||
.addEventListener('click', () => {
|
||||
const url = new URL('stream.html', location.href);
|
||||
|
||||
const streams = document.querySelectorAll("#streams input");
|
||||
const streams = document.querySelectorAll('#streams input');
|
||||
streams.forEach(i => {
|
||||
if (i.checked) url.searchParams.append("src", i.name);
|
||||
if (i.checked) url.searchParams.append('src', i.name);
|
||||
});
|
||||
|
||||
if (!url.searchParams.has("src")) return;
|
||||
if (!url.searchParams.has('src')) return;
|
||||
|
||||
let mode = document.querySelectorAll(".controls input");
|
||||
mode = Array.from(mode).filter(i => i.checked).map(i => i.name).join(",");
|
||||
let mode = document.querySelectorAll('.controls input');
|
||||
mode = Array.from(mode).filter(i => i.checked).map(i => i.name).join(',');
|
||||
|
||||
window.location.href = `${url}&mode=${mode}`;
|
||||
});
|
||||
|
||||
const tbody = document.querySelector("#streams > tbody");
|
||||
tbody.addEventListener("click", ev => {
|
||||
if (ev.target.innerText !== "delete") return;
|
||||
const tbody = document.getElementById('streams');
|
||||
tbody.addEventListener('click', ev => {
|
||||
if (ev.target.innerText !== 'delete') return;
|
||||
|
||||
ev.preventDefault();
|
||||
|
||||
const url = new URL("api/streams", location.href);
|
||||
const url = new URL('api/streams', location.href);
|
||||
const src = decodeURIComponent(ev.target.dataset.name);
|
||||
url.searchParams.set("src", src);
|
||||
fetch(url, {method: "DELETE"}).then(reload);
|
||||
url.searchParams.set('src', src);
|
||||
fetch(url, {method: 'DELETE'}).then(reload);
|
||||
});
|
||||
|
||||
document.getElementById('selectall').addEventListener('change', ev => {
|
||||
document.querySelectorAll('#streams input').forEach(el => {
|
||||
el.checked = ev.target.checked
|
||||
})
|
||||
})
|
||||
el.checked = ev.target.checked;
|
||||
});
|
||||
});
|
||||
|
||||
function reload() {
|
||||
const url = new URL("api/streams", location.href);
|
||||
const url = new URL('api/streams', location.href);
|
||||
fetch(url, {cache: 'no-cache'}).then(r => r.json()).then(data => {
|
||||
tbody.innerHTML = "";
|
||||
tbody.innerHTML = '';
|
||||
|
||||
for (const [name, value] of Object.entries(data)) {
|
||||
const online = value && value.consumers ? value.consumers.length : 0;
|
||||
const src = encodeURIComponent(name);
|
||||
const links = templates.map(link => {
|
||||
return link.replace("{name}", src);
|
||||
}).join(" ");
|
||||
return link.replace('{name}', src);
|
||||
}).join(' ');
|
||||
|
||||
const tr = document.createElement("tr");
|
||||
tr.dataset["id"] = name;
|
||||
const tr = document.createElement('tr');
|
||||
tr.dataset['id'] = name;
|
||||
tr.innerHTML =
|
||||
`<td><label><input type="checkbox" name="${name}">${name}</label></td>` +
|
||||
`<td><a href="api/streams?src=${src}">${online} / info</a></td>` +
|
||||
@@ -146,13 +142,13 @@
|
||||
});
|
||||
}
|
||||
|
||||
const url = new URL("api", location.href);
|
||||
const url = new URL('api', location.href);
|
||||
fetch(url, {cache: 'no-cache'}).then(r => r.json()).then(data => {
|
||||
const info = document.querySelector(".info");
|
||||
const info = document.querySelector('.info');
|
||||
info.innerText = `Version: ${data.version}, Config: ${data.config_path}`;
|
||||
});
|
||||
|
||||
reload();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
+47
-47
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>go2rtc - links</title>
|
||||
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1">
|
||||
@@ -42,21 +42,21 @@
|
||||
<script src="main.js"></script>
|
||||
<div id="links"></div>
|
||||
<script>
|
||||
const src = new URLSearchParams(location.search).get('src')
|
||||
const src = new URLSearchParams(location.search).get('src');
|
||||
|
||||
document.getElementById('links').innerHTML = `
|
||||
<h2>Any codec in source</h2>
|
||||
<li><a href="stream.html?src=${src}">stream.html</a> with auto-select mode / browsers: all / codecs: H264, H265*, MJPEG, JPEG, AAC, PCMU, PCMA, OPUS</li>
|
||||
<li><a href="api/streams?src=${src}">info.json</a> page with active connections</li>
|
||||
`
|
||||
`;
|
||||
|
||||
const url = new URL('api', location.href)
|
||||
const url = new URL('api', location.href);
|
||||
fetch(url, {cache: 'no-cache'}).then(r => r.json()).then(data => {
|
||||
let rtsp = location.host + ':8554'
|
||||
let rtsp = location.host + ':8554';
|
||||
try {
|
||||
const host = data.host.match(/^[^:]+/)[0]
|
||||
const port = data.rtsp.listen.match(/[0-9]+$/)[0]
|
||||
rtsp = `${host}:${port}`
|
||||
const host = data.host.match(/^[^:]+/)[0];
|
||||
const port = data.rtsp.listen.match(/[0-9]+$/)[0];
|
||||
rtsp = `${host}:${port}`;
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
@@ -80,8 +80,8 @@
|
||||
<li><a href="stream.html?src=${src}&mode=mjpeg">stream.html</a> with MJPEG mode / browsers: all / codecs: MJPEG, JPEG</li>
|
||||
<li><a href="api/stream.mjpeg?src=${src}">stream.mjpeg</a> MJPEG stream / browsers: all / codecs: MJPEG, JPEG</li>
|
||||
<li><a href="api/frame.jpeg?src=${src}">frame.jpeg</a> snapshot in JPEG-format / browsers: all / codecs: MJPEG, JPEG</li>
|
||||
`
|
||||
})
|
||||
`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
@@ -92,12 +92,12 @@
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('send').addEventListener('click', ev => {
|
||||
ev.preventDefault()
|
||||
const url = new URL('api/streams', location.href)
|
||||
url.searchParams.set('dst', src)
|
||||
url.searchParams.set('src', document.getElementById('source').value)
|
||||
fetch(url, {method: 'POST'})
|
||||
})
|
||||
ev.preventDefault();
|
||||
const url = new URL('api/streams', location.href);
|
||||
url.searchParams.set('dst', src);
|
||||
url.searchParams.set('src', document.getElementById('source').value);
|
||||
fetch(url, {method: 'POST'});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div id="webrtc">
|
||||
@@ -119,62 +119,62 @@
|
||||
</div>
|
||||
<script>
|
||||
function webrtcLinksUpdate() {
|
||||
const media = document.querySelector('input[name="webrtc"]:checked').value
|
||||
const media = document.querySelector('input[name="webrtc"]:checked').value;
|
||||
|
||||
const direction = media.indexOf('video') >= 0 || media === 'audio' ? 'src' : 'dst'
|
||||
document.getElementById('local').href = `webrtc.html?${direction}=${src}&media=${media}`
|
||||
const direction = media.indexOf('video') >= 0 || media === 'audio' ? 'src' : 'dst';
|
||||
document.getElementById('local').href = `webrtc.html?${direction}=${src}&media=${media}`;
|
||||
|
||||
const share = document.getElementById('shareget')
|
||||
share.href = `https://alexxit.github.io/go2rtc/#${share.dataset.auth}&media=${media}`
|
||||
const share = document.getElementById('shareget');
|
||||
share.href = `https://alexxit.github.io/go2rtc/#${share.dataset.auth}&media=${media}`;
|
||||
}
|
||||
|
||||
function share(method) {
|
||||
const url = new URL('api/webtorrent', location.href)
|
||||
url.searchParams.set('src', src)
|
||||
return fetch(url, {method: method, cache: 'no-cache'})
|
||||
const url = new URL('api/webtorrent', location.href);
|
||||
url.searchParams.set('src', src);
|
||||
return fetch(url, {method: method, cache: 'no-cache'});
|
||||
}
|
||||
|
||||
function onshareadd(r) {
|
||||
document.getElementById('shareget').dataset['auth'] = `share=${r.share}&pwd=${r.pwd}`
|
||||
document.getElementById('shareget').dataset['auth'] = `share=${r.share}&pwd=${r.pwd}`;
|
||||
|
||||
document.getElementById('shareadd').style.display = 'none'
|
||||
document.getElementById('shareget').style.display = ''
|
||||
document.getElementById('sharedel').style.display = ''
|
||||
document.getElementById('shareadd').style.display = 'none';
|
||||
document.getElementById('shareget').style.display = '';
|
||||
document.getElementById('sharedel').style.display = '';
|
||||
|
||||
webrtcLinksUpdate()
|
||||
webrtcLinksUpdate();
|
||||
}
|
||||
|
||||
function onsharedel() {
|
||||
document.getElementById('shareadd').style.display = ''
|
||||
document.getElementById('shareget').style.display = 'none'
|
||||
document.getElementById('sharedel').style.display = 'none'
|
||||
document.getElementById('shareadd').style.display = '';
|
||||
document.getElementById('shareget').style.display = 'none';
|
||||
document.getElementById('sharedel').style.display = 'none';
|
||||
}
|
||||
|
||||
document.getElementById('shareadd').addEventListener('click', ev => {
|
||||
ev.preventDefault()
|
||||
share('POST').then(r => r.json()).then(r => onshareadd(r))
|
||||
})
|
||||
ev.preventDefault();
|
||||
share('POST').then(r => r.json()).then(r => onshareadd(r));
|
||||
});
|
||||
|
||||
document.getElementById('shareget').addEventListener('click', ev => {
|
||||
ev.preventDefault()
|
||||
navigator.clipboard.writeText(ev.target.href)
|
||||
})
|
||||
ev.preventDefault();
|
||||
navigator.clipboard.writeText(ev.target.href);
|
||||
});
|
||||
|
||||
document.getElementById('sharedel').addEventListener('click', ev => {
|
||||
ev.preventDefault()
|
||||
share('DELETE').then(r => onsharedel())
|
||||
})
|
||||
ev.preventDefault();
|
||||
share('DELETE').then(() => onsharedel());
|
||||
});
|
||||
|
||||
document.getElementById('webrtc').addEventListener('click', ev => {
|
||||
if (ev.target.tagName === 'INPUT') webrtcLinksUpdate()
|
||||
})
|
||||
if (ev.target.tagName === 'INPUT') webrtcLinksUpdate();
|
||||
});
|
||||
|
||||
share('GET').then(r => {
|
||||
if (r.ok) r.json().then(r => onshareadd(r))
|
||||
else onsharedel()
|
||||
})
|
||||
if (r.ok) r.json().then(r => onshareadd(r));
|
||||
else onsharedel();
|
||||
});
|
||||
|
||||
webrtcLinksUpdate()
|
||||
webrtcLinksUpdate();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
+8
-8
@@ -30,9 +30,9 @@
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
||||
// support multiple streams and multiple modes
|
||||
const streams = params.getAll("src");
|
||||
const modes = params.getAll("mode");
|
||||
if (modes.length === 0) modes.push("");
|
||||
const streams = params.getAll('src');
|
||||
const modes = params.getAll('mode');
|
||||
if (modes.length === 0) modes.push('');
|
||||
|
||||
while (modes.length > streams.length) {
|
||||
streams.push(streams[0]);
|
||||
@@ -42,19 +42,19 @@
|
||||
}
|
||||
|
||||
if (streams.length > 1) {
|
||||
document.body.className = "flex";
|
||||
document.body.className = 'flex';
|
||||
}
|
||||
|
||||
const background = params.get("background") !== "false";
|
||||
const width = "1 0 " + (params.get("width") || "320px");
|
||||
const background = params.get('background') !== 'false';
|
||||
const width = '1 0 ' + (params.get('width') || '320px');
|
||||
|
||||
for (let i = 0; i < streams.length; i++) {
|
||||
/** @type {VideoStream} */
|
||||
const video = document.createElement("video-stream");
|
||||
const video = document.createElement('video-stream');
|
||||
video.background = background;
|
||||
video.mode = modes[i] || video.mode;
|
||||
video.style.flex = width;
|
||||
video.src = new URL("api/ws?src=" + encodeURIComponent(streams[i]), location.href);
|
||||
video.src = new URL('api/ws?src=' + encodeURIComponent(streams[i]), location.href);
|
||||
document.body.appendChild(video);
|
||||
}
|
||||
</script>
|
||||
|
||||
+121
-97
@@ -1,17 +1,17 @@
|
||||
/**
|
||||
* Video player for go2rtc streaming application.
|
||||
* VideoRTC v1.6.0 - Video player for go2rtc streaming application.
|
||||
*
|
||||
* All modern web technologies are supported in almost any browser except Apple Safari.
|
||||
*
|
||||
* Support:
|
||||
* - ECMAScript 2017 (ES8) = ES6 + async
|
||||
* - RTCPeerConnection for Safari iOS 11.0+
|
||||
* - IntersectionObserver for Safari iOS 12.2+
|
||||
*
|
||||
* Doesn't support:
|
||||
* - MediaSource for Safari iOS all
|
||||
* - Customized built-in elements (extends HTMLVideoElement) because all Safari
|
||||
* - Public class fields because old Safari (before 14.0)
|
||||
* - Autoplay for Safari
|
||||
* - MediaSource for Safari iOS
|
||||
* - Customized built-in elements (extends HTMLVideoElement) because Safari
|
||||
* - Autoplay for WebRTC in Safari
|
||||
*/
|
||||
export class VideoRTC extends HTMLElement {
|
||||
constructor() {
|
||||
@@ -21,21 +21,22 @@ export class VideoRTC extends HTMLElement {
|
||||
this.RECONNECT_TIMEOUT = 30000;
|
||||
|
||||
this.CODECS = [
|
||||
"avc1.640029", // H.264 high 4.1 (Chromecast 1st and 2nd Gen)
|
||||
"avc1.64002A", // H.264 high 4.2 (Chromecast 3rd Gen)
|
||||
"avc1.640033", // H.264 high 5.1 (Chromecast with Google TV)
|
||||
"hvc1.1.6.L153.B0", // H.265 main 5.1 (Chromecast Ultra)
|
||||
"mp4a.40.2", // AAC LC
|
||||
"mp4a.40.5", // AAC HE
|
||||
"flac", // FLAC (PCM compatible)
|
||||
"opus", // OPUS Chrome, Firefox
|
||||
'avc1.640029', // H.264 high 4.1 (Chromecast 1st and 2nd Gen)
|
||||
'avc1.64002A', // H.264 high 4.2 (Chromecast 3rd Gen)
|
||||
'avc1.640033', // H.264 high 5.1 (Chromecast with Google TV)
|
||||
'hvc1.1.6.L153.B0', // H.265 main 5.1 (Chromecast Ultra)
|
||||
'mp4a.40.2', // AAC LC
|
||||
'mp4a.40.5', // AAC HE
|
||||
'null', // for detecting liars (Safari iOS 12)
|
||||
'flac', // FLAC (PCM compatible)
|
||||
'opus', // OPUS Chrome, Firefox
|
||||
];
|
||||
|
||||
/**
|
||||
* [config] Supported modes (webrtc, mse, mp4, mjpeg).
|
||||
* [config] Supported modes (webrtc, webrtc/tcp, mse, hls, mp4, mjpeg).
|
||||
* @type {string}
|
||||
*/
|
||||
this.mode = "webrtc,mse,mp4,mjpeg";
|
||||
this.mode = 'webrtc,mse,hls,mjpeg';
|
||||
|
||||
/**
|
||||
* [config] Run stream when not displayed on the screen. Default `false`.
|
||||
@@ -92,7 +93,7 @@ export class VideoRTC extends HTMLElement {
|
||||
/**
|
||||
* @type {string|URL}
|
||||
*/
|
||||
this.wsURL = "";
|
||||
this.wsURL = '';
|
||||
|
||||
/**
|
||||
* @type {RTCPeerConnection}
|
||||
@@ -107,7 +108,7 @@ export class VideoRTC extends HTMLElement {
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
this.mseCodecs = "";
|
||||
this.mseCodecs = '';
|
||||
|
||||
/**
|
||||
* [internal] Disconnect TimeoutID.
|
||||
@@ -139,11 +140,11 @@ export class VideoRTC extends HTMLElement {
|
||||
* @param {string|URL} value
|
||||
*/
|
||||
set src(value) {
|
||||
if (typeof value !== "string") value = value.toString();
|
||||
if (value.startsWith("http")) {
|
||||
value = "ws" + value.substring(4);
|
||||
} else if (value.startsWith("/")) {
|
||||
value = "ws" + location.origin.substring(4) + value;
|
||||
if (typeof value !== 'string') value = value.toString();
|
||||
if (value.startsWith('http')) {
|
||||
value = 'ws' + value.substring(4);
|
||||
} else if (value.startsWith('/')) {
|
||||
value = 'ws' + location.origin.substring(4) + value;
|
||||
}
|
||||
|
||||
this.wsURL = value;
|
||||
@@ -156,10 +157,12 @@ export class VideoRTC extends HTMLElement {
|
||||
* https://developer.chrome.com/blog/autoplay/
|
||||
*/
|
||||
play() {
|
||||
this.video.play().catch(er => {
|
||||
if (er.name === "NotAllowedError" && !this.video.muted) {
|
||||
this.video.play().catch(() => {
|
||||
if (!this.video.muted) {
|
||||
this.video.muted = true;
|
||||
this.video.play().catch(() => console.debug);
|
||||
this.video.play().catch(er => {
|
||||
console.warn(er);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -173,7 +176,7 @@ export class VideoRTC extends HTMLElement {
|
||||
}
|
||||
|
||||
codecs(type) {
|
||||
const test = type === "mse"
|
||||
const test = type === 'mse'
|
||||
? codec => MediaSource.isTypeSupported(`video/mp4; codecs="${codec}"`)
|
||||
: codec => this.video.canPlayType(`video/mp4; codecs="${codec}"`);
|
||||
return this.CODECS.filter(test).join();
|
||||
@@ -227,30 +230,30 @@ export class VideoRTC extends HTMLElement {
|
||||
* Creates child DOM elements. Called automatically once on `connectedCallback`.
|
||||
*/
|
||||
oninit() {
|
||||
this.video = document.createElement("video");
|
||||
this.video = document.createElement('video');
|
||||
this.video.controls = true;
|
||||
this.video.playsInline = true;
|
||||
this.video.preload = "auto";
|
||||
this.video.preload = 'auto';
|
||||
|
||||
this.video.style.display = "block"; // fix bottom margin 4px
|
||||
this.video.style.width = "100%";
|
||||
this.video.style.height = "100%"
|
||||
this.video.style.display = 'block'; // fix bottom margin 4px
|
||||
this.video.style.width = '100%';
|
||||
this.video.style.height = '100%';
|
||||
|
||||
this.appendChild(this.video);
|
||||
|
||||
if (this.background) return;
|
||||
|
||||
if ("hidden" in document && this.visibilityCheck) {
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if ('hidden' in document && this.visibilityCheck) {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
this.disconnectedCallback();
|
||||
} else if (this.isConnected) {
|
||||
this.connectedCallback();
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if ("IntersectionObserver" in window && this.visibilityThreshold) {
|
||||
if ('IntersectionObserver' in window && this.visibilityThreshold) {
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
entries.forEach(entry => {
|
||||
if (!entry.isIntersecting) {
|
||||
@@ -277,9 +280,9 @@ export class VideoRTC extends HTMLElement {
|
||||
this.connectTS = Date.now();
|
||||
|
||||
this.ws = new WebSocket(this.wsURL);
|
||||
this.ws.binaryType = "arraybuffer";
|
||||
this.ws.addEventListener("open", ev => this.onopen(ev));
|
||||
this.ws.addEventListener("close", ev => this.onclose(ev));
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
this.ws.addEventListener('open', () => this.onopen());
|
||||
this.ws.addEventListener('close', () => this.onclose());
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -296,6 +299,9 @@ export class VideoRTC extends HTMLElement {
|
||||
this.pc.close();
|
||||
this.pc = null;
|
||||
}
|
||||
|
||||
this.video.src = '';
|
||||
this.video.srcObject = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -305,8 +311,8 @@ export class VideoRTC extends HTMLElement {
|
||||
// CONNECTING => OPEN
|
||||
this.wsState = WebSocket.OPEN;
|
||||
|
||||
this.ws.addEventListener("message", ev => {
|
||||
if (typeof ev.data === "string") {
|
||||
this.ws.addEventListener('message', ev => {
|
||||
if (typeof ev.data === 'string') {
|
||||
const msg = JSON.parse(ev.data);
|
||||
for (const mode in this.onmessage) {
|
||||
this.onmessage[mode](msg);
|
||||
@@ -321,27 +327,30 @@ export class VideoRTC extends HTMLElement {
|
||||
|
||||
const modes = [];
|
||||
|
||||
if (this.mode.indexOf("mse") >= 0 && "MediaSource" in window) { // iPhone
|
||||
modes.push("mse");
|
||||
if (this.mode.indexOf('mse') >= 0 && 'MediaSource' in window) { // iPhone
|
||||
modes.push('mse');
|
||||
this.onmse();
|
||||
} else if (this.mode.indexOf("mp4") >= 0) {
|
||||
modes.push("mp4");
|
||||
} else if (this.mode.indexOf('hls') >= 0 && this.video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
modes.push('hls');
|
||||
this.onhls();
|
||||
} else if (this.mode.indexOf('mp4') >= 0) {
|
||||
modes.push('mp4');
|
||||
this.onmp4();
|
||||
}
|
||||
|
||||
if (this.mode.indexOf("webrtc") >= 0 && "RTCPeerConnection" in window) { // macOS Desktop app
|
||||
modes.push("webrtc");
|
||||
if (this.mode.indexOf('webrtc') >= 0 && 'RTCPeerConnection' in window) { // macOS Desktop app
|
||||
modes.push('webrtc');
|
||||
this.onwebrtc();
|
||||
}
|
||||
|
||||
if (this.mode.indexOf("mjpeg") >= 0) {
|
||||
if (this.mode.indexOf('mjpeg') >= 0) {
|
||||
if (modes.length) {
|
||||
this.onmessage["mjpeg"] = msg => {
|
||||
if (msg.type !== "error" || msg.value.indexOf(modes[0]) !== 0) return;
|
||||
this.onmessage['mjpeg'] = msg => {
|
||||
if (msg.type !== 'error' || msg.value.indexOf(modes[0]) !== 0) return;
|
||||
this.onmjpeg();
|
||||
}
|
||||
};
|
||||
} else {
|
||||
modes.push("mjpeg");
|
||||
modes.push('mjpeg');
|
||||
this.onmjpeg();
|
||||
}
|
||||
}
|
||||
@@ -372,25 +381,25 @@ export class VideoRTC extends HTMLElement {
|
||||
|
||||
onmse() {
|
||||
const ms = new MediaSource();
|
||||
ms.addEventListener("sourceopen", () => {
|
||||
ms.addEventListener('sourceopen', () => {
|
||||
URL.revokeObjectURL(this.video.src);
|
||||
this.send({type: "mse", value: this.codecs("mse")});
|
||||
this.send({type: 'mse', value: this.codecs('mse')});
|
||||
}, {once: true});
|
||||
|
||||
this.video.src = URL.createObjectURL(ms);
|
||||
this.video.srcObject = null;
|
||||
this.play();
|
||||
|
||||
this.mseCodecs = "";
|
||||
this.mseCodecs = '';
|
||||
|
||||
this.onmessage["mse"] = msg => {
|
||||
if (msg.type !== "mse") return;
|
||||
this.onmessage['mse'] = msg => {
|
||||
if (msg.type !== 'mse') return;
|
||||
|
||||
this.mseCodecs = msg.value;
|
||||
|
||||
const sb = ms.addSourceBuffer(msg.value);
|
||||
sb.mode = "segments"; // segments or sequence
|
||||
sb.addEventListener("updateend", () => {
|
||||
sb.mode = 'segments'; // segments or sequence
|
||||
sb.addEventListener('updateend', () => {
|
||||
if (sb.updating) return;
|
||||
|
||||
try {
|
||||
@@ -428,23 +437,25 @@ export class VideoRTC extends HTMLElement {
|
||||
// console.debug(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
onwebrtc() {
|
||||
const pc = new RTCPeerConnection(this.pcConfig);
|
||||
|
||||
/** @type {HTMLVideoElement} */
|
||||
const video2 = document.createElement("video");
|
||||
video2.addEventListener("loadeddata", ev => this.onpcvideo(ev), {once: true});
|
||||
const video2 = document.createElement('video');
|
||||
video2.addEventListener('loadeddata', ev => this.onpcvideo(ev), {once: true});
|
||||
|
||||
pc.addEventListener("icecandidate", ev => {
|
||||
const candidate = ev.candidate ? ev.candidate.toJSON().candidate : "";
|
||||
this.send({type: "webrtc/candidate", value: candidate});
|
||||
pc.addEventListener('icecandidate', ev => {
|
||||
if (ev.candidate && this.mode.indexOf('webrtc/tcp') >= 0 && ev.candidate.protocol === 'udp') return;
|
||||
|
||||
const candidate = ev.candidate ? ev.candidate.toJSON().candidate : '';
|
||||
this.send({type: 'webrtc/candidate', value: candidate});
|
||||
});
|
||||
|
||||
pc.addEventListener("track", ev => {
|
||||
pc.addEventListener('track', ev => {
|
||||
// when stream already init
|
||||
if (video2.srcObject !== null) return;
|
||||
|
||||
@@ -457,8 +468,8 @@ export class VideoRTC extends HTMLElement {
|
||||
video2.srcObject = ev.streams[0];
|
||||
});
|
||||
|
||||
pc.addEventListener("connectionstatechange", () => {
|
||||
if (pc.connectionState === "failed" || pc.connectionState === "disconnected") {
|
||||
pc.addEventListener('connectionstatechange', () => {
|
||||
if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
|
||||
pc.close(); // stop next events
|
||||
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
@@ -468,33 +479,33 @@ export class VideoRTC extends HTMLElement {
|
||||
}
|
||||
});
|
||||
|
||||
this.onmessage["webrtc"] = msg => {
|
||||
this.onmessage['webrtc'] = msg => {
|
||||
switch (msg.type) {
|
||||
case "webrtc/candidate":
|
||||
pc.addIceCandidate({
|
||||
candidate: msg.value,
|
||||
sdpMid: "0"
|
||||
}).catch(() => console.debug);
|
||||
case 'webrtc/candidate':
|
||||
if (this.mode.indexOf('webrtc/tcp') >= 0 && msg.value.indexOf(' udp ') > 0) return;
|
||||
|
||||
pc.addIceCandidate({candidate: msg.value, sdpMid: '0'}).catch(er => {
|
||||
console.warn(er);
|
||||
});
|
||||
break;
|
||||
case "webrtc/answer":
|
||||
pc.setRemoteDescription({
|
||||
type: "answer",
|
||||
sdp: msg.value
|
||||
}).catch(() => console.debug);
|
||||
case 'webrtc/answer':
|
||||
pc.setRemoteDescription({type: 'answer', sdp: msg.value}).catch(er => {
|
||||
console.warn(er);
|
||||
});
|
||||
break;
|
||||
case "error":
|
||||
if (msg.value.indexOf("webrtc/offer") < 0) return;
|
||||
case 'error':
|
||||
if (msg.value.indexOf('webrtc/offer') < 0) return;
|
||||
pc.close();
|
||||
}
|
||||
};
|
||||
|
||||
// Safari doesn't support "offerToReceiveVideo"
|
||||
pc.addTransceiver("video", {direction: "recvonly"});
|
||||
pc.addTransceiver("audio", {direction: "recvonly"});
|
||||
pc.addTransceiver('video', {direction: 'recvonly'});
|
||||
pc.addTransceiver('audio', {direction: 'recvonly'});
|
||||
|
||||
pc.createOffer().then(offer => {
|
||||
pc.setLocalDescription(offer).then(() => {
|
||||
this.send({type: "webrtc/offer", value: offer.sdp});
|
||||
this.send({type: 'webrtc/offer', value: offer.sdp});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -513,7 +524,7 @@ export class VideoRTC extends HTMLElement {
|
||||
const state = this.pc.connectionState;
|
||||
|
||||
// Firefox doesn't support pc.connectionState
|
||||
if (state === "connected" || state === "connecting" || !state) {
|
||||
if (state === 'connected' || state === 'connecting' || !state) {
|
||||
// Video+Audio > Video, H265 > H264, Video > Audio, WebRTC > MSE
|
||||
let rtcPriority = 0, msePriority = 0;
|
||||
|
||||
@@ -522,9 +533,9 @@ export class VideoRTC extends HTMLElement {
|
||||
if (ms.getVideoTracks().length > 0) rtcPriority += 0x220;
|
||||
if (ms.getAudioTracks().length > 0) rtcPriority += 0x102;
|
||||
|
||||
if (this.mseCodecs.indexOf("hvc1.") >= 0) msePriority += 0x230;
|
||||
if (this.mseCodecs.indexOf("avc1.") >= 0) msePriority += 0x210;
|
||||
if (this.mseCodecs.indexOf("mp4a.") >= 0) msePriority += 0x101;
|
||||
if (this.mseCodecs.indexOf('hvc1.') >= 0) msePriority += 0x230;
|
||||
if (this.mseCodecs.indexOf('avc1.') >= 0) msePriority += 0x210;
|
||||
if (this.mseCodecs.indexOf('mp4a.') >= 0) msePriority += 0x101;
|
||||
|
||||
if (rtcPriority >= msePriority) {
|
||||
this.video.srcObject = ms;
|
||||
@@ -548,25 +559,38 @@ export class VideoRTC extends HTMLElement {
|
||||
onmjpeg() {
|
||||
this.ondata = data => {
|
||||
this.video.controls = false;
|
||||
this.video.poster = "data:image/jpeg;base64," + VideoRTC.btoa(data);
|
||||
this.video.poster = 'data:image/jpeg;base64,' + VideoRTC.btoa(data);
|
||||
};
|
||||
|
||||
this.send({type: "mjpeg"});
|
||||
this.send({type: 'mjpeg'});
|
||||
}
|
||||
|
||||
onhls() {
|
||||
this.onmessage['hls'] = msg => {
|
||||
if (msg.type !== 'hls') return;
|
||||
|
||||
const url = 'http' + this.wsURL.substring(2, this.wsURL.indexOf('/ws')) + '/hls/';
|
||||
const playlist = msg.value.replace('hls/', url);
|
||||
this.video.src = 'data:application/vnd.apple.mpegurl;base64,' + btoa(playlist);
|
||||
this.play();
|
||||
};
|
||||
|
||||
this.send({type: 'hls', value: this.codecs('hls')});
|
||||
}
|
||||
|
||||
onmp4() {
|
||||
/** @type {HTMLCanvasElement} **/
|
||||
const canvas = document.createElement("canvas");
|
||||
const canvas = document.createElement('canvas');
|
||||
/** @type {CanvasRenderingContext2D} */
|
||||
let context;
|
||||
|
||||
/** @type {HTMLVideoElement} */
|
||||
const video2 = document.createElement("video");
|
||||
const video2 = document.createElement('video');
|
||||
video2.autoplay = true;
|
||||
video2.playsInline = true;
|
||||
video2.muted = true;
|
||||
|
||||
video2.addEventListener("loadeddata", ev => {
|
||||
video2.addEventListener('loadeddata', () => {
|
||||
if (!context) {
|
||||
canvas.width = video2.videoWidth;
|
||||
canvas.height = video2.videoHeight;
|
||||
@@ -576,20 +600,20 @@ export class VideoRTC extends HTMLElement {
|
||||
context.drawImage(video2, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
this.video.controls = false;
|
||||
this.video.poster = canvas.toDataURL("image/jpeg");
|
||||
this.video.poster = canvas.toDataURL('image/jpeg');
|
||||
});
|
||||
|
||||
this.ondata = data => {
|
||||
video2.src = "data:video/mp4;base64," + VideoRTC.btoa(data);
|
||||
video2.src = 'data:video/mp4;base64,' + VideoRTC.btoa(data);
|
||||
};
|
||||
|
||||
this.send({type: "mp4", value: this.codecs("mp4")});
|
||||
this.send({type: 'mp4', value: this.codecs('mp4')});
|
||||
}
|
||||
|
||||
static btoa(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const len = bytes.byteLength;
|
||||
let binary = "";
|
||||
let binary = '';
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
|
||||
+25
-24
@@ -1,23 +1,23 @@
|
||||
import {VideoRTC} from "./video-rtc.js";
|
||||
import {VideoRTC} from './video-rtc.js';
|
||||
|
||||
class VideoStream extends VideoRTC {
|
||||
set divMode(value) {
|
||||
this.querySelector(".mode").innerText = value;
|
||||
this.querySelector(".status").innerText = "";
|
||||
this.querySelector('.mode').innerText = value;
|
||||
this.querySelector('.status').innerText = '';
|
||||
}
|
||||
|
||||
set divError(value) {
|
||||
const state = this.querySelector(".mode").innerText;
|
||||
if (state !== "loading") return;
|
||||
this.querySelector(".mode").innerText = "error";
|
||||
this.querySelector(".status").innerText = value;
|
||||
const state = this.querySelector('.mode').innerText;
|
||||
if (state !== 'loading') return;
|
||||
this.querySelector('.mode').innerText = 'error';
|
||||
this.querySelector('.status').innerText = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom GUI
|
||||
*/
|
||||
oninit() {
|
||||
console.debug("stream.oninit");
|
||||
console.debug('stream.oninit');
|
||||
super.oninit();
|
||||
|
||||
this.innerHTML = `
|
||||
@@ -43,56 +43,57 @@ class VideoStream extends VideoRTC {
|
||||
</div>
|
||||
`;
|
||||
|
||||
const info = this.querySelector(".info")
|
||||
const info = this.querySelector('.info');
|
||||
this.insertBefore(this.video, info);
|
||||
}
|
||||
|
||||
onconnect() {
|
||||
console.debug("stream.onconnect");
|
||||
console.debug('stream.onconnect');
|
||||
const result = super.onconnect();
|
||||
if (result) this.divMode = "loading";
|
||||
if (result) this.divMode = 'loading';
|
||||
return result;
|
||||
}
|
||||
|
||||
ondisconnect() {
|
||||
console.debug("stream.ondisconnect");
|
||||
console.debug('stream.ondisconnect');
|
||||
super.ondisconnect();
|
||||
}
|
||||
|
||||
onopen() {
|
||||
console.debug("stream.onopen");
|
||||
console.debug('stream.onopen');
|
||||
const result = super.onopen();
|
||||
|
||||
this.onmessage["stream"] = msg => {
|
||||
console.debug("stream.onmessge", msg);
|
||||
this.onmessage['stream'] = msg => {
|
||||
console.debug('stream.onmessge', msg);
|
||||
switch (msg.type) {
|
||||
case "error":
|
||||
case 'error':
|
||||
this.divError = msg.value;
|
||||
break;
|
||||
case "mse":
|
||||
case "mp4":
|
||||
case "mjpeg":
|
||||
case 'mse':
|
||||
case 'hls':
|
||||
case 'mp4':
|
||||
case 'mjpeg':
|
||||
this.divMode = msg.type.toUpperCase();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
onclose() {
|
||||
console.debug("stream.onclose");
|
||||
console.debug('stream.onclose');
|
||||
return super.onclose();
|
||||
}
|
||||
|
||||
onpcvideo(ev) {
|
||||
console.debug("stream.onpcvideo");
|
||||
console.debug('stream.onpcvideo');
|
||||
super.onpcvideo(ev);
|
||||
|
||||
if (this.pcState !== WebSocket.CLOSED) {
|
||||
this.divMode = "RTC";
|
||||
this.divMode = 'RTC';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("video-stream", VideoStream);
|
||||
customElements.define('video-stream', VideoStream);
|
||||
|
||||
+17
-17
@@ -22,45 +22,45 @@
|
||||
async function PeerConnection(media) {
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [{urls: 'stun:stun.l.google.com:19302'}]
|
||||
})
|
||||
});
|
||||
|
||||
document.getElementById('video').srcObject = new MediaStream([
|
||||
pc.addTransceiver('audio', {direction: 'sendrecv'}).receiver.track,
|
||||
pc.addTransceiver('video', {direction: 'sendrecv'}).receiver.track,
|
||||
])
|
||||
]);
|
||||
|
||||
const tracks = await navigator.mediaDevices.getUserMedia({
|
||||
video: media.indexOf('camera') >= 0,
|
||||
audio: media.indexOf('microphone') >= 0,
|
||||
})
|
||||
});
|
||||
tracks.getTracks().forEach(track => {
|
||||
pc.addTrack(track)
|
||||
})
|
||||
pc.addTrack(track);
|
||||
});
|
||||
|
||||
return pc
|
||||
return pc;
|
||||
}
|
||||
|
||||
function getCompleteOffer(pc, timeout) {
|
||||
return new Promise((resolve, reject) => {
|
||||
pc.addEventListener('icegatheringstatechange', () => {
|
||||
if (pc.iceGatheringState === 'complete') resolve(pc.localDescription.sdp)
|
||||
})
|
||||
if (pc.iceGatheringState === 'complete') resolve(pc.localDescription.sdp);
|
||||
});
|
||||
|
||||
pc.createOffer().then(offer => pc.setLocalDescription(offer))
|
||||
pc.createOffer().then(offer => pc.setLocalDescription(offer));
|
||||
|
||||
setTimeout(() => resolve(pc.localDescription.sdp), timeout || 3000)
|
||||
})
|
||||
setTimeout(() => resolve(pc.localDescription.sdp), timeout || 3000);
|
||||
});
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
const media = new URLSearchParams(location.search).get('media')
|
||||
const pc = await PeerConnection(media)
|
||||
const url = new URL('api/webrtc' + location.search, location.href)
|
||||
const r = await fetch(url, {method: 'POST', body: await getCompleteOffer(pc)})
|
||||
await pc.setRemoteDescription({type: 'answer', sdp: await r.text()})
|
||||
const media = new URLSearchParams(location.search).get('media');
|
||||
const pc = await PeerConnection(media);
|
||||
const url = new URL('api/webrtc' + location.search, location.href);
|
||||
const r = await fetch(url, {method: 'POST', body: await getCompleteOffer(pc)});
|
||||
await pc.setRemoteDescription({type: 'answer', sdp: await r.text()});
|
||||
}
|
||||
|
||||
connect()
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+35
-35
@@ -22,86 +22,86 @@
|
||||
async function PeerConnection(media) {
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [{urls: 'stun:stun.l.google.com:19302'}]
|
||||
})
|
||||
});
|
||||
|
||||
const localTracks = []
|
||||
const localTracks = [];
|
||||
|
||||
if (/camera|microphone/.test(media)) {
|
||||
const tracks = await getMediaTracks('user', {
|
||||
video: media.indexOf('camera') >= 0,
|
||||
audio: media.indexOf('microphone') >= 0,
|
||||
})
|
||||
});
|
||||
tracks.forEach(track => {
|
||||
pc.addTransceiver(track, {direction: 'sendonly'})
|
||||
if (track.kind === 'video') localTracks.push(track)
|
||||
})
|
||||
pc.addTransceiver(track, {direction: 'sendonly'});
|
||||
if (track.kind === 'video') localTracks.push(track);
|
||||
});
|
||||
}
|
||||
|
||||
if (media.indexOf('display') >= 0) {
|
||||
const tracks = await getMediaTracks('display', {
|
||||
video: true,
|
||||
audio: media.indexOf('speaker') >= 0,
|
||||
})
|
||||
});
|
||||
tracks.forEach(track => {
|
||||
pc.addTransceiver(track, {direction: 'sendonly'})
|
||||
if (track.kind === 'video') localTracks.push(track)
|
||||
})
|
||||
pc.addTransceiver(track, {direction: 'sendonly'});
|
||||
if (track.kind === 'video') localTracks.push(track);
|
||||
});
|
||||
}
|
||||
|
||||
if (/video|audio/.test(media)) {
|
||||
const tracks = ['video', 'audio']
|
||||
.filter(kind => media.indexOf(kind) >= 0)
|
||||
.map(kind => pc.addTransceiver(kind, {direction: 'recvonly'}).receiver.track)
|
||||
localTracks.push(...tracks)
|
||||
.map(kind => pc.addTransceiver(kind, {direction: 'recvonly'}).receiver.track);
|
||||
localTracks.push(...tracks);
|
||||
}
|
||||
|
||||
document.getElementById('video').srcObject = new MediaStream(localTracks)
|
||||
document.getElementById('video').srcObject = new MediaStream(localTracks);
|
||||
|
||||
return pc
|
||||
return pc;
|
||||
}
|
||||
|
||||
async function getMediaTracks(media, constraints) {
|
||||
try {
|
||||
const stream = media === 'user'
|
||||
? await navigator.mediaDevices.getUserMedia(constraints)
|
||||
: await navigator.mediaDevices.getDisplayMedia(constraints)
|
||||
return stream.getTracks()
|
||||
: await navigator.mediaDevices.getDisplayMedia(constraints);
|
||||
return stream.getTracks();
|
||||
} catch (e) {
|
||||
console.warn(e)
|
||||
return []
|
||||
console.warn(e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(media) {
|
||||
const pc = await PeerConnection(media)
|
||||
const url = new URL('api/ws' + location.search, location.href)
|
||||
const ws = new WebSocket('ws' + url.toString().substring(4))
|
||||
const pc = await PeerConnection(media);
|
||||
const url = new URL('api/ws' + location.search, location.href);
|
||||
const ws = new WebSocket('ws' + url.toString().substring(4));
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
pc.addEventListener('icecandidate', ev => {
|
||||
if (!ev.candidate) return
|
||||
const msg = {type: 'webrtc/candidate', value: ev.candidate.candidate}
|
||||
ws.send(JSON.stringify(msg))
|
||||
})
|
||||
if (!ev.candidate) return;
|
||||
const msg = {type: 'webrtc/candidate', value: ev.candidate.candidate};
|
||||
ws.send(JSON.stringify(msg));
|
||||
});
|
||||
|
||||
pc.createOffer().then(offer => pc.setLocalDescription(offer)).then(() => {
|
||||
const msg = {type: 'webrtc/offer', value: pc.localDescription.sdp}
|
||||
ws.send(JSON.stringify(msg))
|
||||
})
|
||||
})
|
||||
const msg = {type: 'webrtc/offer', value: pc.localDescription.sdp};
|
||||
ws.send(JSON.stringify(msg));
|
||||
});
|
||||
});
|
||||
|
||||
ws.addEventListener('message', ev => {
|
||||
const msg = JSON.parse(ev.data)
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === 'webrtc/candidate') {
|
||||
pc.addIceCandidate({candidate: msg.value, sdpMid: '0'})
|
||||
pc.addIceCandidate({candidate: msg.value, sdpMid: '0'});
|
||||
} else if (msg.type === 'webrtc/answer') {
|
||||
pc.setRemoteDescription({type: 'answer', sdp: msg.value})
|
||||
pc.setRemoteDescription({type: 'answer', sdp: msg.value});
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
const media = new URLSearchParams(location.search).get('media')
|
||||
connect(media || 'video+audio')
|
||||
const media = new URLSearchParams(location.search).get('media');
|
||||
connect(media || 'video+audio');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user