Compare commits

..

141 Commits

Author SHA1 Message Date
Alex X
a4885c2c3a Update version to 1.9.4 2024-06-18 21:33:36 +03:00
Alex X
f5aaee006e Merge pull request #1168 from skrashevich/fix-flags-daemon
fix(app): Refactor daemon initialization and add syscall import
2024-06-18 20:53:38 +03:00
Alex X
db6745e8ff Code refactoring after #1168 2024-06-18 20:35:17 +03:00
Alex X
ba34855602 Merge pull request #1196 from skrashevich/feat-network-dot-enhancements
refactor(webui): enhance network visualization in network.html
2024-06-16 22:24:11 +03:00
Alex X
e6fa97c738 Code refactoring after #1196 2024-06-16 22:12:52 +03:00
Sergey Krashevich
5b481a27c6 fix(network): enable autoResize in network settings 2024-06-16 21:57:48 +03:00
Alex X
bdc7ff1035 Fix forwarded remote_addr in the network 2024-06-16 19:04:34 +03:00
Alex X
da5f060741 Add killsignal and killtimeout to exec/rtsp 2024-06-16 19:03:57 +03:00
Alex X
a56d335380 Fix homekit producer remote_addr 2024-06-16 15:26:18 +03:00
Sergey Krashevich
d8aed552bc fix(network): ensure consistent node positions by storing and reusing seed 2024-06-16 15:22:33 +03:00
Alex X
d7286fa06e Merge pull request #1195 from skrashevich/fix-append-dot
fix(streams): handle missing codec_name in appendDOT function
2024-06-16 15:20:51 +03:00
Alex X
906f554d74 Code refactoring after #1195 2024-06-16 15:19:50 +03:00
Sergey Krashevich
cb44d5431a feat(network): preserve pan and scale on data reload 2024-06-16 15:01:40 +03:00
Sergey Krashevich
a69eb8a66e style(network): add flex-grow to network div and move script tag 2024-06-16 14:54:02 +03:00
Sergey Krashevich
1b411b1fed refactor(streams): optimize label generation with strings.Builder
feat(network): add periodic data fetching and network update
2024-06-16 10:19:17 +03:00
Sergey Krashevich
5d57959608 fix(streams): handle missing codec_name in appendDOT function 2024-06-16 08:59:06 +03:00
Alex X
31e57c2ff8 Fix errors output for webrtc client and server 2024-06-16 06:37:42 +03:00
Alex X
734393d638 Add streaming network visualisation 2024-06-16 06:36:24 +03:00
Alex X
96504e2fb0 BIG rewrite stream info 2024-06-16 06:20:45 +03:00
Alex X
ecfe802065 Code refactoring for streams HandleFunc 2024-06-14 12:52:55 +03:00
Alex X
1ac9d54dab Code refactoring for stream MarshalJSON 2024-06-10 16:42:34 +03:00
Sergey Krashevich
72d7e8aaaa refactor(app): remove syscall import and improve error messages 2024-06-08 15:05:26 +03:00
Alex X
0395696866 Fix exec pipe output 2024-06-07 17:59:21 +03:00
Alex X
0667683e4d Restore support old cipher suites after go1.22 #1172 2024-06-07 17:57:36 +03:00
Alex X
aca0781c4b Code refactoring for api/streams 2024-06-07 12:25:58 +03:00
Sergey Krashevich
b389d0eb9c fix(app): handle daemon process correctly on Unix systems 2024-06-06 18:54:40 +03:00
Alex X
bf303ed471 Fix -d flag 2024-06-06 17:58:31 +03:00
Alex X
cd777ba2b4 Update version to 1.9.3 2024-06-06 16:01:01 +03:00
Alex X
e3188a0a6d Update docs about config 2024-06-06 15:21:32 +03:00
Alex X
2bab0a014d Update dependencies 2024-06-06 14:34:16 +03:00
Alex X
a01da18018 Merge pull request #1150 from skrashevich/go122
update Go version to 1.22
2024-06-06 14:25:27 +03:00
Alex X
9d5a5c1e45 Merge remote-tracking branch 'origin/master' 2024-06-06 14:15:20 +03:00
Alex X
8377ad1d05 Update codec section in stream info 2024-06-06 13:16:12 +03:00
Alex X
ec33796bd3 Add goweight to useful commands 2024-06-05 20:02:10 +03:00
Alex X
31e4ba2722 Rewrite Receiver/Sender classes 2024-06-05 20:01:47 +03:00
Alex X
e0b1a50356 Add rtsp_client for testing ghost exec process 2024-06-05 20:00:41 +03:00
Alex X
9bb36ebb6c Fix ghost exec/ffmpeg process 2024-06-05 19:59:22 +03:00
Alex X
756be9801e Code refactoring for app module 2024-06-02 07:00:29 +03:00
Alex X
bd73b07ed8 Merge pull request #1147 from skrashevich/docker-ghcr-repo
ci(workflow): add GitHub Container Registry
2024-05-31 07:22:47 +03:00
Sergey Krashevich
df1d44d24e chore(deps): update Go version to 1.22 across project files 2024-05-30 17:12:56 +03:00
Sergey Krashevich
79245eeff4 fix(ci): skip GitHub Container Registry login on pull requests 2024-05-30 11:48:15 +03:00
Sergey Krashevich
aa86c1ec25 ci(workflow): add GitHub Container Registry login and update image paths 2024-05-30 11:28:06 +03:00
Alex X
2ab1d9d774 Add handling if mp4 client drops connection 2024-05-29 17:32:18 +03:00
Alex X
a9e7a73cc8 Add video bitrate setting for HomeKit source 2024-05-28 22:57:43 +03:00
Alex X
ea17b420d6 Fix two-way audio for webrtc client 2024-05-28 21:36:12 +03:00
Alex X
660979dfda Merge pull request #1141 from skrashevich/feat-log-terminal-check
feat(logging): add interactive shell detection for console output
2024-05-28 13:26:14 +03:00
Alex X
a6b9b4993f Code refactoring after #1141 2024-05-28 13:21:33 +03:00
Sergey Krashevich
cc74504ed8 feat(shell): add Windows support for TTY detection 2024-05-28 10:19:51 +03:00
Sergey Krashevich
791239be12 Merge branch 'master' into feat-log-terminal-check 2024-05-28 09:15:01 +03:00
Sergey Krashevich
a79061c7c2 feat(logging): add interactive shell detection for console output 2024-05-28 09:10:51 +03:00
Alex X
50ad3b20c4 Add config schema.json 2024-05-28 09:08:57 +03:00
Alex X
649de0131c Change logs timestamp format in WebUI 2024-05-27 20:25:09 +03:00
Alex X
8cb513cb89 Add log level for ffmpeg module 2024-05-27 20:24:24 +03:00
Alex X
3932dbaa84 Add print exec stderr to logs for debug level 2024-05-27 20:23:55 +03:00
Alex X
4534b4d8ca Add more log customization options 2024-05-26 21:28:34 +03:00
Alex X
8e571a66e3 Code refactoring for debug packet logger 2024-05-26 00:19:26 +03:00
Alex X
0ccfcb0ec0 Fix timestamps for RTMP client 2024-05-26 00:18:56 +03:00
Alex X
8bae4631d2 Fix support some RTSP servers 2024-05-26 00:18:36 +03:00
Alex X
268629f551 Fix pix_fmt for publishing to RTMP servers 2024-05-25 19:45:29 +03:00
Alex X
0bd2fcde54 Update color index func for ascii stream 2024-05-25 13:52:55 +03:00
Alex X
6f34cf0c95 Add streaming to rawvideo format 2024-05-25 11:55:28 +03:00
Alex X
f8bc25d0ae Add support rawvideo format 2024-05-25 08:22:38 +03:00
Alex X
8749562c96 Fix detection webrtc without audio #1106 2024-05-24 20:41:46 +03:00
Alex X
d9d2bdff44 Add timeout query param to RTSP incoming source #1118 2024-05-24 16:26:06 +03:00
Alex X
b3e9ed23ac Add /api/ffmpeg for playing files and tts on cameras with two-way audio 2024-05-24 15:57:18 +03:00
Alex X
bf3f81ccac Update ffmpeg pkg for reading files and parsing ffmpeg version 2024-05-24 11:06:51 +03:00
Alex X
ff39e2e496 Change import log for hass module from debug to trace 2024-05-24 11:04:26 +03:00
Alex X
d2346a2aed Fix FFmpeg producer codecs 2024-05-24 07:48:44 +03:00
Alex X
8f57b1acb6 Fix TTS template 2024-05-24 07:48:17 +03:00
Alex X
6fafd10482 Add stream source validation for dynamic streams 2024-05-23 17:40:27 +03:00
Alex X
c726651b8b Add ffmpeg version checker 2024-05-23 17:31:02 +03:00
Alex X
02af2e2849 Code refactoring for FFmpeg producer 2024-05-23 12:40:29 +03:00
Alex X
6d9c7012b0 Add output/aac for ffmpeg source 2024-05-23 12:24:41 +03:00
Alex X
8a7712a4c8 Add ffmpeg auto codec selection logic 2024-05-22 18:49:43 +03:00
Alex X
82fa803a37 Add ffmpeg virtual tests 2024-05-22 18:48:40 +03:00
Alex X
78a74da8d6 Fix aac.DecodeConfig sampleRate parsing 2024-05-22 18:46:30 +03:00
Alex X
53242ea02f Add ffmpeg tts source 2024-05-22 13:00:39 +03:00
Alex X
af05083a1f Code refactoring for ffmpeg device and virtual 2024-05-22 12:58:21 +03:00
Alex X
c41bddbbea Add using wav format for ffmpeg transcoding to PCMA/PCMU 2024-05-21 17:50:15 +03:00
Alex X
54c8ca0112 Add wav format to magic producer 2024-05-21 17:48:31 +03:00
Alex X
a518488289 Add debug logs for run RTSP pipe 2024-05-21 17:46:43 +03:00
Alex X
99cc21aacb Code refactoring for magic producer 2024-05-20 14:24:04 +03:00
Alex X
bc8295baee Improve play audio on RTSP backchannel 2024-05-19 11:56:33 +03:00
Alex X
50f9913c41 Add hls.html 2024-05-19 10:33:11 +03:00
Alex X
4c135b5a46 Add binaries to gitignore 2024-05-19 07:25:50 +03:00
Alex X
686fb374e9 Remove PCMU for two way for DVRIP source #1111 2024-05-18 17:14:55 +03:00
Alex X
2b3e6a2730 Merge pull request #1122 from isegals/master
Update client.go
2024-05-18 17:09:02 +03:00
Alex X
9143729042 Merge pull request #1123 from skrashevich/fix-vcs-tags-in-docker-builds
add git to build stage
2024-05-18 16:20:42 +03:00
Sergey Krashevich
3952f0ba0f add git to build stage 2024-05-18 13:47:02 +03:00
isegals
7a131822db Update client.go
Add  "AudioFormat":{"EncodeType":"G711_ALAW"} to suppoet new firmware
2024-05-18 11:14:16 +03:00
Alex X
b2399f3bb3 Update version to 1.9.2 2024-05-17 15:57:11 +03:00
Alex X
2a8a3f1cbf Merge pull request #1113 from skrashevich/ui-move-probe-link
Refactor probe link placement in UI
2024-05-17 15:52:52 +03:00
Alex X
b1ba5bab62 Update readme for ASCII 2024-05-17 14:51:55 +03:00
Alex X
6878f05e57 Fix ESC codes duplicates for ASCII stream 2024-05-17 14:34:24 +03:00
Alex X
d428a8964a Fix writers for MJPEG and ASCII 2024-05-17 14:32:59 +03:00
Alex X
f432e72dd0 Add support custom color for ascii streaming 2024-05-16 22:02:18 +03:00
Alex X
2929db9cec Fix w/h variables for ascii streaming 2024-05-16 22:01:57 +03:00
Alex X
6d967bc1f9 Improve ascii stream for any one symbol 2024-05-16 17:33:09 +03:00
Alex X
83c0053b2c Fix blinking for ASCII stream 2024-05-16 15:28:47 +03:00
Alex X
ecfd7404f5 Add UTF8 support for ASCII streaming 2024-05-16 14:01:43 +03:00
Alex X
41badbfb8e Add support streaming as ascii to terminal 2024-05-16 12:00:41 +03:00
Sergey Krashevich
0cb013a7fd Refactor probe link placement in UI
Moved the 'probe' link from the global templates array to individual
stream status columns for improved clarity and accessibility. This
change enhances the interface by contextualizing the 'probe' option,
making it directly accessible alongside each stream's online status and
info link, thereby streamlining the user experience and emphasizing the
function's importance on a per-stream basis. This adjustment follows
usability feedback indicating that users prefer immediate access to
stream diagnostics.
2024-05-15 12:41:30 +03:00
Alex X
75020d4df7 Add probe link to WebUI 2024-05-15 10:36:29 +03:00
Alex X
69c288b154 Fix codec name for probe producer 2024-05-15 10:31:43 +03:00
Alex X
0ea651db62 Fix links in the manifest.json 2024-05-15 10:23:25 +03:00
Alex X
4823e60a92 Add probe stream API #998 2024-05-15 07:44:18 +03:00
Alex X
c4949eb81f Add example about rpi5 cam to readme #1041 2024-05-15 05:36:28 +03:00
Alex X
aa4c81c266 Add pix_fmt to H265 transcoding string 2024-05-14 21:21:27 +03:00
Alex X
063fef5813 Add auto reconnect for broken MSE stream 2024-05-14 21:20:47 +03:00
Alex X
d9fb734c85 Fix stop pending producer on multiple mode requests 2024-05-14 19:31:42 +03:00
Alex X
a51156cf18 Add instant start for WebRTC consumer 2024-05-14 17:34:47 +03:00
Alex X
32e0ee4a10 Merge pull request #1071 from skrashevich/refactr-syscall-more-generic
refactor(sysctl): consolidate platform-specific syscall files
2024-05-13 19:00:10 +03:00
Alex X
e6bea97936 Merge pull request #1099 from skrashevich/add-favicon
feat(web): Add favicon
2024-05-13 18:23:58 +03:00
Alex X
9776e09ca7 Code refactoring after #1099 2024-05-13 18:22:35 +03:00
Alex X
ad273d3a98 Merge pull request #1098 from skrashevich/ci-docs-docker-tags-and-readme
ci+docs: docker images
2024-05-13 15:03:10 +03:00
Alex X
69c301e79f Remove docker examples from readme (move to dockerhub) 2024-05-13 15:02:39 +03:00
Alex X
8f2bb3f34b Add support key=value pair for cli config 2024-05-13 14:14:28 +03:00
Alex X
e4ff6d224f Update logo.gif 2024-05-13 13:29:44 +03:00
Alex X
00751459a2 Merge pull request #1107 from skrashevich/version-display-enhance
feat(version): Enhancements to Version Display Functionality
2024-05-13 12:45:22 +03:00
Alex X
874c07b887 Code refactoring for #1107 2024-05-13 12:42:55 +03:00
Alex X
152df3ef5d Fix pkt_size key name in json format 2024-05-13 07:18:48 +03:00
Alex X
c950bb0252 Update api.ws log messages 2024-05-13 07:00:51 +03:00
Sergey Krashevich
dd7ea2657a feat(app): enhance CLI with shorthand flags and dynamic versioning
- Added shorthand flag support for `config`, `daemon`, and `version`
- Implemented dynamic version string generation using build info
- Updated flag usage output to include shorthand options and help command
2024-05-12 22:10:58 +03:00
Alex X
5889791847 Merge pull request #1100 from skrashevich/upd-ace-1-33-1
upd(editor): upgrade Ace editor to version 1.33.1
2024-05-12 18:40:37 +03:00
Alex X
9160403b99 Fix device_id and device_private for HomeKit config 2024-05-12 15:59:50 +03:00
Alex X
5ccbd7c1c2 Rename param source to video for ffmpeg virtual source 2024-05-12 15:58:17 +03:00
Alex X
778245dd1c Set default max-bundle for video-rtc.js viewer 2024-05-12 15:56:56 +03:00
Alex X
205018c96a Improve WebRTC candidates handling 2024-05-12 15:55:32 +03:00
Sergey Krashevich
eaba451a47 refactor(app): streamline version info retrieval and formatting 2024-05-12 06:46:45 +03:00
Sergey Krashevich
b7c11db604 feat(version): enhance version command output with VCS revision and timestamp 2024-05-12 06:36:25 +03:00
Alex X
f7b98044e6 Fix Kasa KC200 cameras 2024-05-10 22:50:33 +03:00
Sergey Krashevich
1b1bdb37db feat(branding): prepend 'go2rtc -' to page titles in add, editor, and log pages 2024-05-09 12:06:46 +03:00
Sergey Krashevich
ab453d275e feat(editor): upgrade Ace editor to version 1.33.1 2024-05-09 11:30:26 +03:00
Alex X
ee387b79e1 Update version output 2024-05-09 08:21:19 +03:00
Sergey Krashevich
e71ed5e7eb feat(icons): add favicon and apple-touch-icon links across all pages
Added favicon, apple-touch-icon, and related meta tags to all HTML pages to ensure consistent branding and improve user experience on various platforms.
2024-05-09 06:30:14 +03:00
Sergey Krashevich
122a550599 feat(icons): add website icons and update GH Pages workflow to include icon changes 2024-05-09 06:25:33 +03:00
Sergey Krashevich
f3f08afac8 ci(build.yml): enable latest tag and onlatest for hardware suffix
This commit updates the GitHub Actions workflow to ensure that images built with a hardware suffix are tagged as 'latest'. Additionally, it modifies the README.md to enhance the documentation around the Docker container deployment, including basic and GPU-accelerated deployment instructions.
2024-05-09 06:07:50 +03:00
Alex X
a0030194cb Add gif logo 2024-05-08 13:04:59 +03:00
Sergey Krashevich
f158ffb33e Merge remote-tracking branch 'upstream/master' into refactr-syscall-more-generic 2024-05-07 14:45:48 +03:00
Sergey Krashevich
abe617a346 refactor(ffmpeg): generalize device and hardware support for multiple OS
- Rename `device_freebsd.go` to `device_bsd.go` and `hardware_freebsd.go` to `hardware_bsd.go` to reflect broader BSD support (FreeBSD, NetBSD, OpenBSD, Dragonfly).
- Update build tags in `device_bsd.go` and `hardware_bsd.go` to include FreeBSD, NetBSD, OpenBSD, and Dragonfly.
- Rename `device_linux.go` to `device_unix.go` and `hardware_linux.go` to `hardware_unix.go` to generalize Unix support excluding Darwin-based systems and BSDs.
- Add specific build tags to `device_darwin.go`, `device_unix.go`, `hardware_darwin.go`, and `hardware_unix.go` to correctly target their respective operating systems.
- Ensure Windows-specific files (`device_windows.go` and `hardware_windows.go`) are correctly tagged for building on Windows.
2024-05-01 09:04:19 +03:00
Sergey Krashevich
e080eac204 refactor(mdns): consolidate platform-specific syscall files
- Rename `syscall_linux.go` to `syscall.go` with build constraints for non-BSD and non-Windows platforms.
- Merge `syscall_darwin.go` into `syscall_bsd.go` and adjust build constraints for BSD platforms (Darwin, FreeBSD, OpenBSD).
- Remove redundant `syscall_freebsd.go`.
- Add build constraints to `syscall_windows.go` for Windows platform.
2024-04-30 01:55:51 +03:00
193 changed files with 5022 additions and 1824 deletions

View File

@@ -19,7 +19,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with: { go-version: '1.21' }
with: { go-version: '1.22' }
- name: Build go2rtc_win64
env: { GOOS: windows, GOARCH: amd64 }
@@ -123,7 +123,9 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ github.repository }}
images: |
${{ github.repository }}
ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}},enable=false
@@ -142,6 +144,14 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
@@ -168,10 +178,12 @@ jobs:
id: meta-hw
uses: docker/metadata-action@v5
with:
images: ${{ github.repository }}
images: |
${{ github.repository }}
ghcr.io/${{ github.repository }}
flavor: |
suffix=-hardware
latest=false
suffix=-hardware,onlatest=true
latest=auto
tags: |
type=ref,event=branch
type=semver,pattern={{version}},enable=false
@@ -189,6 +201,14 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5

View File

@@ -26,7 +26,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.21'
go-version: '1.22'
- name: Build Go binary
run: go build -ldflags "-s -w" -trimpath -o ./go2rtc

4
.gitignore vendored
View File

@@ -4,6 +4,10 @@
go2rtc.yaml
go2rtc.json
go2rtc_linux*
go2rtc_mac*
go2rtc_win*
0_test.go
.DS_Store

View File

@@ -2,7 +2,7 @@
# 0. Prepare images
ARG PYTHON_VERSION="3.11"
ARG GO_VERSION="1.21"
ARG GO_VERSION="1.22"
ARG NGROK_VERSION="3"
FROM python:${PYTHON_VERSION}-alpine AS base
@@ -20,6 +20,8 @@ ENV GOARCH=${TARGETARCH}
WORKDIR /build
RUN apk add git
# Cache dependencies
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/root/.cache/go-build go mod download

View File

@@ -1,6 +1,6 @@
<h1 align="center">
![go2rtc](assets/logo.png)
![go2rtc](assets/logo.gif)
<br>
[![stars](https://img.shields.io/github/stars/AlexxIT/go2rtc?style=flat-square&logo=github)](https://github.com/AlexxIT/go2rtc/stargazers)
[![docker pulls](https://img.shields.io/docker/pulls/alexxit/go2rtc?style=flat-square&logo=docker&logoColor=white&label=pulls)](https://hub.docker.com/r/alexxit/go2rtc)
@@ -131,7 +131,7 @@ Don't forget to fix the rights `chmod +x go2rtc_xxx_xxx` on Linux and Mac.
### go2rtc: Docker
Container [alexxit/go2rtc](https://hub.docker.com/r/alexxit/go2rtc) with support `amd64`, `386`, `arm64`, `arm`. This container is the same as [Home Assistant Add-on](#go2rtc-home-assistant-add-on), but can be used separately from Home Assistant. Container has preinstalled [FFmpeg](#source-ffmpeg), [ngrok](#module-ngrok) and [Python](#source-echo).
The Docker container [`alexxit/go2rtc`](https://hub.docker.com/r/alexxit/go2rtc) supports multiple architectures including `amd64`, `386`, `arm64`, and `arm`. This container offers the same functionality as the [Home Assistant Add-on](#go2rtc-home-assistant-add-on) but is designed to operate independently of Home Assistant. It comes preinstalled with [FFmpeg](#source-ffmpeg), [ngrok](#module-ngrok), and [Python](#source-echo).
### go2rtc: Home Assistant Add-on
@@ -429,6 +429,7 @@ streams:
stream: exec:ffmpeg -re -i /media/BigBuckBunny.mp4 -c copy -rtsp_transport tcp -f rtsp {output}
picam_h264: exec:libcamera-vid -t 0 --inline -o -
picam_mjpeg: exec:libcamera-vid -t 0 --codec mjpeg -o -
pi5cam_h264: exec:libcamera-vid -t 0 --libav-format h264 -o -
canon: exec:gphoto2 --capture-movie --stdout#killsignal=2#killtimeout=5
play_pcma: exec:ffplay -fflags nobuffer -f alaw -ar 8000 -i -#backchannel=1
play_pcm48k: exec:ffplay -fflags nobuffer -f s16be -ar 48000 -i -#backchannel=1
@@ -552,11 +553,16 @@ echo -n "cloud password" | shasum -a 256 | awk '{print toupper($0)}'
[TP-Link Kasa](https://www.kasasmart.com/) non-standard protocol [more info](https://medium.com/@hu3vjeen/reverse-engineering-tp-link-kc100-bac4641bf1cd).
- `username` - urlsafe email, `alex@gmail.com` -> `alex%40gmail.com`
- `password` - base64password, `secret1` -> `c2VjcmV0MQ==`
```yaml
streams:
kasa: kasa://user:pass@192.168.1.123:19443/https/stream/mixed
kc401: kasa://username:password@192.168.1.123:19443/https/stream/mixed
```
Tested: KD110, KC200, KC401, KC420WS, EC71.
#### Source: GoPro
*[New in v1.8.3](https://github.com/AlexxIT/go2rtc/releases/tag/v1.8.3)*
@@ -773,7 +779,7 @@ POST http://localhost:1984/api/streams?dst=camera1&src=ffmpeg:http://example.com
You can publish any stream to streaming services (YouTube, Telegram, etc.) via RTMP/RTMPS. Important:
- Supported codecs: H264 for video and AAC for audio
- Pixel format should be `yuv420p`, for cameras with `yuvj420p` format you SHOULD use [transcoding](#source-ffmpeg)
- AAC audio is required for YouTube, videos without audio will not work
- You don't need to enable [RTMP module](#module-rtmp) listening for this task
You can use API:
@@ -786,16 +792,19 @@ Or config file:
```yaml
publish:
# publish stream "tplink_tapo" to Telegram
tplink_tapo: rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx
# publish stream "other_camera" to Telegram and YouTube
other_camera:
# publish stream "video_audio_transcode" to Telegram
video_audio_transcode:
- rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx
- rtmps://xxx.rtmp.youtube.com/live2/xxxx-xxxx-xxxx-xxxx-xxxx
# publish stream "audio_transcode" to Telegram and YouTube
audio_transcode:
- rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx
- rtmp://xxx.rtmp.youtube.com/live2/xxxx-xxxx-xxxx-xxxx-xxxx
streams:
# for TP-Link cameras it's important to use transcoding because of wrong pixel format
tplink_tapo: ffmpeg:rtsp://user:pass@192.168.1.123/stream1#video=h264#hardware#audio=aac
video_audio_transcode:
- ffmpeg:rtsp://user:pass@192.168.1.123/stream1#video=h264#hardware#audio=aac
audio_transcode:
- ffmpeg:rtsp://user:pass@192.168.1.123/stream1#video=copy#audio=aac
```
- **Telegram Desktop App** > Any public or private channel or group (where you admin) > Live stream > Start with... > Start streaming.
@@ -1187,6 +1196,10 @@ API examples:
- You can use `rotate` param with `90`, `180`, `270` or `-90` values
- You can use `hardware`/`hw` param [read more](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration)
**PS.** This module also supports streaming to the server console (terminal) in the **animated ASCII art** format ([read more](https://github.com/AlexxIT/go2rtc/blob/master/internal/mjpeg/README.md)):
[![](https://img.youtube.com/vi/sHj_3h_sX7M/mqdefault.jpg)](https://www.youtube.com/watch?v=sHj_3h_sX7M)
### Module: Log
You can set different log levels for different modules.

BIN
assets/logo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

View File

@@ -0,0 +1,39 @@
package main
import (
"log"
"os"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/rtsp"
"github.com/AlexxIT/go2rtc/pkg/shell"
)
func main() {
client := rtsp.NewClient(os.Args[1])
if err := client.Dial(); err != nil {
log.Panic(err)
}
client.Medias = []*core.Media{
{
Kind: core.KindAudio,
Direction: core.DirectionRecvonly,
Codecs: []*core.Codec{
{Name: core.CodecPCMU, ClockRate: 8000},
},
ID: "streamid=0",
},
}
if err := client.Announce(); err != nil {
log.Panic(err)
}
if _, err := client.SetupMedia(client.Medias[0]); err != nil {
log.Panic(err)
}
if err := client.Record(); err != nil {
log.Panic(err)
}
shell.RunUntilSignal()
}

18
go.mod
View File

@@ -1,11 +1,12 @@
module github.com/AlexxIT/go2rtc
go 1.21
go 1.22
require (
github.com/asticode/go-astits v1.13.0
github.com/expr-lang/expr v1.16.5
github.com/expr-lang/expr v1.16.9
github.com/gorilla/websocket v1.5.1
github.com/mattn/go-isatty v0.0.20
github.com/miekg/dns v1.1.59
github.com/pion/ice/v2 v2.3.24
github.com/pion/interceptor v0.1.29
@@ -15,12 +16,12 @@ require (
github.com/pion/srtp/v2 v2.0.18
github.com/pion/stun v0.6.1
github.com/pion/webrtc/v3 v3.2.40
github.com/rs/zerolog v1.32.0
github.com/rs/zerolog v1.33.0
github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f
github.com/stretchr/testify v1.9.0
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9
golang.org/x/crypto v0.23.0
golang.org/x/crypto v0.24.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -30,7 +31,6 @@ require (
github.com/google/uuid v1.6.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.20 // indirect
github.com/pion/datachannel v1.5.6 // indirect
github.com/pion/dtls/v2 v2.2.11 // indirect
github.com/pion/logging v0.2.2 // indirect
@@ -40,9 +40,9 @@ require (
github.com/pion/transport/v2 v2.2.5 // indirect
github.com/pion/turn/v2 v2.1.6 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/mod v0.18.0 // indirect
golang.org/x/net v0.26.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/tools v0.20.0 // indirect
golang.org/x/sys v0.21.0 // indirect
golang.org/x/tools v0.22.0 // indirect
)

14
go.sum
View File

@@ -8,6 +8,8 @@ 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=
github.com/expr-lang/expr v1.16.5 h1:m2hvtguFeVaVNTHj8L7BoAyt7O0PAIBaSVbjdHgRXMs=
github.com/expr-lang/expr v1.16.5/go.mod h1:uCkhfG+x7fcZ5A5sXHKuQ07jGZRl6J0FCAaf2k4PtVQ=
github.com/expr-lang/expr v1.16.9 h1:WUAzmR0JNI9JCiF0/ewwHB1gmcGw5wW7nWt8gc6PpCI=
github.com/expr-lang/expr v1.16.9/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -85,6 +87,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0=
github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 h1:NVK+OqnavpyFmUiKfUMHrpvbCi2VFoWTrcpI7aDaJ2I=
github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA=
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f h1:1R9KdKjCNSd7F8iGTxIpoID9prlYH8nuNYKt0XvweHA=
@@ -115,10 +119,14 @@ golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -134,6 +142,8 @@ golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/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/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -160,6 +170,8 @@ golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -184,6 +196,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY=
golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg=
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=

View File

@@ -4,7 +4,7 @@
# only debian 13 (trixie) has latest ffmpeg
# https://packages.debian.org/trixie/ffmpeg
ARG DEBIAN_VERSION="trixie-slim"
ARG GO_VERSION="1.21-bookworm"
ARG GO_VERSION="1.22-bookworm"
ARG NGROK_VERSION="3"
FROM debian:${DEBIAN_VERSION} AS base

View File

@@ -83,7 +83,7 @@ func initWS(origin string) {
if o.Host == r.Host {
return true
}
log.Trace().Msgf("[api.ws] origin=%s, host=%s", o.Host, r.Host)
log.Trace().Msgf("[api] ws origin=%s, host=%s", o.Host, r.Host)
// https://github.com/AlexxIT/go2rtc/issues/118
if i := strings.IndexByte(o.Host, ':'); i > 0 {
return o.Host[:i] == r.Host
@@ -127,7 +127,7 @@ func apiWS(w http.ResponseWriter, r *http.Request) {
break
}
log.Trace().Str("type", msg.Type).Msg("[api.ws] msg")
log.Trace().Str("type", msg.Type).Msg("[api] ws msg")
if handler := wsHandlers[msg.Type]; handler != nil {
go func() {

70
internal/app/README.md Normal file
View File

@@ -0,0 +1,70 @@
- By default go2rtc will search config file `go2rtc.yaml` in current work directory
- go2rtc support multiple config files:
- `go2rtc -c config1.yaml -c config2.yaml -c config3.yaml`
- go2rtc support inline config as multiple formats from command line:
- **YAML**: `go2rtc -c '{log: {format: text}}'`
- **JSON**: `go2rtc -c '{"log":{"format":"text"}}'`
- **key=value**: `go2rtc -c log.format=text`
- Every next config will overwrite previous (but only defined params)
```
go2rtc -config "{log: {format: text}}" -config /config/go2rtc.yaml -config "{rtsp: {listen: ''}}" -config /usr/local/go2rtc/go2rtc.yaml
```
or simple version
```
go2rtc -c log.format=text -c /config/go2rtc.yaml -c rtsp.listen='' -c /usr/local/go2rtc/go2rtc.yaml
```
## Environment variables
Also go2rtc support templates for using environment variables in any part of config:
```yaml
streams:
camera1: rtsp://rtsp:${CAMERA_PASSWORD}@192.168.1.123/av_stream/ch0
rtsp:
username: ${RTSP_USER:admin} # "admin" if env "RTSP_USER" not set
password: ${RTSP_PASS:secret} # "secret" if env "RTSP_PASS" not set
```
## JSON Schema
Editors like [GoLand](https://www.jetbrains.com/go/) and [VS Code](https://code.visualstudio.com/) supports autocomplete and syntax validation.
```yaml
# yaml-language-server: $schema=https://raw.githubusercontent.com/AlexxIT/go2rtc/master/website/schema.json
```
## Defaults
- Default values may change in updates
- FFmpeg module has many presets, they are not listed here because they may also change in updates
```yaml
api:
listen: ":1984"
ffmpeg:
bin: "ffmpeg"
log:
format: "color"
level: "info"
output: "stdout"
time: "UNIXMS"
rtsp:
listen: ":8554"
default_query: "video&audio"
srtp:
listen: ":8443"
webrtc:
listen: ":8555/tcp"
ice_servers:
- urls: [ "stun:stun.l.google.com:19302" ]
```

View File

@@ -1,144 +1,101 @@
package app
import (
"errors"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/AlexxIT/go2rtc/pkg/shell"
"github.com/AlexxIT/go2rtc/pkg/yaml"
"github.com/rs/zerolog/log"
"runtime/debug"
)
var Version = "1.9.1"
var UserAgent = "go2rtc/" + Version
var (
Version string
UserAgent string
ConfigPath string
Info = make(map[string]any)
)
var ConfigPath string
var Info = map[string]any{
"version": Version,
}
const usage = `Usage of go2rtc:
-c, --config Path to config file or config string as YAML or JSON, support multiple
-d, --daemon Run in background
-v, --version Print version and exit
`
func Init() {
var confs Config
var config flagConfig
var daemon bool
var version bool
flag.Var(&confs, "config", "go2rtc config (path to file or raw text), support multiple")
if runtime.GOOS != "windows" {
flag.BoolVar(&daemon, "daemon", false, "Run program in background")
}
flag.BoolVar(&version, "version", false, "Print the version of the application and exit")
flag.Var(&config, "config", "")
flag.Var(&config, "c", "")
flag.BoolVar(&daemon, "daemon", false, "")
flag.BoolVar(&daemon, "d", false, "")
flag.BoolVar(&version, "version", false, "")
flag.BoolVar(&version, "v", false, "")
flag.Usage = func() { fmt.Print(usage) }
flag.Parse()
revision, vcsTime := readRevisionTime()
if version {
fmt.Println("Current version:", Version)
fmt.Printf("go2rtc version %s (%s) %s/%s\n", Version, revision, runtime.GOOS, runtime.GOARCH)
os.Exit(0)
}
if daemon {
args := os.Args[1:]
for i, arg := range args {
if arg == "-daemon" {
args[i] = ""
}
if daemon && os.Getppid() != 1 {
if runtime.GOOS == "windows" {
fmt.Println("Daemon mode is not supported on Windows")
os.Exit(1)
}
// Re-run the program in background and exit
cmd := exec.Command(os.Args[0], args...)
cmd := exec.Command(os.Args[0], os.Args[1:]...)
if err := cmd.Start(); err != nil {
log.Fatal().Err(err).Send()
fmt.Println("Failed to start daemon:", err)
os.Exit(1)
}
fmt.Println("Running in daemon mode with PID:", cmd.Process.Pid)
os.Exit(0)
}
if confs == nil {
confs = []string{"go2rtc.yaml"}
}
UserAgent = "go2rtc/" + Version
for _, conf := range confs {
if conf[0] != '{' {
// config as file
if ConfigPath == "" {
ConfigPath = conf
}
Info["version"] = Version
Info["revision"] = revision
data, _ := os.ReadFile(conf)
if data == nil {
continue
}
initConfig(config)
initLogger()
data = []byte(shell.ReplaceEnvVars(string(data)))
configs = append(configs, data)
} else {
// config as raw YAML
configs = append(configs, []byte(conf))
}
}
platform := fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)
Logger.Info().Str("version", Version).Str("platform", platform).Str("revision", revision).Msg("go2rtc")
Logger.Debug().Str("version", runtime.Version()).Str("vcs.time", vcsTime).Msg("build")
if ConfigPath != "" {
if !filepath.IsAbs(ConfigPath) {
if cwd, err := os.Getwd(); err == nil {
ConfigPath = filepath.Join(cwd, ConfigPath)
Logger.Info().Str("path", ConfigPath).Msg("config")
}
}
func readRevisionTime() (revision, vcsTime string) {
if info, ok := debug.ReadBuildInfo(); ok {
for _, setting := range info.Settings {
switch setting.Key {
case "vcs.revision":
if len(setting.Value) > 7 {
revision = setting.Value[:7]
} else {
revision = setting.Value
}
case "vcs.time":
vcsTime = setting.Value
case "vcs.modified":
if setting.Value == "true" {
revision = "mod." + revision
}
}
}
Info["config_path"] = ConfigPath
}
var cfg struct {
Mod map[string]string `yaml:"log"`
}
LoadConfig(&cfg)
log.Logger = NewLogger(cfg.Mod["format"], cfg.Mod["level"])
modules = cfg.Mod
log.Info().Msgf("go2rtc version %s %s/%s", Version, runtime.GOOS, runtime.GOARCH)
migrateStore()
return
}
func LoadConfig(v any) {
for _, data := range configs {
if err := yaml.Unmarshal(data, v); err != nil {
log.Warn().Err(err).Msg("[app] read config")
}
}
}
func PatchConfig(key string, value any, path ...string) error {
if ConfigPath == "" {
return errors.New("config file disabled")
}
// empty config is OK
b, _ := os.ReadFile(ConfigPath)
b, err := yaml.Patch(b, key, value, path...)
if err != nil {
return err
}
return os.WriteFile(ConfigPath, b, 0644)
}
// internal
type Config []string
func (c *Config) String() string {
return strings.Join(*c, " ")
}
func (c *Config) Set(value string) error {
*c = append(*c, value)
return nil
}
var configs [][]byte

109
internal/app/config.go Normal file
View File

@@ -0,0 +1,109 @@
package app
import (
"errors"
"os"
"path/filepath"
"strings"
"github.com/AlexxIT/go2rtc/pkg/shell"
"github.com/AlexxIT/go2rtc/pkg/yaml"
)
func LoadConfig(v any) {
for _, data := range configs {
if err := yaml.Unmarshal(data, v); err != nil {
Logger.Warn().Err(err).Send()
}
}
}
func PatchConfig(key string, value any, path ...string) error {
if ConfigPath == "" {
return errors.New("config file disabled")
}
// empty config is OK
b, _ := os.ReadFile(ConfigPath)
b, err := yaml.Patch(b, key, value, path...)
if err != nil {
return err
}
return os.WriteFile(ConfigPath, b, 0644)
}
type flagConfig []string
func (c *flagConfig) String() string {
return strings.Join(*c, " ")
}
func (c *flagConfig) Set(value string) error {
*c = append(*c, value)
return nil
}
var configs [][]byte
func initConfig(confs flagConfig) {
if confs == nil {
confs = []string{"go2rtc.yaml"}
}
for _, conf := range confs {
if len(conf) == 0 {
continue
}
if conf[0] == '{' {
// config as raw YAML or JSON
configs = append(configs, []byte(conf))
} else if data := parseConfString(conf); data != nil {
configs = append(configs, data)
} else {
// config as file
if ConfigPath == "" {
ConfigPath = conf
}
if data, _ = os.ReadFile(conf); data == nil {
continue
}
data = []byte(shell.ReplaceEnvVars(string(data)))
configs = append(configs, data)
}
}
if ConfigPath != "" {
if !filepath.IsAbs(ConfigPath) {
if cwd, err := os.Getwd(); err == nil {
ConfigPath = filepath.Join(cwd, ConfigPath)
}
}
Info["config_path"] = ConfigPath
}
}
func parseConfString(s string) []byte {
i := strings.IndexByte(s, '=')
if i < 0 {
return nil
}
items := strings.Split(s[:i], ".")
if len(items) < 2 {
return nil
}
// `log.level=trace` => `{log: {level: trace}}`
var pre string
var suf = s[i+1:]
for _, item := range items {
pre += "{" + item + ": "
suf += "}"
}
return []byte(pre + suf)
}

View File

@@ -4,49 +4,100 @@ import (
"io"
"os"
"github.com/mattn/go-isatty"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
var MemoryLog *circularBuffer
func NewLogger(format string, level string) zerolog.Logger {
var writer io.Writer = os.Stdout
if format != "json" {
writer = zerolog.ConsoleWriter{
Out: writer, TimeFormat: "15:04:05.000", NoColor: format == "text",
}
}
MemoryLog = newBuffer(16)
writer = zerolog.MultiLevelWriter(writer, MemoryLog)
zerolog.TimeFieldFormat = zerolog.TimeFormatUnixMs
lvl, err := zerolog.ParseLevel(level)
if err != nil || lvl == zerolog.NoLevel {
lvl = zerolog.InfoLevel
}
return zerolog.New(writer).With().Timestamp().Logger().Level(lvl)
}
var MemoryLog = newBuffer(16)
func GetLogger(module string) zerolog.Logger {
if s, ok := modules[module]; ok {
lvl, err := zerolog.ParseLevel(s)
if err == nil {
return log.Level(lvl)
return Logger.Level(lvl)
}
log.Warn().Err(err).Caller().Send()
Logger.Warn().Err(err).Caller().Send()
}
return log.Logger
return Logger
}
// initLogger support:
// - output: empty (only to memory), stderr, stdout
// - format: empty (autodetect color support), color, json, text
// - time: empty (disable timestamp), UNIXMS, UNIXMICRO, UNIXNANO
// - level: disabled, trace, debug, info, warn, error...
func initLogger() {
var cfg struct {
Mod map[string]string `yaml:"log"`
}
cfg.Mod = modules // defaults
LoadConfig(&cfg)
var writer io.Writer
switch modules["output"] {
case "stderr":
writer = os.Stderr
case "stdout":
writer = os.Stdout
}
timeFormat := modules["time"]
if writer != nil {
if format := modules["format"]; format != "json" {
console := &zerolog.ConsoleWriter{Out: writer}
switch format {
case "text":
console.NoColor = true
case "color":
console.NoColor = false // useless, but anyway
default:
// autodetection if output support color
// go-isatty - dependency for go-colorable - dependency for ConsoleWriter
console.NoColor = !isatty.IsTerminal(writer.(*os.File).Fd())
}
if timeFormat != "" {
console.TimeFormat = "15:04:05.000"
} else {
console.PartsOrder = []string{
zerolog.LevelFieldName,
zerolog.CallerFieldName,
zerolog.MessageFieldName,
}
}
writer = console
}
writer = zerolog.MultiLevelWriter(writer, MemoryLog)
} else {
writer = MemoryLog
}
lvl, _ := zerolog.ParseLevel(modules["level"])
Logger = zerolog.New(writer).Level(lvl)
if timeFormat != "" {
zerolog.TimeFieldFormat = timeFormat
Logger = Logger.With().Timestamp().Logger()
}
}
var Logger zerolog.Logger
// modules log levels
var modules map[string]string
var modules = map[string]string{
"format": "", // useless, but anyway
"level": "info",
"output": "stdout", // TODO: change to stderr someday
"time": zerolog.TimeFormatUnixMs,
}
const chunkSize = 1 << 16

View File

@@ -1,35 +0,0 @@
package app
import (
"encoding/json"
"os"
"github.com/rs/zerolog/log"
)
func migrateStore() {
const name = "go2rtc.json"
data, _ := os.ReadFile(name)
if data == nil {
return
}
var store struct {
Streams map[string]string `json:"streams"`
}
if err := json.Unmarshal(data, &store); err != nil {
log.Warn().Err(err).Caller().Send()
return
}
for id, url := range store.Streams {
if err := PatchConfig(id, url, "streams"); err != nil {
log.Warn().Err(err).Caller().Send()
return
}
}
_ = os.Remove(name)
}

View File

@@ -7,13 +7,7 @@ import (
)
func Init() {
streams.HandleFunc("bubble", handle)
}
func handle(url string) (core.Producer, error) {
conn := bubble.NewClient(url)
if err := conn.Dial(); err != nil {
return nil, err
}
return conn, nil
streams.HandleFunc("bubble", func(source string) (core.Producer, error) {
return bubble.Dial(source)
})
}

View File

@@ -2,16 +2,8 @@ package debug
import (
"github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core"
)
func Init() {
api.HandleFunc("api/stack", stackHandler)
streams.HandleFunc("null", nullHandler)
}
func nullHandler(string) (core.Producer, error) {
return nil, nil
}

View File

@@ -10,26 +10,16 @@ import (
"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)
streams.HandleFunc("dvrip", dvrip.Dial)
// DVRIP client autodiscovery
api.HandleFunc("api/dvrip", apiDvrip)
}
func handle(url string) (core.Producer, error) {
client, err := dvrip.Dial(url)
if err != nil {
return nil, err
}
return client, nil
}
const Port = 34569 // UDP port number for dvrip discovery
func apiDvrip(w http.ResponseWriter, r *http.Request) {
@@ -92,10 +82,7 @@ func sendBroadcasts(conn *net.UDPConn) {
for i := 0; i < 3; i++ {
time.Sleep(100 * time.Millisecond)
if _, err = conn.WriteToUDP(data, addr); err != nil {
log.Err(err).Caller().Send()
}
_, _ = conn.WriteToUDP(data, addr)
}
}

39
internal/exec/closer.go Normal file
View File

@@ -0,0 +1,39 @@
package exec
import (
"errors"
"net/url"
"os"
"os/exec"
"syscall"
"time"
"github.com/AlexxIT/go2rtc/pkg/core"
)
// closer support custom killsignal with custom killtimeout
type closer struct {
cmd *exec.Cmd
query url.Values
}
func (c *closer) Close() (err error) {
sig := os.Kill
if s := c.query.Get("killsignal"); s != "" {
sig = syscall.Signal(core.Atoi(s))
}
log.Trace().Msgf("[exec] kill with signal=%d", sig)
err = c.cmd.Process.Signal(sig)
if s := c.query.Get("killtimeout"); s != "" {
timeout := time.Duration(core.Atoi(s)) * time.Second
timer := time.AfterFunc(timeout, func() {
log.Trace().Msgf("[exec] kill after timeout=%s", s)
_ = c.cmd.Process.Kill()
})
defer timer.Stop() // stop timer if Wait ends before timeout
}
return errors.Join(err, c.cmd.Wait())
}

View File

@@ -1,6 +1,7 @@
package exec
import (
"bufio"
"crypto/md5"
"encoding/hex"
"errors"
@@ -9,6 +10,7 @@ import (
"net/url"
"os"
"os/exec"
"slices"
"strings"
"sync"
"time"
@@ -49,8 +51,10 @@ func Init() {
}
func execHandle(rawURL string) (core.Producer, error) {
rawURL, rawQuery, _ := strings.Cut(rawURL, "#")
query := streams.ParseQuery(rawQuery)
var path string
var query url.Values
// RTSP flow should have `{output}` inside URL
// pipe flow may have `#{params}` inside URL
@@ -62,60 +66,73 @@ func execHandle(rawURL string) (core.Producer, error) {
sum := md5.Sum([]byte(rawURL))
path = "/" + hex.EncodeToString(sum[:])
rawURL = rawURL[:i] + "rtsp://127.0.0.1:" + rtsp.Port + path + rawURL[i+8:]
} else if i = strings.IndexByte(rawURL, '#'); i > 0 {
query = streams.ParseQuery(rawURL[i+1:])
rawURL = rawURL[:i]
}
args := shell.QuoteSplit(rawURL[5:]) // remove `exec:`
cmd := exec.Command(args[0], args[1:]...)
if log.Debug().Enabled() {
cmd.Stderr = os.Stderr
cmd.Stderr = &logWriter{
buf: make([]byte, 512),
debug: log.Debug().Enabled(),
}
if path == "" {
return handlePipe(rawURL, cmd, query)
}
return handleRTSP(rawURL, cmd, path)
}
func handlePipe(_ string, cmd *exec.Cmd, query url.Values) (core.Producer, error) {
if query.Get("backchannel") == "1" {
return stdin.NewClient(cmd)
}
r, err := PipeCloser(cmd, query)
cl := &closer{cmd: cmd, query: query}
if path == "" {
return handlePipe(rawURL, cmd, cl)
}
return handleRTSP(rawURL, cmd, cl, path)
}
func handlePipe(source string, cmd *exec.Cmd, cl io.Closer) (core.Producer, error) {
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
rc := struct {
io.Reader
io.Closer
}{
// add buffer for pipe reader to reduce syscall
bufio.NewReaderSize(stdout, core.BufferSize),
cl,
}
log.Debug().Strs("args", cmd.Args).Msg("[exec] run pipe")
ts := time.Now()
if err = cmd.Start(); err != nil {
return nil, err
}
prod, err := magic.Open(r)
prod, err := magic.Open(rc)
if err != nil {
_ = r.Close()
_ = rc.Close()
return nil, fmt.Errorf("exec/pipe: %w\n%s", err, cmd.Stderr)
}
return prod, err
if info, ok := prod.(core.Info); ok {
info.SetProtocol("pipe")
setRemoteInfo(info, source, cmd.Args)
}
log.Debug().Stringer("launch", time.Since(ts)).Msg("[exec] run pipe")
return prod, nil
}
func handleRTSP(url string, cmd *exec.Cmd, path string) (core.Producer, error) {
stderr := limitBuffer{buf: make([]byte, 512)}
if cmd.Stderr != nil {
cmd.Stderr = io.MultiWriter(cmd.Stderr, &stderr)
} else {
cmd.Stderr = &stderr
}
func handleRTSP(source string, cmd *exec.Cmd, cl io.Closer, path string) (core.Producer, error) {
if log.Trace().Enabled() {
cmd.Stdout = os.Stdout
}
waiter := make(chan core.Producer)
waiter := make(chan *pkg.Conn, 1)
waitersMu.Lock()
waiters[path] = waiter
@@ -127,12 +144,12 @@ func handleRTSP(url string, cmd *exec.Cmd, path string) (core.Producer, error) {
waitersMu.Unlock()
}()
log.Debug().Str("url", url).Str("cmd", fmt.Sprintf("%s", strings.Join(cmd.Args, " "))).Msg("[exec] run")
log.Debug().Strs("args", cmd.Args).Msg("[exec] run rtsp")
ts := time.Now()
if err := cmd.Start(); err != nil {
log.Error().Err(err).Str("url", url).Msg("[exec]")
log.Error().Err(err).Str("source", source).Msg("[exec]")
return nil, err
}
@@ -142,15 +159,17 @@ func handleRTSP(url string, cmd *exec.Cmd, path string) (core.Producer, error) {
}()
select {
case <-time.After(time.Second * 60):
_ = cmd.Process.Kill()
log.Error().Str("url", url).Msg("[exec] timeout")
return nil, errors.New("timeout")
case <-time.After(time.Minute):
log.Error().Str("source", source).Msg("[exec] timeout")
_ = cl.Close()
return nil, errors.New("exec: timeout")
case <-done:
// limit message size
return nil, errors.New("exec: " + stderr.String())
return nil, fmt.Errorf("exec/rtsp\n%s", cmd.Stderr)
case prod := <-waiter:
log.Debug().Stringer("launch", time.Since(ts)).Msg("[exec] run")
log.Debug().Stringer("launch", time.Since(ts)).Msg("[exec] run rtsp")
setRemoteInfo(prod, source, cmd.Args)
prod.OnClose = cl.Close
return prod, nil
}
}
@@ -159,25 +178,63 @@ func handleRTSP(url string, cmd *exec.Cmd, path string) (core.Producer, error) {
var (
log zerolog.Logger
waiters = map[string]chan core.Producer{}
waiters = make(map[string]chan *pkg.Conn)
waitersMu sync.Mutex
)
type limitBuffer struct {
buf []byte
n int
type logWriter struct {
buf []byte
debug bool
n int
}
func (l *limitBuffer) String() string {
func (l *logWriter) String() string {
if l.n == len(l.buf) {
return string(l.buf) + "..."
}
return string(l.buf[:l.n])
}
func (l *limitBuffer) Write(p []byte) (int, error) {
func (l *logWriter) Write(p []byte) (n int, err error) {
if l.n < cap(l.buf) {
l.n += copy(l.buf[l.n:], p)
}
return len(p), nil
n = len(p)
if l.debug {
if p = trimSpace(p); p != nil {
log.Debug().Msgf("[exec] %s", p)
}
}
return
}
func trimSpace(b []byte) []byte {
start := 0
stop := len(b)
for ; start < stop; start++ {
if b[start] >= ' ' {
break // trim all ASCII before 0x20
}
}
for ; ; stop-- {
if stop == start {
return nil // skip empty output
}
if b[stop-1] > ' ' {
break // trim all ASCII before 0x21
}
}
return b[start:stop]
}
func setRemoteInfo(info core.Info, source string, args []string) {
info.SetSource(source)
if i := slices.Index(args, "-i"); i > 0 && i < len(args)-1 {
rawURL := args[i+1]
if u, err := url.Parse(rawURL); err == nil && u.Host != "" {
info.SetRemoteAddr(u.Host)
info.SetURL(rawURL)
}
}
}

View File

@@ -1,56 +0,0 @@
package exec
import (
"bufio"
"errors"
"io"
"net/url"
"os/exec"
"syscall"
"time"
"github.com/AlexxIT/go2rtc/pkg/core"
)
// PipeCloser - return StdoutPipe that Kill cmd on Close call
func PipeCloser(cmd *exec.Cmd, query url.Values) (io.ReadCloser, error) {
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
// add buffer for pipe reader to reduce syscall
return &pipeCloser{bufio.NewReaderSize(stdout, core.BufferSize), stdout, cmd, query}, nil
}
type pipeCloser struct {
io.Reader
io.Closer
cmd *exec.Cmd
query url.Values
}
func (p *pipeCloser) Close() error {
return errors.Join(p.Closer.Close(), p.Kill(), p.Wait())
}
func (p *pipeCloser) Kill() error {
if s := p.query.Get("killsignal"); s != "" {
log.Trace().Msgf("[exec] kill with custom sig=%s", s)
sig := syscall.Signal(core.Atoi(s))
return p.cmd.Process.Signal(sig)
}
return p.cmd.Process.Kill()
}
func (p *pipeCloser) Wait() error {
if s := p.query.Get("killtimeout"); s != "" {
timeout := time.Duration(core.Atoi(s)) * time.Second
timer := time.AfterFunc(timeout, func() {
log.Trace().Msgf("[exec] kill after timeout=%s", s)
_ = p.cmd.Process.Kill()
})
defer timer.Stop() // stop timer if Wait ends before timeout
}
return p.cmd.Wait()
}

View File

@@ -45,6 +45,13 @@
[video4linux2,v4l2 @ 0x7f7de7c58bc0] Compressed: mjpeg : Motion-JPEG : 640x480 160x120 176x144 320x176 320x240 352x288 432x240 544x288 640x360 752x416 800x448 800x600 864x480 960x544 960x720 1024x576 1184x656 1280x720 1280x960
```
## TTS
```yaml
streams:
tts: ffmpeg:#input=-readrate 1 -readrate_initial_burst 0.001 -f lavfi -i "flite=text='1 2 3 4 5 6 7 8 9 0'"#audio=pcma
```
## Useful links
- https://superuser.com/questions/564402/explanation-of-x264-tune

51
internal/ffmpeg/api.go Normal file
View File

@@ -0,0 +1,51 @@
package ffmpeg
import (
"net/http"
"strings"
"github.com/AlexxIT/go2rtc/internal/streams"
)
func apiFFmpeg(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "", http.StatusMethodNotAllowed)
return
}
query := r.URL.Query()
dst := query.Get("dst")
stream := streams.Get(dst)
if stream == nil {
http.Error(w, "", http.StatusNotFound)
return
}
var src string
if s := query.Get("file"); s != "" {
if streams.Validate(s) == nil {
src = "ffmpeg:" + s + "#audio=auto#input=file"
}
} else if s = query.Get("live"); s != "" {
if streams.Validate(s) == nil {
src = "ffmpeg:" + s + "#audio=auto"
}
} else if s = query.Get("text"); s != "" {
if strings.IndexAny(s, `'"&%$`) < 0 {
src = "ffmpeg:tts?text=" + s
if s = query.Get("voice"); s != "" {
src += "&voice=" + s
}
src += "#audio=auto"
}
}
if src == "" {
http.Error(w, "", http.StatusBadRequest)
return
}
if err := stream.Play(src); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}

View File

@@ -1,3 +1,5 @@
//go:build freebsd || netbsd || openbsd || dragonfly
package device
import (

View File

@@ -1,3 +1,5 @@
//go:build darwin || ios
package device
import (

View File

@@ -1,3 +1,5 @@
//go:build unix && !darwin && !freebsd && !netbsd && !openbsd && !dragonfly
package device
import (

View File

@@ -1,3 +1,5 @@
//go:build windows
package device
import (

View File

@@ -1,11 +1,9 @@
package device
import (
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"github.com/AlexxIT/go2rtc/internal/api"
@@ -17,24 +15,15 @@ func Init(bin string) {
api.HandleFunc("api/ffmpeg/devices", apiDevices)
}
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:])
func GetInput(src string) string {
query, err := url.ParseQuery(src)
if err != nil {
return "", err
return ""
}
runonce.Do(initDevices)
if input := queryToInput(query); input != "" {
return input, nil
}
return "", errors.New("wrong query: " + src)
return queryToInput(query)
}
var Bin string

View File

@@ -2,35 +2,58 @@ package ffmpeg
import (
"net/url"
"slices"
"strings"
"github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/internal/ffmpeg/device"
"github.com/AlexxIT/go2rtc/internal/ffmpeg/hardware"
"github.com/AlexxIT/go2rtc/internal/ffmpeg/virtual"
"github.com/AlexxIT/go2rtc/internal/rtsp"
"github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/ffmpeg"
"github.com/rs/zerolog"
)
func Init() {
var cfg struct {
Mod map[string]string `yaml:"ffmpeg"`
Log struct {
Level string `yaml:"ffmpeg"`
} `yaml:"log"`
}
cfg.Mod = defaults // will be overriden from yaml
cfg.Log.Level = "error"
app.LoadConfig(&cfg)
if app.GetLogger("exec").GetLevel() >= 0 {
defaults["global"] += " -v error"
log = app.GetLogger("ffmpeg")
// zerolog levels: trace debug info warn error fatal panic disabled
// FFmpeg levels: trace debug verbose info warning error fatal panic quiet
if cfg.Log.Level == "warn" {
cfg.Log.Level = "warning"
}
defaults["global"] += " -v " + cfg.Log.Level
streams.RedirectFunc("ffmpeg", func(url string) (string, error) {
if _, err := Version(); err != nil {
return "", err
}
args := parseArgs(url[7:])
if slices.Contains(args.Codecs, "auto") {
return "", nil // force call streams.HandleFunc("ffmpeg")
}
return "exec:" + args.String(), nil
})
streams.HandleFunc("ffmpeg", NewProducer)
api.HandleFunc("api/ffmpeg", apiFFmpeg)
device.Init(defaults["bin"])
hardware.Init(defaults["bin"])
}
@@ -49,16 +72,25 @@ var defaults = map[string]string{
// output
"output": "-user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}",
"output/mjpeg": "-f mjpeg -",
"output/raw": "-f yuv4mpegpipe -",
"output/aac": "-f adts -",
"output/wav": "-f wav -",
// `-preset superfast` - we can't use ultrafast because it doesn't support `-profile main -level 4.1`
// `-tune zerolatency` - for minimal latency
// `-profile high -level 4.1` - most used streaming profile
// `-pix_fmt:v yuv420p` - important for Telegram
"h264": "-c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p",
"h265": "-c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency",
"h265": "-c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p",
"mjpeg": "-c:v mjpeg",
//"mjpeg": "-c:v mjpeg -force_duplicated_matrix:v 1 -huffman:v 0 -pix_fmt:v yuvj420p",
"raw": "-c:v rawvideo",
"raw/gray8": "-c:v rawvideo -pix_fmt:v gray8",
"raw/yuv420p": "-c:v rawvideo -pix_fmt:v yuv420p",
"raw/yuv422p": "-c:v rawvideo -pix_fmt:v yuv422p",
"raw/yuv444p": "-c:v rawvideo -pix_fmt:v yuv444p",
// https://ffmpeg.org/ffmpeg-codecs.html#libopus-1
// https://github.com/pion/webrtc/issues/1514
// https://ffmpeg.org/ffmpeg-resampler.html
@@ -116,6 +148,8 @@ var defaults = map[string]string{
"h265/videotoolbox": "-c:v hevc_videotoolbox -g 50 -bf 0 -profile:v main -level:v 5.1",
}
var log zerolog.Logger
// configTemplate - return template from config (defaults) if exist or return raw template
func configTemplate(template string) string {
if s := defaults[template]; s != "" {
@@ -140,9 +174,10 @@ func inputTemplate(name, s string, query url.Values) string {
func parseArgs(s string) *ffmpeg.Args {
// init FFmpeg arguments
args := &ffmpeg.Args{
Bin: defaults["bin"],
Global: defaults["global"],
Output: defaults["output"],
Bin: defaults["bin"],
Global: defaults["global"],
Output: defaults["output"],
Version: verAV,
}
var query url.Values
@@ -188,16 +223,14 @@ func parseArgs(s string) *ffmpeg.Args {
s += "?video&audio"
}
args.Input = inputTemplate("rtsp", s, query)
} else if strings.HasPrefix(s, "device?") {
var err error
args.Input, err = device.GetInput(s)
if err != nil {
return nil
}
} else if strings.HasPrefix(s, "virtual?") {
var err error
if args.Input, err = virtual.GetInput(s[8:]); err != nil {
return nil
} else if i = strings.Index(s, "?"); i > 0 {
switch s[:i] {
case "device":
args.Input = device.GetInput(s[i+1:])
case "virtual":
args.Input = virtual.GetInput(s[i+1:])
case "tts":
args.Input = virtual.GetInputTTS(s[i+1:])
}
} else {
args.Input = inputTemplate("file", s, query)
@@ -280,6 +313,12 @@ func parseArgs(s string) *ffmpeg.Args {
}
}
if query["bitrate"] != nil {
// https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
b := query["bitrate"][0]
args.AddCodec("-b:v " + b + " -maxrate " + b + " -bufsize " + b)
}
// 4. Process audio codecs
if args.Audio > 0 {
for _, audio := range query["audio"] {
@@ -309,11 +348,27 @@ func parseArgs(s string) *ffmpeg.Args {
args.AddCodec("-an")
}
// transcoding to only mjpeg
if (args.Video == 1 && args.Audio == 0 && query.Get("video") == "mjpeg") ||
// no transcoding from mjpeg input
(args.Video == 0 && args.Audio == 0 && strings.Contains(args.Input, " mjpeg ")) {
args.Output = defaults["output/mjpeg"]
// change otput from RTSP to some other pipe format
switch {
case args.Video == 0 && args.Audio == 0:
// no transcoding from mjpeg input (ffmpeg device with support output as raw MJPEG)
if strings.Contains(args.Input, " mjpeg ") {
args.Output = defaults["output/mjpeg"]
}
case args.Video == 1 && args.Audio == 0:
switch core.Before(query.Get("video"), "/") {
case "mjpeg":
args.Output = defaults["output/mjpeg"]
case "raw":
args.Output = defaults["output/raw"]
}
case args.Video == 0 && args.Audio == 1:
switch core.Before(query.Get("audio"), "/") {
case "aac":
args.Output = defaults["output/aac"]
case "pcma", "pcmu", "pcml":
args.Output = defaults["output/wav"]
}
}
return args

View File

@@ -3,6 +3,7 @@ package ffmpeg
import (
"testing"
"github.com/AlexxIT/go2rtc/pkg/ffmpeg"
"github.com/stretchr/testify/require"
)
@@ -292,3 +293,23 @@ func TestDrawText(t *testing.T) {
})
}
}
func TestVersion(t *testing.T) {
verAV = ffmpeg.Version61
tests := []struct {
name string
source string
expect string
}{
{
source: "/media/bbb.mp4",
expect: `ffmpeg -hide_banner -readrate_initial_burst 0.001 -re -i /media/bbb.mp4 -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
args := parseArgs(test.source)
require.Equal(t, test.expect, args.String())
})
}
}

View File

@@ -7,8 +7,6 @@ import (
"github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/pkg/ffmpeg"
"github.com/rs/zerolog/log"
)
const (
@@ -152,7 +150,6 @@ var cache = map[string]string{}
func run(bin string, args string) bool {
err := exec.Command(bin, strings.Split(args, " ")...).Run()
log.Printf("%v %v", args, err)
return err == nil
}

View File

@@ -1,3 +1,5 @@
//go:build freebsd || netbsd || openbsd || dragonfly
package hardware
import (

View File

@@ -1,3 +1,5 @@
//go:build darwin || ios
package hardware
import (

View File

@@ -1,3 +1,5 @@
//go:build unix && !darwin && !freebsd && !netbsd && !openbsd && !dragonfly
package hardware
import (

View File

@@ -1,3 +1,5 @@
//go:build windows
package hardware
import "github.com/AlexxIT/go2rtc/internal/api"

118
internal/ffmpeg/producer.go Normal file
View File

@@ -0,0 +1,118 @@
package ffmpeg
import (
"encoding/json"
"errors"
"net/url"
"strconv"
"strings"
"github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/aac"
"github.com/AlexxIT/go2rtc/pkg/core"
)
type Producer struct {
core.Connection
url string
query url.Values
ffmpeg core.Producer
}
// NewProducer - FFmpeg producer with auto selection video/audio codec based on client capabilities
func NewProducer(url string) (core.Producer, error) {
p := &Producer{}
i := strings.IndexByte(url, '#')
p.url, p.query = url[:i], streams.ParseQuery(url[i+1:])
// ffmpeg.NewProducer support only one audio
if len(p.query["video"]) != 0 || len(p.query["audio"]) != 1 {
return nil, errors.New("ffmpeg: unsupported params: " + url[i:])
}
p.ID = core.NewID()
p.FormatName = "ffmpeg"
p.Medias = []*core.Media{
{
// we can support only audio, because don't know FmtpLine for H264 and PayloadType for MJPEG
Kind: core.KindAudio,
Direction: core.DirectionRecvonly,
// codecs in order from best to worst
Codecs: []*core.Codec{
// OPUS will always marked as OPUS/48000/2
{Name: core.CodecOpus, ClockRate: 48000, Channels: 2},
{Name: core.CodecPCM, ClockRate: 16000},
{Name: core.CodecPCMA, ClockRate: 16000},
{Name: core.CodecPCMU, ClockRate: 16000},
{Name: core.CodecPCM, ClockRate: 8000},
{Name: core.CodecPCMA, ClockRate: 8000},
{Name: core.CodecPCMU, ClockRate: 8000},
// AAC has unknown problems on Dahua two way
{Name: core.CodecAAC, ClockRate: 16000, FmtpLine: aac.FMTP + "1408"},
},
},
}
return p, nil
}
func (p *Producer) Start() error {
var err error
if p.ffmpeg, err = streams.GetProducer(p.newURL()); err != nil {
return err
}
for i, media := range p.ffmpeg.GetMedias() {
track, err := p.ffmpeg.GetTrack(media, media.Codecs[0])
if err != nil {
return err
}
p.Receivers[i].Replace(track)
}
return p.ffmpeg.Start()
}
func (p *Producer) Stop() error {
if p.ffmpeg == nil {
return nil
}
return p.ffmpeg.Stop()
}
func (p *Producer) MarshalJSON() ([]byte, error) {
if p.ffmpeg == nil {
return json.Marshal(p.Connection)
}
return json.Marshal(p.ffmpeg)
}
func (p *Producer) newURL() string {
s := p.url
// rewrite codecs in url from auto to known presets from defaults
for _, receiver := range p.Receivers {
codec := receiver.Codec
switch codec.Name {
case core.CodecOpus:
s += "#audio=opus"
case core.CodecAAC:
s += "#audio=aac/16000"
case core.CodecPCM:
s += "#audio=pcm/" + strconv.Itoa(int(codec.ClockRate))
case core.CodecPCMA:
s += "#audio=pcma/" + strconv.Itoa(int(codec.ClockRate))
case core.CodecPCMU:
s += "#audio=pcmu/" + strconv.Itoa(int(codec.ClockRate))
}
}
// add other params
for key, values := range p.query {
if key != "audio" {
for _, value := range values {
s += "#" + key + "=" + value
}
}
}
return s
}

View File

@@ -0,0 +1,46 @@
package ffmpeg
import (
"errors"
"os/exec"
"sync"
"github.com/AlexxIT/go2rtc/pkg/ffmpeg"
)
var verMu sync.Mutex
var verErr error
var verFF string
var verAV string
func Version() (string, error) {
verMu.Lock()
defer verMu.Unlock()
if verFF != "" {
return verFF, verErr
}
cmd := exec.Command(defaults["bin"], "-version")
b, err := cmd.Output()
if err != nil {
verFF = "-"
verErr = err
return verFF, verErr
}
verFF, verAV = ffmpeg.ParseVersion(b)
if verFF == "" {
verFF = "?"
}
// better to compare libavformat, because nightly/master builds
if verAV != "" && verAV < ffmpeg.Version50 {
verErr = errors.New("ffmpeg: unsupported version: " + verFF)
}
log.Debug().Str("version", verFF).Str("libavformat", verAV).Msgf("[ffmpeg] bin")
return verFF, verErr
}

View File

@@ -4,56 +4,76 @@ import (
"net/url"
)
func GetInput(src string) (string, error) {
func GetInput(src string) string {
query, err := url.ParseQuery(src)
if err != nil {
return "", err
return ""
}
// set defaults (using Add instead of Set)
query.Add("source", "testsrc")
query.Add("size", "1920x1080")
query.Add("decimals", "2")
input := "-re"
// https://ffmpeg.org/ffmpeg-filters.html
source := query.Get("source")
input := "-re -f lavfi -i " + source
for _, video := range query["video"] {
// https://ffmpeg.org/ffmpeg-filters.html
sep := "=" // first separator
sep := "=" // first separator
for key, values := range query {
value := values[0]
// https://ffmpeg.org/ffmpeg-utils.html#video-size-syntax
switch key {
case "color", "rate", "duration", "sar":
case "size":
switch value {
case "720":
value = "1280x720"
case "1080":
value = "1920x1080"
case "2K":
value = "2560x1440"
case "4K":
value = "3840x2160"
case "8K":
value = "7680x4230" // https://reolink.com/blog/8k-resolution/
}
case "decimals":
if source != "testsrc" {
continue
}
default:
continue
if video == "" {
video = "testsrc=decimals=2" // default video
sep = ":"
}
input += sep + key + "=" + value
sep = ":" // next separator
input += " -f lavfi -i " + video
// set defaults (using Add instead of Set)
query.Add("size", "1920x1080")
for key, values := range query {
value := values[0]
// https://ffmpeg.org/ffmpeg-utils.html#video-size-syntax
switch key {
case "color", "rate", "duration", "sar", "decimals":
case "size":
switch value {
case "720":
value = "1280x720" // crf=1 -> 12 Mbps
case "1080":
value = "1920x1080" // crf=1 -> 25 Mbps
case "2K":
value = "2560x1440" // crf=1 -> 43 Mbps
case "4K":
value = "3840x2160" // crf=1 -> 103 Mbps
case "8K":
value = "7680x4230" // https://reolink.com/blog/8k-resolution/
}
default:
continue
}
input += sep + key + "=" + value
sep = ":" // next separator
}
if s := query.Get("format"); s != "" {
input += ",format=" + s
}
}
if s := query.Get("format"); s != "" {
input += ",format=" + s
}
return input, nil
return input
}
func GetInputTTS(src string) string {
query, err := url.ParseQuery(src)
if err != nil {
return ""
}
input := `-re -f lavfi -i "flite=text='` + query.Get("text") + `'`
// ffmpeg -f lavfi -i flite=list_voices=1
// awb, kal, kal16, rms, slt
if voice := query.Get("voice"); voice != "" {
input += ":voice" + voice
}
return input + `"`
}

View File

@@ -0,0 +1,20 @@
package virtual
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestGetInput(t *testing.T) {
s := GetInput("video")
require.Equal(t, "-re -f lavfi -i testsrc=decimals=2:size=1920x1080", s)
s = GetInput("video=testsrc2&size=4K")
require.Equal(t, "-re -f lavfi -i testsrc2=size=3840x2160", s)
}
func TestGetInputTTS(t *testing.T) {
s := GetInputTTS("text=hello world&voice=slt")
require.Equal(t, `-re -f lavfi -i "flite=text='hello world':voiceslt"`, s)
}

View File

@@ -10,15 +10,13 @@ import (
)
func Init() {
streams.HandleFunc("gopro", handleGoPro)
streams.HandleFunc("gopro", func(source string) (core.Producer, error) {
return gopro.Dial(source)
})
api.HandleFunc("api/gopro", apiGoPro)
}
func handleGoPro(rawURL string) (core.Producer, error) {
return gopro.Dial(rawURL)
}
func apiGoPro(w http.ResponseWriter, r *http.Request) {
var items []*api.Source

View File

@@ -63,7 +63,7 @@ func apiStream(w http.ResponseWriter, r *http.Request) {
return
}
s, err = webrtc.ExchangeSDP(stream, string(offer), "WebRTC/Hass sync", r.UserAgent())
s, err = webrtc.ExchangeSDP(stream, string(offer), "hass/webrtc", r.UserAgent())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return

View File

@@ -21,7 +21,7 @@ import (
func Init() {
var conf struct {
API struct {
Listen string `json:"listen"`
Listen string `yaml:"listen"`
} `yaml:"api"`
Mod struct {
Config string `yaml:"config"`
@@ -45,19 +45,14 @@ func Init() {
return "", nil
})
streams.HandleFunc("hass", func(url string) (core.Producer, error) {
streams.HandleFunc("hass", func(source string) (core.Producer, error) {
// support hass://supervisor?entity_id=camera.driveway_doorbell
client, err := hass.NewClient(url)
if err != nil {
return nil, err
}
return client, nil
return hass.NewClient(source)
})
// load static entries from Hass config
if err := importConfig(conf.Mod.Config); err != nil {
log.Debug().Msgf("[hass] can't import config: %s", err)
log.Trace().Msgf("[hass] can't import config: %s", err)
api.HandleFunc("api/hass", func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "no hass config", http.StatusNotFound)

View File

@@ -12,7 +12,6 @@ import (
"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"
)
@@ -63,15 +62,13 @@ func handlerStream(w http.ResponseWriter, r *http.Request) {
medias := mp4.ParseQuery(r.URL.Query())
if medias != nil {
c := mp4.NewConsumer(medias)
c.Type = "HLS/fMP4 consumer"
c.RemoteAddr = tcp.RemoteAddr(r)
c.UserAgent = r.UserAgent()
c.FormatName = "hls/fmp4"
c.WithRequest(r)
cons = c
} else {
c := mpegts.NewConsumer()
c.Type = "HLS/TS consumer"
c.RemoteAddr = tcp.RemoteAddr(r)
c.UserAgent = r.UserAgent()
c.FormatName = "hls/mpegts"
c.WithRequest(r)
cons = c
}

View File

@@ -8,7 +8,6 @@ import (
"github.com/AlexxIT/go2rtc/internal/api/ws"
"github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/mp4"
"github.com/AlexxIT/go2rtc/pkg/tcp"
)
func handlerWSHLS(tr *ws.Transport, msg *ws.Message) error {
@@ -20,9 +19,8 @@ func handlerWSHLS(tr *ws.Transport, msg *ws.Message) error {
codecs := msg.String()
medias := mp4.ParseCodecs(codecs, true)
cons := mp4.NewConsumer(medias)
cons.Type = "HLS/fMP4 consumer"
cons.RemoteAddr = tcp.RemoteAddr(tr.Request)
cons.UserAgent = tr.Request.UserAgent()
cons.FormatName = "hls/fmp4"
cons.WithRequest(tr.Request)
log.Trace().Msgf("[hls] new ws consumer codecs=%s", codecs)

View File

@@ -22,12 +22,11 @@ import (
func Init() {
var cfg struct {
Mod map[string]struct {
Pin string `json:"pin"`
Name string `json:"name"`
DeviceID string `json:"device_id"`
DevicePrivate string `json:"device_private"`
Pairings []string `json:"pairings"`
//Listen string `json:"listen"`
Pin string `yaml:"pin"`
Name string `yaml:"name"`
DeviceID string `yaml:"device_id"`
DevicePrivate string `yaml:"device_private"`
Pairings []string `yaml:"pairings"`
} `yaml:"homekit"`
}
app.LoadConfig(&cfg)
@@ -134,12 +133,19 @@ func Init() {
var log zerolog.Logger
var servers map[string]*server
func streamHandler(url string) (core.Producer, error) {
func streamHandler(rawURL string) (core.Producer, error) {
if srtp.Server == nil {
return nil, errors.New("homekit: can't work without SRTP server")
}
return homekit.Dial(url, srtp.Server)
rawURL, rawQuery, _ := strings.Cut(rawURL, "#")
client, err := homekit.Dial(rawURL, srtp.Server)
if client != nil && rawQuery != "" {
query := streams.ParseQuery(rawQuery)
client.Bitrate = parseBitrate(query.Get("bitrate"))
}
return client, err
}
func hapPairSetup(w http.ResponseWriter, r *http.Request) {
@@ -200,3 +206,24 @@ func findHomeKitURL(stream *streams.Stream) string {
return ""
}
func parseBitrate(s string) int {
n := len(s)
if n == 0 {
return 0
}
var k int
switch n--; s[n] {
case 'K':
k = 1024
s = s[:n]
case 'M':
k = 1024 * 1024
s = s[:n]
default:
k = 1
}
return k * core.Atoi(s)
}

View File

@@ -11,9 +11,9 @@ import (
"github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/hls"
"github.com/AlexxIT/go2rtc/pkg/image"
"github.com/AlexxIT/go2rtc/pkg/magic"
"github.com/AlexxIT/go2rtc/pkg/mjpeg"
"github.com/AlexxIT/go2rtc/pkg/multipart"
"github.com/AlexxIT/go2rtc/pkg/mpjpeg"
"github.com/AlexxIT/go2rtc/pkg/tcp"
)
@@ -45,6 +45,21 @@ func handleHTTP(rawURL string) (core.Producer, error) {
}
}
prod, err := do(req)
if err != nil {
return nil, err
}
if info, ok := prod.(core.Info); ok {
info.SetProtocol("http")
info.SetRemoteAddr(req.URL.Host) // TODO: rewrite to net.Conn
info.SetURL(rawURL)
}
return prod, nil
}
func do(req *http.Request) (core.Producer, error) {
res, err := tcp.Do(req)
if err != nil {
return nil, err
@@ -66,14 +81,12 @@ func handleHTTP(rawURL string) (core.Producer, error) {
}
switch {
case ct == "image/jpeg":
return mjpeg.NewClient(res), nil
case ct == "multipart/x-mixed-replace":
return multipart.Open(res.Body)
case ct == "application/vnd.apple.mpegurl" || ext == "m3u8":
return hls.OpenURL(req.URL, res.Body)
case ct == "image/jpeg":
return image.Open(res)
case ct == "multipart/x-mixed-replace":
return mpjpeg.Open(res.Body)
}
return magic.Open(res.Body)

View File

@@ -7,16 +7,7 @@ import (
)
func Init() {
streams.HandleFunc("isapi", handle)
}
func handle(url string) (core.Producer, error) {
conn, err := isapi.NewClient(url)
if err != nil {
return nil, err
}
if err = conn.Dial(); err != nil {
return nil, err
}
return conn, nil
streams.HandleFunc("isapi", func(source string) (core.Producer, error) {
return isapi.Dial(source)
})
}

View File

@@ -4,16 +4,10 @@ import (
"github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/ivideon"
"strings"
)
func Init() {
streams.HandleFunc("ivideon", func(url string) (core.Producer, error) {
id := strings.Replace(url[8:], "/", ":", 1)
prod := ivideon.NewClient(id)
if err := prod.Dial(); err != nil {
return nil, err
}
return prod, nil
streams.HandleFunc("ivideon", func(source string) (core.Producer, error) {
return ivideon.Dial(source)
})
}

38
internal/mjpeg/README.md Normal file
View File

@@ -0,0 +1,38 @@
## Stream as ASCII to Terminal
[![](https://img.youtube.com/vi/sHj_3h_sX7M/mqdefault.jpg)](https://www.youtube.com/watch?v=sHj_3h_sX7M)
**Tips**
- this feature works only with MJPEG codec (use transcoding)
- choose a low frame rate (FPS)
- choose the width and height to fit in your terminal
- different terminals support different numbers of colours (8, 256, rgb)
- escape text param with urlencode
- you can stream any camera or file from a disc
**go2rtc.yaml** - transcoding to MJPEG, terminal size - 210x59 (16/9), fps - 10
```yaml
streams:
gamazda: ffmpeg:gamazda.mp4#video=mjpeg#hardware#width=210#height=59#raw=-r 10
```
**API params**
- `color` - foreground color, values: empty, `8`, `256`, `rgb`, [SGR](https://en.wikipedia.org/wiki/ANSI_escape_code)
- example: `30` (black), `37` (white), `38;5;226` (yellow)
- `back` - background color, values: empty, `8`, `256`, `rgb`, [SGR](https://en.wikipedia.org/wiki/ANSI_escape_code)
- example: `40` (black), `47` (white), `48;5;226` (yellow)
- `text` - character set, values: empty, one char, `block`, list of chars (in order of brightness)
- example: `%20` (space), `block` (keyword for block elements), `ox` (two chars)
**Examples**
```bash
% curl "http://192.168.1.123:1984/api/stream.ascii?src=gamazda"
% curl "http://192.168.1.123:1984/api/stream.ascii?src=gamazda&color=256"
% curl "http://192.168.1.123:1984/api/stream.ascii?src=gamazda&back=256&text=%20"
% curl "http://192.168.1.123:1984/api/stream.ascii?src=gamazda&back=8&text=%20%20"
% curl "http://192.168.1.123:1984/api/stream.ascii?src=gamazda&text=helloworld"
```

View File

@@ -5,26 +5,36 @@ import (
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/internal/api/ws"
"github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/internal/ffmpeg"
"github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/ascii"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/magic"
"github.com/AlexxIT/go2rtc/pkg/mjpeg"
"github.com/AlexxIT/go2rtc/pkg/tcp"
"github.com/rs/zerolog/log"
"github.com/AlexxIT/go2rtc/pkg/mpjpeg"
"github.com/AlexxIT/go2rtc/pkg/y4m"
"github.com/rs/zerolog"
)
func Init() {
api.HandleFunc("api/frame.jpeg", handlerKeyframe)
api.HandleFunc("api/stream.mjpeg", handlerStream)
api.HandleFunc("api/stream.ascii", handlerStream)
api.HandleFunc("api/stream.y4m", apiStreamY4M)
ws.HandleFunc("mjpeg", handlerWS)
log = app.GetLogger("mjpeg")
}
var log zerolog.Logger
func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
src := r.URL.Query().Get("src")
stream := streams.Get(src)
@@ -34,8 +44,7 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
}
cons := magic.NewKeyframe()
cons.RemoteAddr = tcp.RemoteAddr(r)
cons.UserAgent = r.UserAgent()
cons.WithRequest(r)
if err := stream.AddConsumer(cons); err != nil {
log.Error().Err(err).Caller().Send()
@@ -90,8 +99,7 @@ func outputMjpeg(w http.ResponseWriter, r *http.Request) {
}
cons := mjpeg.NewConsumer()
cons.RemoteAddr = tcp.RemoteAddr(r)
cons.UserAgent = r.UserAgent()
cons.WithRequest(r)
if err := stream.AddConsumer(cons); err != nil {
log.Error().Err(err).Msg("[api.mjpeg] add consumer")
@@ -99,38 +107,22 @@ func outputMjpeg(w http.ResponseWriter, r *http.Request) {
}
h := w.Header()
h.Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
h.Set("Cache-Control", "no-cache")
h.Set("Connection", "close")
h.Set("Pragma", "no-cache")
wr := &writer{wr: w, buf: []byte(header)}
_, _ = cons.WriteTo(wr)
if strings.HasSuffix(r.URL.Path, "mjpeg") {
wr := mjpeg.NewWriter(w)
_, _ = cons.WriteTo(wr)
} else {
cons.FormatName = "ascii"
stream.RemoveConsumer(cons)
}
const header = "--frame\r\nContent-Type: image/jpeg\r\nContent-Length: "
type writer struct {
wr io.Writer
buf []byte
}
func (w *writer) Write(p []byte) (n int, err error) {
w.buf = w.buf[:len(header)]
w.buf = append(w.buf, strconv.Itoa(len(p))...)
w.buf = append(w.buf, "\r\n\r\n"...)
w.buf = append(w.buf, p...)
w.buf = append(w.buf, "\r\n"...)
// Chrome bug: mjpeg image always shows the second to last image
// https://bugs.chromium.org/p/chromium/issues/detail?id=527446
if n, err = w.wr.Write(w.buf); err == nil {
w.wr.(http.Flusher).Flush()
query := r.URL.Query()
wr := ascii.NewWriter(w, query.Get("color"), query.Get("back"), query.Get("text"))
_, _ = cons.WriteTo(wr)
}
return
stream.RemoveConsumer(cons)
}
func inputMjpeg(w http.ResponseWriter, r *http.Request) {
@@ -141,17 +133,16 @@ func inputMjpeg(w http.ResponseWriter, r *http.Request) {
return
}
res := &http.Response{Body: r.Body, Header: r.Header, Request: r}
res.Header.Set("Content-Type", "multipart/mixed;boundary=")
prod, _ := mpjpeg.Open(r.Body)
prod.WithRequest(r)
client := mjpeg.NewClient(res)
stream.AddProducer(client)
stream.AddProducer(prod)
if err := client.Start(); err != nil && err != io.EOF {
if err := prod.Start(); err != nil && err != io.EOF {
log.Warn().Err(err).Caller().Send()
}
stream.RemoveProducer(client)
stream.RemoveProducer(prod)
}
func handlerWS(tr *ws.Transport, _ *ws.Message) error {
@@ -161,8 +152,7 @@ func handlerWS(tr *ws.Transport, _ *ws.Message) error {
}
cons := mjpeg.NewConsumer()
cons.RemoteAddr = tcp.RemoteAddr(tr.Request)
cons.UserAgent = tr.Request.UserAgent()
cons.WithRequest(tr.Request)
if err := stream.AddConsumer(cons); err != nil {
log.Debug().Err(err).Msg("[mjpeg] add consumer")
@@ -179,3 +169,24 @@ func handlerWS(tr *ws.Transport, _ *ws.Message) error {
return nil
}
func apiStreamY4M(w http.ResponseWriter, r *http.Request) {
src := r.URL.Query().Get("src")
stream := streams.Get(src)
if stream == nil {
http.Error(w, api.StreamNotFound, http.StatusNotFound)
return
}
cons := y4m.NewConsumer()
cons.WithRequest(r)
if err := stream.AddConsumer(cons); err != nil {
log.Error().Err(err).Caller().Send()
return
}
_, _ = cons.WriteTo(w)
stream.RemoveConsumer(cons)
}

View File

@@ -1,6 +1,7 @@
package mp4
import (
"context"
"net/http"
"strconv"
"strings"
@@ -12,7 +13,6 @@ import (
"github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/mp4"
"github.com/AlexxIT/go2rtc/pkg/tcp"
"github.com/rs/zerolog"
)
@@ -99,9 +99,9 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) {
medias := mp4.ParseQuery(r.URL.Query())
cons := mp4.NewConsumer(medias)
cons.Type = "MP4/HTTP active consumer"
cons.RemoteAddr = tcp.RemoteAddr(r)
cons.UserAgent = r.UserAgent()
cons.FormatName = "mp4"
cons.Protocol = "http"
cons.WithRequest(r)
if err := stream.AddConsumer(cons); err != nil {
log.Error().Err(err).Caller().Send()
@@ -127,20 +127,20 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) {
header.Set("Content-Disposition", `attachment; filename="`+filename+`"`)
}
var duration *time.Timer
if s := query.Get("duration"); s != "" {
if i, _ := strconv.Atoi(s); i > 0 {
duration = time.AfterFunc(time.Second*time.Duration(i), func() {
_ = cons.Stop()
})
}
ctx := r.Context() // handle when the client drops the connection
if i := core.Atoi(query.Get("duration")); i > 0 {
timeout := time.Second * time.Duration(i)
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
}
go func() {
<-ctx.Done()
_ = cons.Stop()
stream.RemoveConsumer(cons)
}()
_, _ = cons.WriteTo(w)
stream.RemoveConsumer(cons)
if duration != nil {
duration.Stop()
}
}

View File

@@ -8,7 +8,6 @@ import (
"github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/mp4"
"github.com/AlexxIT/go2rtc/pkg/tcp"
)
func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error {
@@ -24,9 +23,8 @@ func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error {
}
cons := mp4.NewConsumer(medias)
cons.Type = "MSE/WebSocket active consumer"
cons.RemoteAddr = tcp.RemoteAddr(tr.Request)
cons.UserAgent = tr.Request.UserAgent()
cons.FormatName = "mse/fmp4"
cons.WithRequest(tr.Request)
if err := stream.AddConsumer(cons); err != nil {
log.Debug().Err(err).Msg("[mp4] add consumer")
@@ -57,9 +55,7 @@ func handlerWSMP4(tr *ws.Transport, msg *ws.Message) error {
}
cons := mp4.NewKeyframe(medias)
cons.Type = "MP4/WebSocket active consumer"
cons.RemoteAddr = tcp.RemoteAddr(tr.Request)
cons.UserAgent = tr.Request.UserAgent()
cons.WithRequest(tr.Request)
if err := stream.AddConsumer(cons); err != nil {
log.Error().Err(err).Caller().Send()

View File

@@ -6,8 +6,6 @@ import (
"github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/aac"
"github.com/AlexxIT/go2rtc/pkg/tcp"
"github.com/rs/zerolog/log"
)
func apiStreamAAC(w http.ResponseWriter, r *http.Request) {
@@ -19,11 +17,9 @@ func apiStreamAAC(w http.ResponseWriter, r *http.Request) {
}
cons := aac.NewConsumer()
cons.RemoteAddr = tcp.RemoteAddr(r)
cons.UserAgent = r.UserAgent()
cons.WithRequest(r)
if err := stream.AddConsumer(cons); err != nil {
log.Error().Err(err).Caller().Send()
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

View File

@@ -6,8 +6,6 @@ import (
"github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/internal/streams"
"github.com/AlexxIT/go2rtc/pkg/mpegts"
"github.com/AlexxIT/go2rtc/pkg/tcp"
"github.com/rs/zerolog/log"
)
func Init() {
@@ -32,11 +30,9 @@ func outputMpegTS(w http.ResponseWriter, r *http.Request) {
}
cons := mpegts.NewConsumer()
cons.RemoteAddr = tcp.RemoteAddr(r)
cons.UserAgent = r.UserAgent()
cons.WithRequest(r)
if err := stream.AddConsumer(cons); err != nil {
log.Error().Err(err).Caller().Send()
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

View File

@@ -10,19 +10,13 @@ import (
)
func Init() {
streams.HandleFunc("nest", streamNest)
streams.HandleFunc("nest", func(source string) (core.Producer, error) {
return nest.Dial(source)
})
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")

View File

@@ -50,7 +50,7 @@ func Init() {
log.Info().Str("addr", address).Msg("[ngrok] add external candidate for WebRTC")
webrtc.AddCandidate(address, "tcp")
webrtc.AddCandidate("tcp", address)
}
}
})

View File

@@ -11,22 +11,13 @@ import (
)
func Init() {
streams.HandleFunc("roborock", handle)
streams.HandleFunc("roborock", func(source string) (core.Producer, error) {
return roborock.Dial(source)
})
api.HandleFunc("api/roborock", apiHandle)
}
func handle(url string) (core.Producer, error) {
conn := roborock.NewClient(url)
if err := conn.Dial(); err != nil {
return nil, err
}
if err := conn.Connect(); err != nil {
return nil, err
}
return conn, nil
}
var Auth struct {
UserData *roborock.UserInfo `json:"user_data"`
BaseURL string `json:"base_url"`

View File

@@ -12,7 +12,6 @@ import (
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/flv"
"github.com/AlexxIT/go2rtc/pkg/rtmp"
"github.com/AlexxIT/go2rtc/pkg/tcp"
"github.com/rs/zerolog"
)
@@ -128,11 +127,7 @@ func tcpHandle(netConn net.Conn) error {
var log zerolog.Logger
func streamsHandle(url string) (core.Producer, error) {
client, err := rtmp.DialPlay(url)
if err != nil {
return nil, err
}
return client, nil
return rtmp.DialPlay(url)
}
func streamsConsumerHandle(url string) (core.Consumer, func(), error) {
@@ -165,9 +160,7 @@ func outputFLV(w http.ResponseWriter, r *http.Request) {
}
cons := flv.NewConsumer()
cons.Type = "HTTP-FLV consumer"
cons.RemoteAddr = tcp.RemoteAddr(r)
cons.UserAgent = r.UserAgent()
cons.WithRequest(r)
if err := stream.AddConsumer(cons); err != nil {
log.Error().Err(err).Caller().Send()

View File

@@ -21,7 +21,7 @@ func Init() {
Username string `yaml:"username" json:"-"`
Password string `yaml:"password" json:"-"`
DefaultQuery string `yaml:"default_query" json:"default_query"`
PacketSize uint16 `yaml:"pkt_size"`
PacketSize uint16 `yaml:"pkt_size" json:"pkt_size,omitempty"`
} `yaml:"rtsp"`
}
@@ -210,6 +210,11 @@ func tcpHandler(conn *rtsp.Conn) {
return
}
query := conn.URL.Query()
if s := query.Get("timeout"); s != "" {
conn.Timeout = core.Atoi(s)
}
log.Debug().Str("stream", name).Msg("[rtsp] new producer")
stream.AddProducer(conn)

View File

@@ -0,0 +1,8 @@
## Testing notes
```yaml
streams:
test1-basic: ffmpeg:virtual?video#video=h264
test2-reconnect: ffmpeg:virtual?video&duration=10#video=h264
test3-execkill: exec:./examples/rtsp_client/rtsp_client/rtsp_client {output}
```

124
internal/streams/api.go Normal file
View File

@@ -0,0 +1,124 @@
package streams
import (
"net/http"
"github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/pkg/probe"
)
func apiStreams(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
src := query.Get("src")
// without source - return all streams list
if src == "" && r.Method != "POST" {
api.ResponseJSON(w, streams)
return
}
// Not sure about all this API. Should be rewrited...
switch r.Method {
case "GET":
stream := Get(src)
if stream == nil {
http.Error(w, "", http.StatusNotFound)
return
}
cons := probe.NewProbe(query)
if len(cons.Medias) != 0 {
cons.WithRequest(r)
if err := stream.AddConsumer(cons); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
api.ResponsePrettyJSON(w, stream)
stream.RemoveConsumer(cons)
} else {
api.ResponsePrettyJSON(w, streams[src])
}
case "PUT":
name := query.Get("name")
if name == "" {
name = src
}
if New(name, src) == nil {
http.Error(w, "", http.StatusBadRequest)
return
}
if err := app.PatchConfig(name, src, "streams"); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
}
case "PATCH":
name := query.Get("name")
if name == "" {
http.Error(w, "", http.StatusBadRequest)
return
}
// support {input} templates: https://github.com/AlexxIT/go2rtc#module-hass
if Patch(name, src) == nil {
http.Error(w, "", http.StatusBadRequest)
}
case "POST":
// with dst - redirect source to dst
if dst := query.Get("dst"); dst != "" {
if stream := Get(dst); stream != nil {
if err := Validate(src); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
} else if err = stream.Play(src); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
} else {
api.ResponseJSON(w, stream)
}
} else if stream = Get(src); stream != nil {
if err := Validate(dst); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
} else if err = stream.Publish(dst); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
} else {
http.Error(w, "", http.StatusNotFound)
}
} else {
http.Error(w, "", http.StatusBadRequest)
}
case "DELETE":
delete(streams, src)
if err := app.PatchConfig(src, nil, "streams"); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
}
}
}
func apiStreamsDOT(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
dot := make([]byte, 0, 1024)
dot = append(dot, "digraph {\n"...)
if query.Has("src") {
for _, name := range query["src"] {
if stream := streams[name]; stream != nil {
dot = AppendDOT(dot, stream)
}
}
} else {
for _, stream := range streams {
dot = AppendDOT(dot, stream)
}
}
dot = append(dot, '}')
api.Response(w, dot, "text/vnd.graphviz")
}

175
internal/streams/dot.go Normal file
View File

@@ -0,0 +1,175 @@
package streams
import (
"encoding/json"
"fmt"
"strings"
)
func AppendDOT(dot []byte, stream *Stream) []byte {
for _, prod := range stream.producers {
if prod.conn == nil {
continue
}
c, err := marshalConn(prod.conn)
if err != nil {
continue
}
dot = c.appendDOT(dot, "producer")
}
for _, cons := range stream.consumers {
c, err := marshalConn(cons)
if err != nil {
continue
}
dot = c.appendDOT(dot, "consumer")
}
return dot
}
func marshalConn(v any) (*conn, error) {
b, err := json.Marshal(v)
if err != nil {
return nil, err
}
var c conn
if err = json.Unmarshal(b, &c); err != nil {
return nil, err
}
return &c, nil
}
const bytesK = "KMGTP"
func humanBytes(i int) string {
if i < 1000 {
return fmt.Sprintf("%d B", i)
}
f := float64(i) / 1000
var n uint8
for f >= 1000 && n < 5 {
f /= 1000
n++
}
return fmt.Sprintf("%.2f %cB", f, bytesK[n])
}
type node struct {
ID uint32 `json:"id"`
Codec map[string]any `json:"codec"`
Parent uint32 `json:"parent"`
Childs []uint32 `json:"childs"`
Bytes int `json:"bytes"`
//Packets uint32 `json:"packets"`
//Drops uint32 `json:"drops"`
}
var codecKeys = []string{"codec_name", "sample_rate", "channels", "profile", "level"}
func (n *node) name() string {
if name, ok := n.Codec["codec_name"].(string); ok {
return name
}
return "unknown"
}
func (n *node) codec() []byte {
b := make([]byte, 0, 128)
for _, k := range codecKeys {
if v := n.Codec[k]; v != nil {
b = fmt.Appendf(b, "%s=%v\n", k, v)
}
}
if l := len(b); l > 0 {
return b[:l-1]
}
return b
}
func (n *node) appendDOT(dot []byte, group string) []byte {
dot = fmt.Appendf(dot, "%d [group=%s, label=%q, title=%q];\n", n.ID, group, n.name(), n.codec())
//for _, sink := range n.Childs {
// dot = fmt.Appendf(dot, "%d -> %d;\n", n.ID, sink)
//}
return dot
}
type conn struct {
ID uint32 `json:"id"`
FormatName string `json:"format_name"`
Protocol string `json:"protocol"`
RemoteAddr string `json:"remote_addr"`
Source string `json:"source"`
URL string `json:"url"`
UserAgent string `json:"user_agent"`
Receivers []node `json:"receivers"`
Senders []node `json:"senders"`
BytesRecv int `json:"bytes_recv"`
BytesSend int `json:"bytes_send"`
}
func (c *conn) appendDOT(dot []byte, group string) []byte {
host := c.host()
dot = fmt.Appendf(dot, "%s [group=host];\n", host)
dot = fmt.Appendf(dot, "%d [group=%s, label=%q, title=%q];\n", c.ID, group, c.FormatName, c.label())
if group == "producer" {
dot = fmt.Appendf(dot, "%s -> %d [label=%q];\n", host, c.ID, humanBytes(c.BytesRecv))
} else {
dot = fmt.Appendf(dot, "%d -> %s [label=%q];\n", c.ID, host, humanBytes(c.BytesSend))
}
for _, recv := range c.Receivers {
dot = fmt.Appendf(dot, "%d -> %d [label=%q];\n", c.ID, recv.ID, humanBytes(recv.Bytes))
dot = recv.appendDOT(dot, "node")
}
for _, send := range c.Senders {
dot = fmt.Appendf(dot, "%d -> %d [label=%q];\n", send.Parent, c.ID, humanBytes(send.Bytes))
//dot = fmt.Appendf(dot, "%d -> %d [label=%q];\n", send.ID, c.ID, humanBytes(send.Bytes))
//dot = send.appendDOT(dot, "node")
}
return dot
}
func (c *conn) host() (s string) {
if c.Protocol == "pipe" {
return "127.0.0.1"
}
if s = c.RemoteAddr; s == "" {
return "unknown"
}
if i := strings.Index(s, "forwarded"); i > 0 {
s = s[i+10:]
}
if s[0] == '[' {
if i := strings.Index(s, "]"); i > 0 {
return s[1:i]
}
}
if i := strings.IndexAny(s, " ,:"); i > 0 {
return s[:i]
}
return
}
func (c *conn) label() string {
var sb strings.Builder
sb.WriteString("format_name=" + c.FormatName)
if c.Protocol != "" {
sb.WriteString("\nprotocol=" + c.Protocol)
}
if c.Source != "" {
sb.WriteString("\nsource=" + c.Source)
}
if c.URL != "" {
sb.WriteString("\nurl=" + c.URL)
}
if c.UserAgent != "" {
sb.WriteString("\nuser_agent=" + c.UserAgent)
}
return sb.String()
}

View File

@@ -7,7 +7,7 @@ import (
"github.com/AlexxIT/go2rtc/pkg/core"
)
type Handler func(url string) (core.Producer, error)
type Handler func(source string) (core.Producer, error)
var handlers = map[string]Handler{}

View File

@@ -2,6 +2,8 @@ package streams
import (
"errors"
"time"
"github.com/AlexxIT/go2rtc/pkg/core"
)
@@ -80,18 +82,20 @@ func (s *Stream) Play(source string) error {
s.AddInternalProducer(src)
s.AddInternalConsumer(cons)
go func() {
_ = src.Start()
_ = dst.Stop()
s.RemoveProducer(src)
}()
go func() {
_ = dst.Start()
_ = src.Stop()
s.RemoveInternalConsumer(cons)
}()
go func() {
_ = src.Start()
// little timeout before stop dst, so the buffer can be transferred
time.Sleep(time.Second)
_ = dst.Stop()
s.RemoveProducer(src)
}()
return nil
}

View File

@@ -132,11 +132,10 @@ func (p *Producer) AddTrack(media *core.Media, codec *core.Codec, track *core.Re
}
func (p *Producer) MarshalJSON() ([]byte, error) {
if p.conn != nil {
return json.Marshal(p.conn)
if conn := p.conn; conn != nil {
return json.Marshal(conn)
}
info := core.Info{URL: p.url}
info := map[string]string{"url": p.url}
return json.Marshal(info)
}
@@ -207,7 +206,7 @@ func (p *Producer) reconnect(workerID, retry int) {
for _, media := range conn.GetMedias() {
switch media.Direction {
case core.DirectionRecvonly:
for _, receiver := range p.receivers {
for i, receiver := range p.receivers {
codec := media.MatchCodec(receiver.Codec)
if codec == nil {
continue
@@ -219,6 +218,7 @@ func (p *Producer) reconnect(workerID, retry int) {
}
receiver.Replace(track)
p.receivers[i] = track
break
}
@@ -234,6 +234,9 @@ func (p *Producer) reconnect(workerID, retry int) {
}
}
// stop previous connection after moving tracks (fix ghost exec/ffmpeg)
_ = p.conn.Stop()
// swap connections
p.conn = conn
go p.worker(conn, workerID)

View File

@@ -88,6 +88,11 @@ func (s *Stream) RemoveProducer(prod core.Producer) {
}
func (s *Stream) stopProducers() {
if s.pending.Load() > 0 {
log.Trace().Msg("[streams] skip stop pending producer")
return
}
s.mu.Lock()
producers:
for _, producer := range s.producers {
@@ -107,19 +112,12 @@ producers:
}
func (s *Stream) MarshalJSON() ([]byte, error) {
if !s.mu.TryLock() {
log.Warn().Msgf("[streams] json locked")
return json.Marshal(nil)
}
var info struct {
var info = struct {
Producers []*Producer `json:"producers"`
Consumers []core.Consumer `json:"consumers"`
}{
Producers: s.producers,
Consumers: s.consumers,
}
info.Producers = s.producers
info.Consumers = s.consumers
s.mu.Unlock()
return json.Marshal(info)
}

View File

@@ -1,7 +1,7 @@
package streams
import (
"net/http"
"errors"
"net/url"
"regexp"
"sync"
@@ -26,7 +26,8 @@ func Init() {
streams[name] = NewStream(item)
}
api.HandleFunc("api/streams", streamsHandler)
api.HandleFunc("api/streams", apiStreams)
api.HandleFunc("api/streams.dot", apiStreamsDOT)
if cfg.Publish == nil {
return
@@ -47,9 +48,16 @@ func Get(name string) *Stream {
var sanitize = regexp.MustCompile(`\s`)
func New(name string, source string) *Stream {
// not allow creating dynamic streams with spaces in the source
// Validate - not allow creating dynamic streams with spaces in the source
func Validate(source string) error {
if sanitize.MatchString(source) {
return errors.New("streams: invalid dynamic source")
}
return nil
}
func New(name string, source string) *Stream {
if Validate(source) != nil {
return nil
}
@@ -135,77 +143,6 @@ func Delete(id string) {
delete(streams, id)
}
func streamsHandler(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
src := query.Get("src")
// without source - return all streams list
if src == "" && r.Method != "POST" {
api.ResponseJSON(w, streams)
return
}
// Not sure about all this API. Should be rewrited...
switch r.Method {
case "GET":
api.ResponsePrettyJSON(w, streams[src])
case "PUT":
name := query.Get("name")
if name == "" {
name = src
}
if New(name, src) == nil {
http.Error(w, "", http.StatusBadRequest)
return
}
if err := app.PatchConfig(name, src, "streams"); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
}
case "PATCH":
name := query.Get("name")
if name == "" {
http.Error(w, "", http.StatusBadRequest)
return
}
// support {input} templates: https://github.com/AlexxIT/go2rtc#module-hass
if Patch(name, src) == nil {
http.Error(w, "", http.StatusBadRequest)
}
case "POST":
// with dst - redirect source to dst
if dst := query.Get("dst"); dst != "" {
if stream := Get(dst); stream != nil {
if err := stream.Play(src); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
} else {
api.ResponseJSON(w, stream)
}
} else if stream = Get(src); stream != nil {
if err := stream.Publish(dst); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
} else {
http.Error(w, "", http.StatusNotFound)
}
} else {
http.Error(w, "", http.StatusBadRequest)
}
case "DELETE":
delete(streams, src)
if err := app.PatchConfig(src, nil, "streams"); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
}
}
}
var log zerolog.Logger
var streams = map[string]*Stream{}
var streamsMu sync.Mutex

View File

@@ -8,11 +8,11 @@ import (
)
func Init() {
streams.HandleFunc("kasa", func(url string) (core.Producer, error) {
return kasa.Dial(url)
streams.HandleFunc("kasa", func(source string) (core.Producer, error) {
return kasa.Dial(source)
})
streams.HandleFunc("tapo", func(url string) (core.Producer, error) {
return tapo.Dial(url)
streams.HandleFunc("tapo", func(source string) (core.Producer, error) {
return tapo.Dial(source)
})
}

View File

@@ -1,13 +1,105 @@
What you should to know about WebRTC:
- It's almost always a **direct [peer-to-peer](https://en.wikipedia.org/wiki/Peer-to-peer) connection** from your browser to go2rtc app
- When you use Home Assistant, Frigate, Nginx, Nabu Casa, Cloudflare and other software - they are only **involved in establishing** the connection, but they are **not involved in transferring** media data
- WebRTC media cannot be transferred inside an HTTP connection
- Usually, WebRTC uses random UDP ports on client and server side to establish a connection
- Usually, WebRTC uses public [STUN](https://en.wikipedia.org/wiki/STUN) servers to establish a connection outside LAN, such servers are only needed to establish a connection and are not involved in data transfer
- Usually, WebRTC will automatically discover all of your local addresses and all of your public addresses and try to establish a connection
If an external connection via STUN is used:
- Uses [UDP hole punching](https://en.wikipedia.org/wiki/UDP_hole_punching) technology to bypass NAT even if you not open your server to the World
- For about 20% of users, the techology will not work because of the [Symmetric NAT](https://tomchen.github.io/symmetric-nat-test/)
- UDP is not suitable for transmitting 2K and 4K high bitrate video over open networks because of the high loss rate
## Default config
```yaml
webrtc:
listen: ":8555/tcp"
ice_servers:
- urls: [ "stun:stun.l.google.com:19302" ]
```
## Config
- supported TCP: fixed port (default), disabled
- supported UDP: random port (default), fixed port
**Important!** This example is not for copypasting!
| Config examples | TCP | UDP |
|-----------------------|-------|--------|
| `listen: ":8555/tcp"` | fixed | random |
| `listen: ":8555"` | fixed | fixed |
| `listen: ""` | no | random |
```yaml
webrtc:
# fix local TCP or UDP or both ports for WebRTC media
listen: ":8555/tcp" # address of your local server
# add additional host candidates manually
# order is important, the first will have a higher priority
candidates:
- 216.58.210.174:8555 # if you have static public IP-address
- stun:8555 # if you have dynamic public IP-address
- home.duckdns.org:8555 # if you have domain
# add custom STUN and TURN servers
# use `ice_servers: []` for remove defaults and leave empty
ice_servers:
- urls: [ stun:stun1.l.google.com:19302 ]
- urls: [ turn:123.123.123.123:3478 ]
username: your_user
credential: your_pass
# optional filter list for auto discovery logic
# some settings only make sense if you don't specify a fixed UDP port
filters:
# list of host candidates from auto discovery to be sent
# including candidates from the `listen` option
# use `candidates: []` to remove all auto discovery candidates
candidates: [ 192.168.1.123 ]
# list of network types to be used for connection
# including candidates from the `listen` option
networks: [ udp4, udp6, tcp4, tcp6 ]
# list of interfaces to be used for connection
# not related to the `listen` option
interfaces: [ eno1 ]
# list of host IP-addresses to be used for connection
# not related to the `listen` option
ips: [ 192.168.1.123 ]
# range for random UDP ports [min, max] to be used for connection
# not related to the `listen` option
udp_ports: [ 50000, 50100 ]
```
By default go2rtc uses **fixed TCP** port and multiple **random UDP** ports for each WebRTC connection - `listen: ":8555/tcp"`.
You can set **fixed TCP** and **fixed UDP** port for all connections - `listen: ":8555"`. This may has lower performance, but it's your choice.
Don't know why, but you can disable TCP port and leave only random UDP ports - `listen: ""`.
## Config filters
Filters allow you to exclude unnecessary candidates. Extra candidates don't make your connection worse or better. But the wrong filter settings can break everything. Skip this setting if you don't understand it.
For example, go2rtc is installed on the host system. And there are unnecessary interfaces. You can keep only the relevant via `interfaces` or `ips` options. You can also exclude IPv6 candidates if your server supports them but your home network does not.
```yaml
webrtc:
listen: ":8555/tcp" # use fixed TCP port and random UDP ports
filters:
ips: [ 192.168.1.2 ] # IP-address of your server
networks: [ udp4, tcp4 ] # skip IPv6, if it's not supported for you
```
For example, go2rtc inside closed docker container (ex. [Frigate](https://frigate.video/)). You shouldn't filter docker interfaces, otherwise go2rtc will not be able to connect anywhere. But you can filter the docker candidates because no one can connect to them.
```yaml
webrtc:
listen: ":8555" # use fixed TCP and UDP ports
candidates: [ 192.168.1.2:8555 ] # add manual host candidate (use docker port forwarding)
filters:
candidates: [] # skip all internal docker candidates
```
## Userful links

View File

@@ -2,57 +2,60 @@ package webrtc
import (
"net"
"slices"
"strings"
"github.com/AlexxIT/go2rtc/internal/api/ws"
"github.com/AlexxIT/go2rtc/pkg/webrtc"
"github.com/pion/sdp/v3"
pion "github.com/pion/webrtc/v3"
)
type Address struct {
Host string
Port string
Network string
Offset int
host string
Port string
Network string
Priority uint32
}
func (a *Address) Marshal() string {
host := a.Host
if host == "stun" {
func (a *Address) Host() string {
if a.host == "stun" {
ip, err := webrtc.GetCachedPublicIP()
if err != nil {
return ""
}
host = ip.String()
return ip.String()
}
return a.host
}
switch a.Network {
case "udp":
return webrtc.CandidateManualHostUDP(host, a.Port, a.Offset)
case "tcp":
return webrtc.CandidateManualHostTCPPassive(host, a.Port, a.Offset)
func (a *Address) Marshal() string {
if host := a.Host(); host != "" {
return webrtc.CandidateICE(a.Network, host, a.Port, a.Priority)
}
return ""
}
var addresses []*Address
var filters webrtc.Filters
func AddCandidate(network, address string) {
if network == "" {
AddCandidate("tcp", address)
AddCandidate("udp", address)
return
}
func AddCandidate(address, network string) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return
}
offset := -1 - len(addresses) // every next candidate will have a lower priority
// start from 1, so manual candidates will be lower than built-in
// and every next candidate will have a lower priority
candidateIndex := 1 + len(addresses)
switch network {
case "tcp", "udp":
addresses = append(addresses, &Address{host, port, network, offset})
default:
addresses = append(
addresses, &Address{host, port, "udp", offset}, &Address{host, port, "tcp", offset},
)
}
priority := webrtc.CandidateHostPriority(network, candidateIndex)
addresses = append(addresses, &Address{host, port, network, priority})
}
func GetCandidates() (candidates []string) {
@@ -64,6 +67,38 @@ func GetCandidates() (candidates []string) {
return
}
// FilterCandidate return true if candidate passed the check
func FilterCandidate(candidate *pion.ICECandidate) bool {
if candidate == nil {
return false
}
// host candidate should be in the hosts list
if candidate.Typ == pion.ICECandidateTypeHost && filters.Candidates != nil {
if !slices.Contains(filters.Candidates, candidate.Address) {
return false
}
}
if filters.Networks != nil {
networkType := NetworkType(candidate.Protocol.String(), candidate.Address)
if !slices.Contains(filters.Networks, networkType) {
return false
}
}
return true
}
// NetworkType convert tcp/udp network to tcp4/tcp6/udp4/udp6
func NetworkType(network, host string) string {
if strings.IndexByte(host, ':') >= 0 {
return network + "6"
} else {
return network + "4"
}
}
func asyncCandidates(tr *ws.Transport, cons *webrtc.Conn) {
tr.WithContext(func(ctx map[any]any) {
if candidates, ok := ctx["candidate"].([]string); ok {
@@ -86,30 +121,6 @@ func asyncCandidates(tr *ws.Transport, cons *webrtc.Conn) {
}
}
func syncCanditates(answer string) (string, error) {
if len(addresses) == 0 {
return answer, nil
}
sd := &sdp.SessionDescription{}
if err := sd.Unmarshal([]byte(answer)); err != nil {
return "", err
}
md := sd.MediaDescriptions[0]
for _, candidate := range GetCandidates() {
md.WithPropertyAttribute(candidate)
}
data, err := sd.Marshal()
if err != nil {
return "", err
}
return string(data), nil
}
func candidateHandler(tr *ws.Transport, msg *ws.Message) error {
// process incoming candidate in sync function
tr.WithContext(func(ctx map[any]any) {

View File

@@ -41,7 +41,7 @@ func streamsHandler(rawURL string) (core.Producer, error) {
// https://aws.amazon.com/kinesis/video-streams/
// https://docs.aws.amazon.com/kinesisvideostreams-webrtc-dg/latest/devguide/what-is-kvswebrtc.html
// https://github.com/orgs/awslabs/repositories?q=kinesis+webrtc
return kinesisClient(rawURL, query, "WebRTC/Kinesis")
return kinesisClient(rawURL, query, "webrtc/kinesis")
} else if format == "openipc" {
return openIPCClient(rawURL, query)
} else {
@@ -77,17 +77,23 @@ func go2rtcClient(url string) (core.Producer, error) {
// 2. Create PeerConnection
pc, err := PeerConnection(true)
if err != nil {
log.Error().Err(err).Caller().Send()
return nil, err
}
defer func() {
if err != nil {
_ = pc.Close()
}
}()
// waiter will wait PC error or WS error or nil (connection OK)
var connState core.Waiter
var connMu sync.Mutex
prod := webrtc.NewConn(pc)
prod.Desc = "WebRTC/WebSocket async"
prod.Mode = core.ModeActiveProducer
prod.Protocol = "ws"
prod.URL = url
prod.Listen(func(msg any) {
switch msg := msg.(type) {
case *pion.ICECandidate:
@@ -132,7 +138,8 @@ func go2rtcClient(url string) (core.Producer, error) {
}
if msg.Type != "webrtc/answer" {
return nil, errors.New("wrong answer: " + msg.Type)
err = errors.New("wrong answer: " + msg.String())
return nil, err
}
answer := msg.String()
@@ -180,8 +187,9 @@ func whepClient(url string) (core.Producer, error) {
}
prod := webrtc.NewConn(pc)
prod.Desc = "WebRTC/WHEP sync"
prod.Mode = core.ModeActiveProducer
prod.Protocol = "http"
prod.URL = url
medias := []*core.Media{
{Kind: core.KindVideo, Direction: core.DirectionRecvonly},

View File

@@ -34,7 +34,7 @@ func (k kinesisResponse) String() string {
return fmt.Sprintf("type=%s, payload=%s", k.Type, k.Payload)
}
func kinesisClient(rawURL string, query url.Values, desc string) (core.Producer, error) {
func kinesisClient(rawURL string, query url.Values, format string) (core.Producer, error) {
// 1. Connect to signalign server
conn, _, err := websocket.DefaultDialer.Dial(rawURL, nil)
if err != nil {
@@ -79,8 +79,10 @@ func kinesisClient(rawURL string, query url.Values, desc string) (core.Producer,
}
prod := webrtc.NewConn(pc)
prod.Desc = desc
prod.FormatName = format
prod.Mode = core.ModeActiveProducer
prod.Protocol = "ws"
prod.URL = rawURL
prod.Listen(func(msg any) {
switch msg := msg.(type) {
case *pion.ICECandidate:
@@ -216,5 +218,5 @@ func wyzeClient(rawURL string) (core.Producer, error) {
"ice_servers": []string{string(kvs.Servers)},
}
return kinesisClient(kvs.URL, query, "WebRTC/Wyze")
return kinesisClient(kvs.URL, query, "webrtc/wyze")
}

View File

@@ -193,8 +193,10 @@ func milestoneClient(rawURL string, query url.Values) (core.Producer, error) {
}
prod := webrtc.NewConn(pc)
prod.Desc = "WebRTC/Milestone"
prod.FormatName = "webrtc/milestone"
prod.Mode = core.ModeActiveProducer
prod.Protocol = "http"
prod.URL = rawURL
offer, err := mc.GetOffer()
if err != nil {

View File

@@ -53,8 +53,10 @@ func openIPCClient(rawURL string, query url.Values) (core.Producer, error) {
var connState core.Waiter
prod := webrtc.NewConn(pc)
prod.Desc = "WebRTC/OpenIPC"
prod.FormatName = "webrtc/openipc"
prod.Mode = core.ModeActiveProducer
prod.Protocol = "ws"
prod.URL = rawURL
prod.Listen(func(msg any) {
switch msg := msg.(type) {
case *pion.ICECandidate:

View File

@@ -65,6 +65,7 @@ func outputWebRTC(w http.ResponseWriter, r *http.Request) {
url := r.URL.Query().Get("src")
stream := streams.Get(url)
if stream == nil {
http.Error(w, api.StreamNotFound, http.StatusNotFound)
return
}
@@ -100,11 +101,11 @@ func outputWebRTC(w http.ResponseWriter, r *http.Request) {
switch mediaType {
case "application/json":
desc = "WebRTC/JSON sync"
desc = "webrtc/json"
case MimeSDP:
desc = "WebRTC/WHEP sync"
desc = "webrtc/whep"
default:
desc = "WebRTC/HTTP sync"
desc = "webrtc/post"
}
answer, err := ExchangeSDP(stream, offer, desc, r.UserAgent())
@@ -168,8 +169,8 @@ func inputWebRTC(w http.ResponseWriter, r *http.Request) {
// create new webrtc instance
prod := webrtc.NewConn(pc)
prod.Desc = "WebRTC/WHIP sync"
prod.Mode = core.ModePassiveProducer
prod.Protocol = "http"
prod.UserAgent = r.UserAgent()
if err = prod.SetOffer(string(offer)); err != nil {
@@ -178,10 +179,7 @@ func inputWebRTC(w http.ResponseWriter, r *http.Request) {
return
}
answer, err := prod.GetCompleteAnswer()
if err == nil {
answer, err = syncCanditates(answer)
}
answer, err := prod.GetCompleteAnswer(GetCandidates(), FilterCandidate)
if err != nil {
log.Warn().Err(err).Caller().Send()
http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@@ -20,6 +20,7 @@ func Init() {
Listen string `yaml:"listen"`
Candidates []string `yaml:"candidates"`
IceServers []pion.ICEServer `yaml:"ice_servers"`
Filters webrtc.Filters `yaml:"filters"`
} `yaml:"webrtc"`
}
@@ -32,20 +33,15 @@ func Init() {
log = app.GetLogger("webrtc")
filters = cfg.Mod.Filters
address, network, _ := strings.Cut(cfg.Mod.Listen, "/")
var candidateHost []string
for _, candidate := range cfg.Mod.Candidates {
if strings.HasPrefix(candidate, "host:") {
candidateHost = append(candidateHost, candidate[5:])
continue
}
AddCandidate(candidate, network)
AddCandidate(network, candidate)
}
// create pionAPI with custom codecs list and custom network settings
serverAPI, err := webrtc.NewServerAPI(address, network, candidateHost)
serverAPI, err := webrtc.NewServerAPI(network, address, &filters)
if err != nil {
log.Error().Err(err).Caller().Send()
return
@@ -55,8 +51,7 @@ func Init() {
clientAPI := serverAPI
if address != "" {
log.Info().Str("addr", address).Msg("[webrtc] listen")
log.Info().Str("addr", cfg.Mod.Listen).Msg("[webrtc] listen")
clientAPI, _ = webrtc.NewAPI()
}
@@ -122,8 +117,8 @@ func asyncHandler(tr *ws.Transport, msg *ws.Message) error {
defer sendAnswer.Done(nil)
conn := webrtc.NewConn(pc)
conn.Desc = "WebRTC/WebSocket async"
conn.Mode = mode
conn.Protocol = "ws"
conn.UserAgent = tr.Request.UserAgent()
conn.Listen(func(msg any) {
switch msg := msg.(type) {
@@ -139,6 +134,9 @@ func asyncHandler(tr *ws.Transport, msg *ws.Message) error {
}
case *pion.ICECandidate:
if !FilterCandidate(msg) {
return
}
_ = sendAnswer.Wait()
s := msg.ToJSON().Candidate
@@ -209,8 +207,9 @@ func ExchangeSDP(stream *streams.Stream, offer, desc, userAgent string) (answer
// create new webrtc instance
conn := webrtc.NewConn(pc)
conn.Desc = desc
conn.FormatName = desc
conn.UserAgent = userAgent
conn.Protocol = "http"
conn.Listen(func(msg any) {
switch msg := msg.(type) {
case pion.PeerConnectionState:
@@ -248,10 +247,7 @@ func ExchangeSDP(stream *streams.Stream, offer, desc, userAgent string) (answer
stream.AddProducer(conn)
}
answer, err = conn.GetCompleteAnswer()
if err == nil {
answer, err = syncCanditates(answer)
}
answer, err = conn.GetCompleteAnswer(GetCandidates(), FilterCandidate)
log.Trace().Msgf("[webrtc] answer\n%s", answer)
if err != nil {

View File

@@ -47,7 +47,7 @@ func Init() {
if stream == nil {
return "", errors.New(api.StreamNotFound)
}
return webrtc.ExchangeSDP(stream, offer, "WebRTC/WebTorrent sync", "")
return webrtc.ExchangeSDP(stream, offer, "webtorrent", "")
},
}

View File

@@ -36,6 +36,8 @@ import (
)
func main() {
app.Version = "1.9.4"
// 1. Core modules: app, api/ws, streams
app.Init() // init config and logs

View File

@@ -1,3 +1,85 @@
# Notes
go2rtc tries to name formats, protocols and codecs the same way they are named in FFmpeg.
Some formats and protocols go2rtc supports exclusively. They have no equivalent in FFmpeg.
## Producers (input)
- The initiator of the connection can be go2rtc - **Source protocols**
- The initiator of the connection can be an external program - **Ingress protocols**
- Codecs can be incoming - **Recevers codecs**
- Codecs can be outgoing (two way audio) - **Senders codecs**
| Format | Source protocols | Ingress protocols | Recevers codecs | Senders codecs | Example |
|--------------|------------------|-------------------|------------------------------|--------------------|---------------|
| adts | http,tcp,pipe | http | aac | | `http:` |
| bubble | http | | h264,hevc,pcm_alaw | | `bubble:` |
| dvrip | tcp | | h264,hevc,pcm_alaw,pcm_mulaw | pcm_alaw | `dvrip:` |
| flv | http,tcp,pipe | http | h264,aac | | `http:` |
| gopro | http+udp | | TODO | | `gopro:` |
| hass/webrtc | ws+udp,tcp | | TODO | | `hass:` |
| hls/mpegts | http | | h264,h265,aac,opus | | `http:` |
| homekit | homekit+udp | | h264,eld* | | `homekit:` |
| isapi | http | | | pcm_alaw,pcm_mulaw | `isapi:` |
| ivideon | ws | | h264 | | `ivideon:` |
| kasa | http | | h264,pcm_mulaw | | `kasa:` |
| h264 | http,tcp,pipe | http | h264 | | `http:` |
| hevc | http,tcp,pipe | http | hevc | | `http:` |
| mjpeg | http,tcp,pipe | http | mjpeg | | `http:` |
| mpjpeg | http,tcp,pipe | http | mjpeg | | `http:` |
| mpegts | http,tcp,pipe | http | h264,hevc,aac,opus | | `http:` |
| nest/webrtc | http+udp | | TODO | | `nest:` |
| roborock | mqtt+udp | | h264,opus | opus | `roborock:` |
| rtmp | rtmp | rtmp | h264,aac | | `rtmp:` |
| rtsp | rtsp+tcp,ws | rtsp+tcp | h264,hevc,aac,pcm*,opus | pcm*,opus | `rtsp:` |
| stdin | pipe | | | pcm_alaw,pcm_mulaw | `stdin:` |
| tapo | http | | h264,pcma | pcm_alaw | `tapo:` |
| wav | http,tcp,pipe | http | pcm_alaw,pcm_mulaw | | `http:` |
| webrtc* | TODO | TODO | h264,pcm_alaw,pcm_mulaw,opus | pcm_alaw,pcm_mulaw | `webrtc:` |
| webtorrent | TODO | TODO | TODO | TODO | `webtorrent:` |
| yuv4mpegpipe | http,tcp,pipe | http | rawvideo | | `http:` |
- **eld** - rare variant of aac codec
- **pcm** - pcm_alaw pcm_mulaw pcm_s16be pcm_s16le
- **webrtc** - webrtc/kinesis, webrtc/openipc, webrtc/milestone, webrtc/wyze, webrtc/whep
## Consumers (output)
| Format | Protocol | Send codecs | Recv codecs | Example |
|--------------|-------------|------------------------------|-------------------------|---------------------------------------|
| adts | http | aac | | `GET /api/stream.adts` |
| ascii | http | mjpeg | | `GET /api/stream.ascii` |
| flv | http | h264,aac | | `GET /api/stream.flv` |
| hls/mpegts | http | h264,hevc,aac | | `GET /api/stream.m3u8` |
| hls/fmp4 | http | h264,hevc,aac,pcm*,opus | | `GET /api/stream.m3u8?mp4` |
| homekit | homekit+udp | h264,opus | | Apple HomeKit app |
| mjpeg | ws | mjpeg | | `{"type":"mjpeg"}` -> `/api/ws` |
| mpjpeg | http | mjpeg | | `GET /api/stream.mjpeg` |
| mp4 | http | h264,hevc,aac,pcm*,opus | | `GET /api/stream.mp4` |
| mse/fmp4 | ws | h264,hevc,aac,pcm*,opus | | `{"type":"mse"}` -> `/api/ws` |
| mpegts | http | h264,hevc,aac | | `GET /api/stream.ts` |
| rtmp | rtmp | h264,aac | | `rtmp://localhost:1935/{stream_name}` |
| rtsp | rtsp+tcp | h264,hevc,aac,pcm*,opus | | `rtsp://localhost:8554/{stream_name}` |
| webrtc | TODO | h264,pcm_alaw,pcm_mulaw,opus | pcm_alaw,pcm_mulaw,opus | `{"type":"webrtc"}` -> `/api/ws` |
| yuv4mpegpipe | http | rawvideo | | `GET /api/stream.y4m` |
- **pcm** - pcm_alaw pcm_mulaw pcm_s16be pcm_s16le
## Snapshots
| Format | Protocol | Send codecs | Example |
|--------|----------|-------------|-----------------------|
| jpeg | http | mjpeg | `GET /api/frame.jpeg` |
| mp4 | http | h264,hevc | `GET /api/frame.mp4` |
## Developers
File naming:
- `pkg/{format}/producer.go` - producer for this format (also if support backchannel)
- `pkg/{format}/consumer.go` - consumer for this format
- `pkg/{format}/backchanel.go` - producer with only backchannel func
## Useful links
- https://www.wowza.com/blog/streaming-protocols

View File

@@ -69,6 +69,8 @@ func DecodeConfig(b []byte) (objType, sampleFreqIdx, channels byte, sampleRate u
sampleFreqIdx = rd.ReadBits8(4)
if sampleFreqIdx == 0b1111 {
sampleRate = rd.ReadBits(24)
} else {
sampleRate = sampleRates[sampleFreqIdx]
}
channels = rd.ReadBits8(4)

View File

@@ -41,3 +41,12 @@ func TestADTS(t *testing.T) {
require.Equal(t, src[:len(dst)], dst)
}
func TestEncodeConfig(t *testing.T) {
conf := EncodeConfig(TypeAACLC, 48000, 1, false)
require.Equal(t, "1188", hex.EncodeToString(conf))
conf = EncodeConfig(TypeAACLC, 16000, 1, false)
require.Equal(t, "1408", hex.EncodeToString(conf))
conf = EncodeConfig(TypeAACLC, 8000, 1, false)
require.Equal(t, "1588", hex.EncodeToString(conf))
}

View File

@@ -8,15 +8,12 @@ import (
)
type Consumer struct {
core.SuperConsumer
core.Connection
wr *core.WriteBuffer
}
func NewConsumer() *Consumer {
cons := &Consumer{
wr: core.NewWriteBuffer(nil),
}
cons.Medias = []*core.Media{
medias := []*core.Media{
{
Kind: core.KindAudio,
Direction: core.DirectionSendonly,
@@ -25,7 +22,16 @@ func NewConsumer() *Consumer {
},
},
}
return cons
wr := core.NewWriteBuffer(nil)
return &Consumer{
Connection: core.Connection{
ID: core.NewID(),
FormatName: "adts",
Medias: medias,
Transport: wr,
},
wr: wr,
}
}
func (c *Consumer) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error {
@@ -51,8 +57,3 @@ func (c *Consumer) AddTrack(media *core.Media, codec *core.Codec, track *core.Re
func (c *Consumer) WriteTo(wr io.Writer) (int64, error) {
return c.wr.WriteTo(wr)
}
func (c *Consumer) Stop() error {
_ = c.SuperConsumer.Close()
return c.wr.Close()
}

View File

@@ -10,9 +10,8 @@ import (
)
type Producer struct {
core.SuperProducer
core.Connection
rd *bufio.Reader
cl io.Closer
}
func Open(r io.Reader) (*Producer, error) {
@@ -23,18 +22,22 @@ func Open(r io.Reader) (*Producer, error) {
return nil, err
}
codec := ADTSToCodec(b)
prod := &Producer{rd: rd, cl: r.(io.Closer)}
prod.Type = "ADTS producer"
prod.Medias = []*core.Media{
medias := []*core.Media{
{
Kind: core.KindAudio,
Direction: core.DirectionRecvonly,
Codecs: []*core.Codec{codec},
Codecs: []*core.Codec{ADTSToCodec(b)},
},
}
return prod, nil
return &Producer{
Connection: core.Connection{
ID: core.NewID(),
FormatName: "adts",
Medias: medias,
Transport: r,
},
rd: rd,
}, nil
}
func (c *Producer) Start() error {
@@ -66,8 +69,3 @@ func (c *Producer) Start() error {
c.Receivers[0].WriteRTP(pkt)
}
}
func (c *Producer) Stop() error {
_ = c.SuperProducer.Close()
return c.cl.Close()
}

6
pkg/ascii/README.md Normal file
View File

@@ -0,0 +1,6 @@
## Useful links
- https://en.wikipedia.org/wiki/ANSI_escape_code
- https://paulbourke.net/dataformats/asciiart/
- https://github.com/kutuluk/xterm-color-chart
- https://github.com/hugomd/parrot.live

173
pkg/ascii/ascii.go Normal file
View File

@@ -0,0 +1,173 @@
package ascii
import (
"bytes"
"fmt"
"image/jpeg"
"io"
"net/http"
"unicode/utf8"
)
func NewWriter(w io.Writer, foreground, background, text string) io.Writer {
// once clear screen
_, _ = w.Write([]byte(csiClear))
// every frame - move to home
a := &writer{wr: w, buf: []byte(csiHome)}
// https://en.wikipedia.org/wiki/ANSI_escape_code
switch foreground {
case "":
case "8":
a.color = func(r, g, b uint8) {
idx := xterm256color(r, g, b, 8)
a.appendEsc(fmt.Sprintf("\033[%dm", 30+idx))
}
case "256":
a.color = func(r, g, b uint8) {
idx := xterm256color(r, g, b, 255)
a.appendEsc(fmt.Sprintf("\033[38;5;%dm", idx))
}
case "rgb":
a.color = func(r, g, b uint8) {
a.appendEsc(fmt.Sprintf("\033[38;2;%d;%d;%dm", r, g, b))
}
default:
a.buf = append(a.buf, "\033["+foreground+"m"...)
}
switch background {
case "":
case "8":
a.color = func(r, g, b uint8) {
idx := xterm256color(r, g, b, 8)
a.appendEsc(fmt.Sprintf("\033[%dm", 40+idx))
}
case "256":
a.color = func(r, g, b uint8) {
idx := xterm256color(r, g, b, 255)
a.appendEsc(fmt.Sprintf("\033[48;5;%dm", idx))
}
case "rgb":
a.color = func(r, g, b uint8) {
a.appendEsc(fmt.Sprintf("\033[48;2;%d;%d;%dm", r, g, b))
}
default:
a.buf = append(a.buf, "\033["+background+"m"...)
}
a.pre = len(a.buf) // save prefix size
if len(text) == 1 {
// fast 1 symbol version
a.text = func(_, _, _ uint32) {
a.buf = append(a.buf, text[0])
}
} else {
switch text {
case "":
text = ` .::--~~==++**##%%$@` // default for empty text
case "block":
text = " ░░▒▒▓▓█" // https://en.wikipedia.org/wiki/Block_Elements
}
if runes := []rune(text); len(runes) != len(text) {
k := float32(len(runes)-1) / 255
a.text = func(r, g, b uint32) {
i := gray(r, g, b, k)
a.buf = utf8.AppendRune(a.buf, runes[i])
}
} else {
k := float32(len(text)-1) / 255
a.text = func(r, g, b uint32) {
i := gray(r, g, b, k)
a.buf = append(a.buf, text[i])
}
}
}
return a
}
type writer struct {
wr io.Writer
buf []byte
pre int
esc string
color func(r, g, b uint8)
text func(r, g, b uint32)
}
// https://stackoverflow.com/questions/37774983/clearing-the-screen-by-printing-a-character
const csiClear = "\033[2J"
const csiHome = "\033[H"
func (a *writer) Write(p []byte) (n int, err error) {
img, err := jpeg.Decode(bytes.NewReader(p))
if err != nil {
return 0, err
}
a.buf = a.buf[:a.pre] // restore prefix
w := img.Bounds().Dx()
h := img.Bounds().Dy()
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
r, g, b, _ := img.At(x, y).RGBA()
if a.color != nil {
a.color(uint8(r>>8), uint8(g>>8), uint8(b>>8))
}
a.text(r, g, b)
}
a.buf = append(a.buf, '\n')
}
a.appendEsc("\033[0m")
if _, err = a.wr.Write(a.buf); err != nil {
return 0, err
}
a.wr.(http.Flusher).Flush()
return len(p), nil
}
// appendEsc - append ESC code to buffer, and skip duplicates
func (a *writer) appendEsc(s string) {
if a.esc != s {
a.esc = s
a.buf = append(a.buf, s...)
}
}
func gray(r, g, b uint32, k float32) uint8 {
gr := (19595*r + 38470*g + 7471*b + 1<<15) >> 24 // uint8
return uint8(float32(gr) * k)
}
const x256r = "\x00\x80\x00\x80\x00\x80\x00\xc0\x80\xff\x00\xff\x00\xff\x00\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x08\x12\x1c\x26\x30\x3a\x44\x4e\x58\x60\x66\x76\x80\x8a\x94\x9e\xa8\xb2\xbc\xc6\xd0\xda\xe4\xee"
const x256g = "\x00\x00\x80\x80\x00\x00\x80\xc0\x80\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x5f\x5f\x5f\x5f\x5f\x5f\x87\x87\x87\x87\x87\x87\xaf\xaf\xaf\xaf\xaf\xaf\xd7\xd7\xd7\xd7\xd7\xd7\xff\xff\xff\xff\xff\xff\x08\x12\x1c\x26\x30\x3a\x44\x4e\x58\x60\x66\x76\x80\x8a\x94\x9e\xa8\xb2\xbc\xc6\xd0\xda\xe4\xee"
const x256b = "\x00\x00\x00\x00\x80\x80\x80\xc0\x80\x00\x00\x00\xff\xff\xff\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x00\x5f\x87\xaf\xd7\xff\x08\x12\x1c\x26\x30\x3a\x44\x4e\x58\x60\x66\x76\x80\x8a\x94\x9e\xa8\xb2\xbc\xc6\xd0\xda\xe4\xee"
func xterm256color(r, g, b uint8, n int) (index uint8) {
best := uint16(0xFFFF)
for i := 0; i < n; i++ {
diff := sqDiff(r, x256r[i]) + sqDiff(g, x256g[i]) + sqDiff(b, x256b[i])
if diff < best {
best = diff
index = uint8(i)
}
}
return
}
// sqDiff - just like from image/color/color.go
func sqDiff(x, y uint8) uint16 {
d := uint16(x - y)
//return d
return (d * d) >> 2
}

View File

@@ -131,3 +131,7 @@ func (r *Reader) ReadSEGolomb() int32 {
func (r *Reader) Left() []byte {
return r.buf[r.pos:]
}
func (r *Reader) Pos() (int, byte) {
return r.pos - 1, r.bits
}

View File

@@ -22,6 +22,7 @@ import (
"github.com/pion/rtp"
)
// Deprecated: should be rewritten to core.Connection
type Client struct {
core.Listener
@@ -43,8 +44,12 @@ type Client struct {
recv int
}
func NewClient(url string) *Client {
return &Client{url: url}
func Dial(rawURL string) (*Client, error) {
client := &Client{url: rawURL}
if err := client.Dial(); err != nil {
return nil, err
}
return client, nil
}
const (

View File

@@ -65,11 +65,16 @@ func (c *Client) Stop() error {
}
func (c *Client) MarshalJSON() ([]byte, error) {
info := &core.Info{
Type: "Bubble active producer",
Medias: c.medias,
Recv: c.recv,
Receivers: c.receivers,
info := &core.Connection{
ID: core.ID(c),
FormatName: "bubble",
Protocol: "http",
Medias: c.medias,
Recv: c.recv,
Receivers: c.receivers,
}
if c.conn != nil {
info.RemoteAddr = c.conn.RemoteAddr().String()
}
return json.Marshal(info)
}

View File

@@ -2,8 +2,8 @@ package core
import (
"encoding/base64"
"encoding/json"
"fmt"
"strconv"
"strings"
"unicode"
@@ -18,34 +18,76 @@ type Codec struct {
PayloadType uint8
}
func (c *Codec) String() string {
s := fmt.Sprintf("%d %s", c.PayloadType, c.Name)
// MarshalJSON - return FFprobe compatible output
func (c *Codec) MarshalJSON() ([]byte, error) {
info := map[string]any{}
if name := FFmpegCodecName(c.Name); name != "" {
info["codec_name"] = name
info["codec_type"] = c.Kind()
}
if c.Name == CodecH264 {
profile, level := DecodeH264(c.FmtpLine)
if profile != "" {
info["profile"] = profile
info["level"] = level
}
}
if c.ClockRate != 0 && c.ClockRate != 90000 {
s = fmt.Sprintf("%s/%d", s, c.ClockRate)
info["sample_rate"] = c.ClockRate
}
if c.Channels > 0 {
s = fmt.Sprintf("%s/%d", s, c.Channels)
info["channels"] = c.Channels
}
return s
return json.Marshal(info)
}
func (c *Codec) Text() string {
switch c.Name {
func FFmpegCodecName(name string) string {
switch name {
case CodecH264:
if profile := DecodeH264(c.FmtpLine); profile != "" {
return "H.264 " + profile
}
return c.Name
return "h264"
case CodecH265:
return "hevc"
case CodecJPEG:
return "mjpeg"
case CodecRAW:
return "rawvideo"
case CodecPCMA:
return "pcm_alaw"
case CodecPCMU:
return "pcm_mulaw"
case CodecPCM:
return "pcm_s16be"
case CodecPCML:
return "pcm_s16le"
case CodecAAC:
return "aac"
case CodecOpus:
return "opus"
case CodecVP8:
return "vp8"
case CodecVP9:
return "vp9"
case CodecAV1:
return "av1"
case CodecELD:
return "aac/eld"
case CodecFLAC:
return "flac"
case CodecMP3:
return "mp3"
}
return name
}
s := c.Name
func (c *Codec) String() (s string) {
s = c.Name
if c.ClockRate != 0 && c.ClockRate != 90000 {
s += "/" + strconv.Itoa(int(c.ClockRate))
s += fmt.Sprintf("/%d", c.ClockRate)
}
if c.Channels > 0 {
s += "/" + strconv.Itoa(int(c.Channels))
s += fmt.Sprintf("/%d", c.Channels)
}
return s
return
}
func (c *Codec) IsRTP() bool {
@@ -181,10 +223,9 @@ func UnmarshalCodec(md *sdp.MediaDescription, payloadType string) *Codec {
return c
}
func DecodeH264(fmtp string) string {
func DecodeH264(fmtp string) (profile string, level byte) {
if ps := Between(fmtp, "sprop-parameter-sets=", ","); ps != "" {
if sps, _ := base64.StdEncoding.DecodeString(ps); len(sps) >= 4 {
var profile string
switch sps[1] {
case 0x42:
profile = "Baseline"
@@ -198,8 +239,8 @@ func DecodeH264(fmtp string) string {
profile = fmt.Sprintf("0x%02X", sps[1])
}
return fmt.Sprintf("%s %d.%d", profile, sps[3]/10, sps[3]%10)
level = sps[3]
}
}
return ""
return
}

139
pkg/core/connection.go Normal file
View File

@@ -0,0 +1,139 @@
package core
import (
"io"
"net/http"
"reflect"
"sync/atomic"
)
func NewID() uint32 {
return id.Add(1)
}
// Deprecated: use NewID instead
func ID(v any) uint32 {
p := uintptr(reflect.ValueOf(v).UnsafePointer())
return 0x8000_0000 | uint32(p)
}
var id atomic.Uint32
type Info interface {
SetProtocol(string)
SetRemoteAddr(string)
SetSource(string)
SetURL(string)
WithRequest(*http.Request)
}
// Connection just like webrtc.PeerConnection
// - ID and RemoteAddr used for building Connection(s) graph
// - FormatName, Protocol, RemoteAddr, Source, URL, SDP, UserAgent used for info about Connection
// - FormatName and Protocol has FFmpeg compatible names
// - Transport used for auto closing on Stop
type Connection struct {
ID uint32 `json:"id,omitempty"`
FormatName string `json:"format_name,omitempty"` // rtsp, webrtc, mp4, mjpeg, mpjpeg...
Protocol string `json:"protocol,omitempty"` // tcp, udp, http, ws, pipe...
RemoteAddr string `json:"remote_addr,omitempty"` // host:port other info
Source string `json:"source,omitempty"`
URL string `json:"url,omitempty"`
SDP string `json:"sdp,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
Medias []*Media `json:"medias,omitempty"`
Receivers []*Receiver `json:"receivers,omitempty"`
Senders []*Sender `json:"senders,omitempty"`
Recv int `json:"bytes_recv,omitempty"`
Send int `json:"bytes_send,omitempty"`
Transport any `json:"-"`
}
func (c *Connection) GetMedias() []*Media {
return c.Medias
}
func (c *Connection) GetTrack(media *Media, codec *Codec) (*Receiver, error) {
for _, receiver := range c.Receivers {
if receiver.Codec == codec {
return receiver, nil
}
}
receiver := NewReceiver(media, codec)
c.Receivers = append(c.Receivers, receiver)
return receiver, nil
}
func (c *Connection) Stop() error {
for _, receiver := range c.Receivers {
receiver.Close()
}
for _, sender := range c.Senders {
sender.Close()
}
if closer, ok := c.Transport.(io.Closer); ok {
return closer.Close()
}
return nil
}
// Deprecated:
func (c *Connection) Codecs() []*Codec {
codecs := make([]*Codec, len(c.Senders))
for i, sender := range c.Senders {
codecs[i] = sender.Codec
}
return codecs
}
func (c *Connection) SetProtocol(s string) {
c.Protocol = s
}
func (c *Connection) SetRemoteAddr(s string) {
if c.RemoteAddr == "" {
c.RemoteAddr = s
} else {
c.RemoteAddr += " forwarded " + s
}
}
func (c *Connection) SetSource(s string) {
c.Source = s
}
func (c *Connection) SetURL(s string) {
c.URL = s
}
func (c *Connection) WithRequest(r *http.Request) {
if r.Header.Get("Upgrade") == "websocket" {
c.Protocol = "ws"
} else {
c.Protocol = "http"
}
c.RemoteAddr = r.RemoteAddr
if remote := r.Header.Get("X-Forwarded-For"); remote != "" {
c.RemoteAddr += " forwarded " + remote
}
c.UserAgent = r.UserAgent()
}
// Create like os.Create, init Consumer with existing Transport
func Create(w io.Writer) (*Connection, error) {
return &Connection{Transport: w}, nil
}
// Open like os.Open, init Producer from existing Transport
func Open(r io.Reader) (*Connection, error) {
return &Connection{Transport: r}, nil
}
// Dial like net.Dial, init Producer via Dialing
func Dial(rawURL string) (*Connection, error) {
return &Connection{}, nil
}

View File

@@ -1,5 +1,7 @@
package core
import "encoding/json"
const (
DirectionRecvonly = "recvonly"
DirectionSendonly = "sendonly"
@@ -18,6 +20,7 @@ const (
CodecVP9 = "VP9"
CodecAV1 = "AV1"
CodecJPEG = "JPEG" // payloadType: 26
CodecRAW = "RAW"
CodecPCMU = "PCMU" // payloadType: 0
CodecPCMA = "PCMA" // payloadType: 8
@@ -89,89 +92,6 @@ func (m Mode) String() string {
return "unknown"
}
type Info struct {
Type string `json:"type,omitempty"`
URL string `json:"url,omitempty"`
RemoteAddr string `json:"remote_addr,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
SDP string `json:"sdp,omitempty"`
Medias []*Media `json:"medias,omitempty"`
Receivers []*Receiver `json:"receivers,omitempty"`
Senders []*Sender `json:"senders,omitempty"`
Recv int `json:"recv,omitempty"`
Send int `json:"send,omitempty"`
}
const (
UnsupportedCodec = "unsupported codec"
WrongMediaDirection = "wrong media direction"
)
type SuperProducer struct {
Type string `json:"type,omitempty"`
URL string `json:"url,omitempty"`
SDP string `json:"sdp,omitempty"`
Medias []*Media `json:"medias,omitempty"`
Receivers []*Receiver `json:"receivers,omitempty"`
Recv int `json:"recv,omitempty"`
}
func (s *SuperProducer) GetMedias() []*Media {
return s.Medias
}
func (s *SuperProducer) GetTrack(media *Media, codec *Codec) (*Receiver, error) {
for _, receiver := range s.Receivers {
if receiver.Codec == codec {
return receiver, nil
}
}
receiver := NewReceiver(media, codec)
s.Receivers = append(s.Receivers, receiver)
return receiver, nil
}
func (s *SuperProducer) Close() error {
for _, receiver := range s.Receivers {
receiver.Close()
}
return nil
}
type SuperConsumer struct {
Type string `json:"type,omitempty"`
URL string `json:"url,omitempty"`
RemoteAddr string `json:"remote_addr,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
SDP string `json:"sdp,omitempty"`
Medias []*Media `json:"medias,omitempty"`
Senders []*Sender `json:"senders,omitempty"`
Send int `json:"send,omitempty"`
}
func (s *SuperConsumer) GetMedias() []*Media {
return s.Medias
}
func (s *SuperConsumer) AddTrack(media *Media, codec *Codec, track *Receiver) error {
return nil
}
//func (b *SuperConsumer) WriteTo(w io.Writer) (n int64, err error) {
// return 0, nil
//}
func (s *SuperConsumer) Close() error {
for _, sender := range s.Senders {
sender.Close()
}
return nil
}
func (s *SuperConsumer) Codecs() []*Codec {
codecs := make([]*Codec, len(s.Senders))
for i, sender := range s.Senders {
codecs[i] = sender.Codec
}
return codecs
func (m Mode) MarshalJSON() ([]byte, error) {
return json.Marshal(m.String())
}

120
pkg/core/core_test.go Normal file
View File

@@ -0,0 +1,120 @@
package core
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
)
type producer struct {
Medias []*Media
Receivers []*Receiver
id byte
}
func (p *producer) GetMedias() []*Media {
return p.Medias
}
func (p *producer) GetTrack(_ *Media, codec *Codec) (*Receiver, error) {
for _, receiver := range p.Receivers {
if receiver.Codec == codec {
return receiver, nil
}
}
receiver := NewReceiver(nil, codec)
p.Receivers = append(p.Receivers, receiver)
return receiver, nil
}
func (p *producer) Start() error {
pkt := &Packet{Payload: []byte{p.id}}
p.Receivers[0].Input(pkt)
return nil
}
func (p *producer) Stop() error {
for _, receiver := range p.Receivers {
receiver.Close()
}
return nil
}
type consumer struct {
Medias []*Media
Senders []*Sender
cache chan byte
}
func (c *consumer) GetMedias() []*Media {
return c.Medias
}
func (c *consumer) AddTrack(_ *Media, _ *Codec, track *Receiver) error {
c.cache = make(chan byte, 1)
sender := NewSender(nil, track.Codec)
sender.Output = func(packet *Packet) {
c.cache <- packet.Payload[0]
}
sender.HandleRTP(track)
c.Senders = append(c.Senders, sender)
return nil
}
func (c *consumer) Stop() error {
for _, sender := range c.Senders {
sender.Close()
}
return nil
}
func (c *consumer) read() byte {
return <-c.cache
}
func TestName(t *testing.T) {
GetProducer := func(b byte) Producer {
return &producer{
Medias: []*Media{
{
Kind: KindVideo,
Direction: DirectionRecvonly,
Codecs: []*Codec{
{Name: CodecH264},
},
},
},
id: b,
}
}
// stage1
prod1 := GetProducer(1)
cons2 := &consumer{}
media1 := prod1.GetMedias()[0]
track1, _ := prod1.GetTrack(media1, media1.Codecs[0])
_ = cons2.AddTrack(nil, nil, track1)
_ = prod1.Start()
require.Equal(t, byte(1), cons2.read())
// stage2
prod2 := GetProducer(2)
media2 := prod2.GetMedias()[0]
require.NotEqual(t, fmt.Sprintf("%p", media1), fmt.Sprintf("%p", media2))
track2, _ := prod2.GetTrack(media2, media2.Codecs[0])
track1.Replace(track2)
_ = prod1.Stop()
_ = prod2.Start()
require.Equal(t, byte(2), cons2.read())
// stage3
_ = prod2.Stop()
}

View File

@@ -38,6 +38,13 @@ func RandString(size, base byte) string {
return string(b)
}
func Before(s, sep string) string {
if i := strings.Index(s, sep); i > 0 {
return s[:i]
}
return s
}
func Between(s, sub1, sub2 string) string {
i := strings.Index(s, sub1)
if i < 0 {

View File

@@ -22,7 +22,7 @@ type Media struct {
func (m *Media) String() string {
s := fmt.Sprintf("%s, %s", m.Kind, m.Direction)
for _, codec := range m.Codecs {
name := codec.Text()
name := codec.String()
if strings.Contains(s, name) {
continue
@@ -92,7 +92,7 @@ func (m *Media) Equal(media *Media) bool {
func GetKind(name string) string {
switch name {
case CodecH264, CodecH265, CodecVP8, CodecVP9, CodecAV1, CodecJPEG:
case CodecH264, CodecH265, CodecVP8, CodecVP9, CodecAV1, CodecJPEG, CodecRAW:
return KindVideo
case CodecPCMU, CodecPCMA, CodecAAC, CodecOpus, CodecG722, CodecMP3, CodecPCM, CodecPCML, CodecELD, CodecFLAC:
return KindAudio

88
pkg/core/node.go Normal file
View File

@@ -0,0 +1,88 @@
package core
import (
"sync"
"github.com/pion/rtp"
)
//type Packet struct {
// Payload []byte
// Timestamp uint32 // PTS if DTS == 0 else DTS
// Composition uint32 // CTS = PTS-DTS (for support B-frames)
// Sequence uint16
//}
type Packet = rtp.Packet
// HandlerFunc - process input packets (just like http.HandlerFunc)
type HandlerFunc func(packet *Packet)
// Filter - a decorator for any HandlerFunc
type Filter func(handler HandlerFunc) HandlerFunc
// Node - Receiver or Sender or Filter (transform)
type Node struct {
Codec *Codec
Input HandlerFunc
Output HandlerFunc
id uint32
childs []*Node
parent *Node
mu sync.Mutex
}
func (n *Node) WithParent(parent *Node) *Node {
parent.AppendChild(n)
return n
}
func (n *Node) AppendChild(child *Node) {
n.mu.Lock()
n.childs = append(n.childs, child)
n.mu.Unlock()
child.parent = n
}
func (n *Node) RemoveChild(child *Node) {
n.mu.Lock()
for i, ch := range n.childs {
if ch == child {
n.childs = append(n.childs[:i], n.childs[i+1:]...)
break
}
}
n.mu.Unlock()
}
func (n *Node) Close() {
if parent := n.parent; parent != nil {
parent.RemoveChild(n)
if len(parent.childs) == 0 {
parent.Close()
}
} else {
for _, childs := range n.childs {
childs.Close()
}
}
}
func MoveNode(dst, src *Node) {
src.mu.Lock()
childs := src.childs
src.childs = nil
src.mu.Unlock()
dst.mu.Lock()
dst.childs = childs
dst.mu.Unlock()
for _, child := range childs {
child.parent = dst
}
}

View File

@@ -3,201 +3,212 @@ package core
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"sync"
"github.com/pion/rtp"
)
type Packet struct {
PayloadType uint8
Sequence uint16
Timestamp uint32 // PTS if DTS == 0 else DTS
Composition uint32 // CTS = PTS-DTS (for support B-frames)
Payload []byte
}
var ErrCantGetTrack = errors.New("can't get track")
type Receiver struct {
Codec *Codec
Media *Media
Node
ID byte // Channel for RTSP, PayloadType for MPEG-TS
// Deprecated: should be removed
Media *Media `json:"-"`
// Deprecated: should be removed
ID byte `json:"-"` // Channel for RTSP, PayloadType for MPEG-TS
senders map[*Sender]chan *rtp.Packet
mu sync.RWMutex
bytes int
Bytes int `json:"bytes,omitempty"`
Packets int `json:"packets,omitempty"`
}
func NewReceiver(media *Media, codec *Codec) *Receiver {
Assert(codec != nil)
return &Receiver{Codec: codec, Media: media}
}
// WriteRTP - fast and non blocking write to all readers buffers
func (t *Receiver) WriteRTP(packet *rtp.Packet) {
t.mu.Lock()
t.bytes += len(packet.Payload)
for sender, buffer := range t.senders {
select {
case buffer <- packet:
default:
sender.overflow++
r := &Receiver{
Node: Node{id: NewID(), Codec: codec},
Media: media,
}
r.Input = func(packet *Packet) {
r.Bytes += len(packet.Payload)
r.Packets++
for _, child := range r.childs {
child.Input(packet)
}
}
t.mu.Unlock()
return r
}
func (t *Receiver) Senders() (senders []*Sender) {
t.mu.RLock()
for sender := range t.senders {
senders = append(senders, sender)
// Deprecated: should be removed
func (r *Receiver) WriteRTP(packet *rtp.Packet) {
r.Input(packet)
}
// Deprecated: should be removed
func (r *Receiver) Senders() []*Sender {
if len(r.childs) > 0 {
return []*Sender{{}}
} else {
return nil
}
t.mu.RUnlock()
return
}
func (t *Receiver) Close() {
t.mu.Lock()
// close all sender channel buffers and erase senders list
for _, buffer := range t.senders {
close(buffer)
}
t.senders = nil
t.mu.Unlock()
// Deprecated: should be removed
func (r *Receiver) Replace(target *Receiver) {
MoveNode(&target.Node, &r.Node)
}
func (t *Receiver) Replace(target *Receiver) {
// move this receiver senders to new receiver
t.mu.Lock()
senders := t.senders
t.mu.Unlock()
target.mu.Lock()
target.senders = senders
target.mu.Unlock()
}
func (t *Receiver) String() string {
s := t.Codec.String() + ", bytes=" + strconv.Itoa(t.bytes)
t.mu.RLock()
s += fmt.Sprintf(", senders=%d", len(t.senders))
t.mu.RUnlock()
return s
}
func (t *Receiver) MarshalJSON() ([]byte, error) {
return json.Marshal(t.String())
func (r *Receiver) Close() {
r.Node.Close()
}
type Sender struct {
Codec *Codec
Media *Media
Node
Handler HandlerFunc
// Deprecated:
Media *Media `json:"-"`
// Deprecated:
Handler HandlerFunc `json:"-"`
receivers []*Receiver
mu sync.RWMutex
bytes int
Bytes int `json:"bytes,omitempty"`
Packets int `json:"packets,omitempty"`
Drops int `json:"drops,omitempty"`
overflow int
buf chan *Packet
done chan struct{}
}
func NewSender(media *Media, codec *Codec) *Sender {
return &Sender{Codec: codec, Media: media}
}
var bufSize uint16
// HandlerFunc like http.HandlerFunc
type HandlerFunc func(packet *rtp.Packet)
func (s *Sender) HandleRTP(track *Receiver) {
bufferSize := 100
if GetKind(track.Codec.Name) == KindVideo {
if track.Codec.IsRTP() {
if GetKind(codec.Name) == KindVideo {
if codec.IsRTP() {
// in my tests 40Mbit/s 4K-video can generate up to 1500 items
// for the h264.RTPDepay => RTPPay queue
bufferSize = 5000
bufSize = 4096
} else {
bufferSize = 50
bufSize = 64
}
} else {
bufSize = 128
}
buffer := make(chan *rtp.Packet, bufferSize)
track.mu.Lock()
if track.senders == nil {
track.senders = map[*Sender]chan *rtp.Packet{}
buf := make(chan *Packet, bufSize)
s := &Sender{
Node: Node{id: NewID(), Codec: codec},
Media: media,
buf: buf,
}
track.senders[s] = buffer
track.mu.Unlock()
s.mu.Lock()
s.receivers = append(s.receivers, track)
s.mu.Unlock()
go func() {
// read packets from buffer channel until it will be closed
for packet := range buffer {
s.bytes += len(packet.Payload)
s.Handler(packet)
}
// remove current receiver from list
// it can only happen when receiver close buffer channel
s.Input = func(packet *Packet) {
// writing to nil chan - OK, writing to closed chan - panic
s.mu.Lock()
for i, receiver := range s.receivers {
if receiver == track {
s.receivers = append(s.receivers[:i], s.receivers[i+1:]...)
break
}
select {
case s.buf <- packet:
s.Bytes += len(packet.Payload)
s.Packets++
default:
s.Drops++
}
s.mu.Unlock()
}
s.Output = func(packet *Packet) {
s.Handler(packet)
}
return s
}
// Deprecated: should be removed
func (s *Sender) HandleRTP(parent *Receiver) {
s.WithParent(parent)
s.Start()
}
// Deprecated: should be removed
func (s *Sender) Bind(parent *Receiver) {
s.WithParent(parent)
}
func (s *Sender) WithParent(parent *Receiver) *Sender {
s.Node.WithParent(&parent.Node)
return s
}
func (s *Sender) Start() {
s.mu.Lock()
defer s.mu.Unlock()
if s.buf == nil || s.done != nil {
return
}
s.done = make(chan struct{})
go func() {
for packet := range s.buf {
s.Output(packet)
}
close(s.done)
}()
}
func (s *Sender) Close() {
s.mu.Lock()
// remove this sender from all receivers list
for _, receiver := range s.receivers {
receiver.mu.Lock()
if buffer := receiver.senders[s]; buffer != nil {
// remove channel from list
delete(receiver.senders, s)
// close channel
close(buffer)
}
receiver.mu.Unlock()
func (s *Sender) Wait() {
if done := s.done; s.done != nil {
<-done
}
s.receivers = nil
s.mu.Unlock()
}
func (s *Sender) String() string {
info := s.Codec.String() + ", bytes=" + strconv.Itoa(s.bytes)
s.mu.RLock()
info += ", receivers=" + strconv.Itoa(len(s.receivers))
s.mu.RUnlock()
if s.overflow > 0 {
info += ", overflow=" + strconv.Itoa(s.overflow)
func (s *Sender) State() string {
if s.buf == nil {
return "closed"
}
return info
if s.done == nil {
return "new"
}
return "connected"
}
func (s *Sender) Close() {
// close buffer if exists
if buf := s.buf; buf != nil {
s.buf = nil
defer close(buf)
}
s.Node.Close()
}
func (r *Receiver) MarshalJSON() ([]byte, error) {
v := struct {
ID uint32 `json:"id"`
Codec *Codec `json:"codec"`
Childs []uint32 `json:"childs,omitempty"`
Bytes int `json:"bytes,omitempty"`
Packets int `json:"packets,omitempty"`
}{
ID: r.Node.id,
Codec: r.Node.Codec,
Bytes: r.Bytes,
Packets: r.Packets,
}
for _, child := range r.childs {
v.Childs = append(v.Childs, child.id)
}
return json.Marshal(v)
}
func (s *Sender) MarshalJSON() ([]byte, error) {
return json.Marshal(s.String())
}
// VA - helper, for extract video and audio receivers from list
func VA(receivers []*Receiver) (video, audio *Receiver) {
for _, receiver := range receivers {
switch GetKind(receiver.Codec.Name) {
case KindVideo:
video = receiver
case KindAudio:
audio = receiver
}
v := struct {
ID uint32 `json:"id"`
Codec *Codec `json:"codec"`
Parent uint32 `json:"parent,omitempty"`
Bytes int `json:"bytes,omitempty"`
Packets int `json:"packets,omitempty"`
Drops int `json:"drops,omitempty"`
}{
ID: s.Node.id,
Codec: s.Node.Codec,
Bytes: s.Bytes,
Packets: s.Packets,
Drops: s.Drops,
}
return
if s.parent != nil {
v.Parent = s.parent.id
}
return json.Marshal(v)
}

View File

@@ -24,7 +24,7 @@ func Logger(include func(packet *rtp.Packet) bool) func(packet *rtp.Packet) {
now := time.Now()
fmt.Printf(
"%s: size:%6d, ts:%10d, type:%2d, ssrc:%d, seq:%5d, mark:%t, dts:%4d, dtime:%3d\n",
"%s: size=%6d ts=%10d type=%2d ssrc=%d seq=%5d mark=%t dts=%4d dtime=%3dms\n",
now.Format("15:04:05.000"),
len(packet.Payload), packet.Timestamp, packet.PayloadType, packet.SSRC, packet.SequenceNumber, packet.Marker,
packet.Timestamp-lastTS, now.Sub(lastTime).Milliseconds(),
@@ -41,7 +41,7 @@ func Logger(include func(packet *rtp.Packet) bool) func(packet *rtp.Packet) {
if dt := now.Sub(secTime); dt > time.Second {
fmt.Printf(
"%s: size:%6d, cnt:%d, dts: %d, dtime:%d\n",
"%s: size=%6d cnt=%d dts=%d dtime=%3dms\n",
now.Format("15:04:05.000"),
secSize, secCnt, lastTS-secTS, dt.Milliseconds(),
)

View File

@@ -8,16 +8,16 @@ import (
"github.com/pion/rtp"
)
type Consumer struct {
core.SuperConsumer
type Backchannel struct {
core.Connection
client *Client
}
func (c *Consumer) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) {
func (c *Backchannel) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) {
return nil, core.ErrCantGetTrack
}
func (c *Consumer) Start() error {
func (c *Backchannel) Start() error {
if err := c.client.conn.SetReadDeadline(time.Time{}); err != nil {
return err
}
@@ -30,12 +30,7 @@ func (c *Consumer) Start() error {
}
}
func (c *Consumer) Stop() error {
_ = c.SuperConsumer.Close()
return c.client.Close()
}
func (c *Consumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error {
func (c *Backchannel) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error {
if err := c.client.Talk(); err != nil {
return err
}

View File

@@ -114,7 +114,7 @@ func (c *Client) Play() error {
}
func (c *Client) Talk() error {
format := `{"Name":"OPTalk","SessionID":"0x%08X","OPTalk":{"Action":"%s"}}` + "\x0A\x00"
format := `{"Name":"OPTalk","SessionID":"0x%08X","OPTalk":{"Action":"%s","AudioFormat":{"EncodeType":"G711_ALAW"}}}` + "\x0A\x00"
data := fmt.Sprintf(format, c.session, "Claim")
if _, err := c.WriteCmd(OPTalkClaim, []byte(data)); err != nil {

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