Compare commits
340 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f2af78392 | |||
| 548d8133eb | |||
| 36ee2b29fb | |||
| 05accb4555 | |||
| f949a278da | |||
| bfae16f3a0 | |||
| d09d21434b | |||
| 2b9926cedb | |||
| af24fd67aa | |||
| e2cd34ffe3 | |||
| ecdf5ba271 | |||
| 995ef5bb36 | |||
| 8165adcab1 | |||
| 91c4a3e7b5 | |||
| cb710ea2be | |||
| 843a3ae9c9 | |||
| de040fb160 | |||
| acec8a76aa | |||
| 6c07c59454 | |||
| 4d708b5385 | |||
| 2e9f3181d4 | |||
| 3ae15d8f80 | |||
| d016529030 | |||
| 09f1553e40 | |||
| 52e4bf1b35 | |||
| bbe6ae0059 | |||
| c02117e626 | |||
| b8fb3acbab | |||
| d4d0064220 | |||
| 855bbdeb60 | |||
| 05893c9203 | |||
| c9c8e73587 | |||
| c7b6eb5d5b | |||
| 96bc88d8ce | |||
| 9a2e9dd6d1 | |||
| b252fcaaa1 | |||
| c582b932c7 | |||
| c3f26c4db8 | |||
| f27f7d28bb | |||
| 0424b1a92a | |||
| 81fb8fc238 | |||
| 037970a4ea | |||
| 3f6e83e87c | |||
| aa5b23fa80 | |||
| 02bde2c8b7 | |||
| cb5e90cc3b | |||
| 209fe09806 | |||
| dca8279e0c | |||
| 8163c7a520 | |||
| 4dffceaf7e | |||
| 9f1e33e0c6 | |||
| 9a7d7e68e2 | |||
| ab18d5d1ca | |||
| 6e53e74742 | |||
| f910bd4fce | |||
| 93e475f3a4 | |||
| e5d8170037 | |||
| 861632f92b | |||
| 9cf75565b5 | |||
| 9368a6b85e | |||
| c8ac6b2271 | |||
| 28f5c2b974 | |||
| daa2522a52 | |||
| 863f8ec19b | |||
| 8f98fc4547 | |||
| 398afbe49f | |||
| ad8c0ab2fb | |||
| 37130576e9 | |||
| 486fea2227 | |||
| 6d7357b151 | |||
| 452d7577f8 | |||
| 124398115e | |||
| 541a7b28a7 | |||
| 947b0970ad | |||
| 447fd5b3eb | |||
| 064ffef462 | |||
| 05360ac284 | |||
| 08dabc7331 | |||
| d724df7db2 | |||
| fc1b6af436 | |||
| 88fb589d2e | |||
| 5c5357cd79 | |||
| 5ffd60c429 | |||
| 5645c73613 | |||
| 13a7957cf3 | |||
| c0d5a7c01a | |||
| d87cc9ddb6 | |||
| b1c4bcc508 | |||
| 6288c2a57f | |||
| 1c569e690d | |||
| 7fdc6b9472 | |||
| 60d7d525f2 | |||
| f6f2998e85 | |||
| 82d1f2cf0b | |||
| f00e646612 | |||
| a101387b26 | |||
| af31ab604d | |||
| ccdd6ed490 | |||
| 9f404d965f | |||
| 0621b82aff | |||
| 22787b979d | |||
| 7d65c60711 | |||
| 69da64a49c | |||
| 66c858e00e | |||
| ef63cec7a8 | |||
| 0ac505ba09 | |||
| d4444c6257 | |||
| c6d5bb4eeb | |||
| 7f232c5cf2 | |||
| dc2ab5fcc0 | |||
| 137b23da10 | |||
| 54e361e3b8 | |||
| c78da1a7a9 | |||
| 27673cb0c1 | |||
| c040a02fa8 | |||
| a664e3b838 | |||
| 317b3b5eeb | |||
| 9f14b30aae | |||
| 065a6f4f46 | |||
| 9f9dc7e844 | |||
| b1c0a28366 | |||
| fc963dfe5c | |||
| 6f5ba2ade6 | |||
| ea708bb606 | |||
| 0822326900 | |||
| 79fc0cd395 | |||
| 357e7c1b18 | |||
| 71f1e445e1 | |||
| 20efe22e60 | |||
| 75a3dad745 | |||
| f5cca50830 | |||
| 8cd977f7ad | |||
| 90f2a9e106 | |||
| e0ad358aa9 | |||
| 3db4002420 | |||
| bf248c49c3 | |||
| 69a3a30a0e | |||
| f80f179e4c | |||
| c1c1d84cef | |||
| c431d888f0 | |||
| 2ebb791eb7 | |||
| 00b818b4d7 | |||
| ce1b0d442c | |||
| 5283c9781c | |||
| 279d8bf799 | |||
| 7114d63ba6 | |||
| 120ae89578 | |||
| d1eb623fd6 | |||
| 873cf65317 | |||
| 2091dead3f | |||
| 2ffd859f0e | |||
| da02a97a00 | |||
| fb51dc781d | |||
| 32bf64028d | |||
| 2e4e75e386 | |||
| f67f6e5b9f | |||
| 24039218a1 | |||
| 1f447ef73c | |||
| 4509198eef | |||
| bc60cbefb8 | |||
| a9118562a9 | |||
| 24637be7c2 | |||
| d74be47696 | |||
| 76a00031cd | |||
| 063a192699 | |||
| b016b7dc2a | |||
| 42f6441512 | |||
| dd066ba040 | |||
| b3def6cfa2 | |||
| 4a82eb3503 | |||
| c3ba8db660 | |||
| 4e1a0e1ab9 | |||
| 1dd3dbbcd8 | |||
| e1be2d9e48 | |||
| 8fbfccd024 | |||
| de6bb33f01 | |||
| 3a40515a90 | |||
| 5d533338d0 | |||
| f412852d50 | |||
| 5fbec487e2 | |||
| 19c61e20c0 | |||
| 0b6fda2af5 | |||
| e9795e7521 | |||
| 3b8413a9dd | |||
| b2f9ad7efb | |||
| 4baa3f5588 | |||
| 9c5ae3260c | |||
| b7baef0a48 | |||
| 8778d7c9ab | |||
| d275997e54 | |||
| 2faea1bb69 | |||
| ba6c96412b | |||
| ed38122752 | |||
| 922587ed2e | |||
| 8e7c9d19e4 | |||
| 0f33ef0fc5 | |||
| a14c87ad60 | |||
| 6d82b1ce89 | |||
| d73e9f6bcf | |||
| e6a87fbd69 | |||
| 3defbd60db | |||
| 6e9574a1bd | |||
| 7005cd08f2 | |||
| e94f338b77 | |||
| d6172587b3 | |||
| f196d83a14 | |||
| 9d4f4e1509 | |||
| 7308652f6e | |||
| 870e9c3688 | |||
| 189f142fae | |||
| 6c0918662e | |||
| 2bc01c143a | |||
| f310b85ee6 | |||
| 97fef36f2f | |||
| a8526ae4eb | |||
| 966fbe7d61 | |||
| a77c2ef71f | |||
| 61a194e396 | |||
| ae25784d72 | |||
| 3343c78699 | |||
| 7928f54a95 | |||
| e4b68518e5 | |||
| 14ed1cdee8 | |||
| 72f159be88 | |||
| 144954b979 | |||
| 9e15391471 | |||
| d62b1e445a | |||
| ade4c035b7 | |||
| 13ca991c37 | |||
| e48459f49d | |||
| facf18e0df | |||
| 5c93dc62bd | |||
| d272d4b6c3 | |||
| 1b41edfc7e | |||
| d55270bd64 | |||
| 85225917f5 | |||
| eaef62a775 | |||
| f6c8d63658 | |||
| ea82d7ec2b | |||
| e8a7ba056c | |||
| 9fd40467f2 | |||
| c81e29fe54 | |||
| b9b7bb5489 | |||
| 8036278e29 | |||
| 39c25215ba | |||
| 490a48cd50 | |||
| b5d40caffc | |||
| 1e0952be86 | |||
| d5fa933772 | |||
| 73bf96e123 | |||
| 4ea5a22eda | |||
| a79fe6041d | |||
| 07440f359e | |||
| 01ef67153e | |||
| fded87aa33 | |||
| 52a4fc329c | |||
| ce61d5759c | |||
| 39cc4610e3 | |||
| 67b25015df | |||
| f0d627fa55 | |||
| 9809f41117 | |||
| 2ce72dbcca | |||
| ddfeb6fae6 | |||
| 19130a4858 | |||
| 51b494b193 | |||
| fd3b3c9bf1 | |||
| fa763399c2 | |||
| af2398c072 | |||
| 19b0bc5f44 | |||
| f94cd16cb7 | |||
| 3246e7284c | |||
| 9339957c13 | |||
| 4ca397da3d | |||
| f6936f7cee | |||
| bdafaef7dc | |||
| 209d7b47d9 | |||
| 4283ae1022 | |||
| c2a398211c | |||
| 6c2f883f9e | |||
| c34f9ae2b7 | |||
| c29dd8c4e3 | |||
| 9e65f18e08 | |||
| db3fb72ac8 | |||
| 90cdfafcf5 | |||
| fa8d4e4807 | |||
| 37abe2ce0d | |||
| 1c3835f2a8 | |||
| bc6e4f40bf | |||
| ac5bcda492 | |||
| 7bd42eb55f | |||
| e4c7ffd1b4 | |||
| d31cf5521b | |||
| 9de980a63c | |||
| 74cef13479 | |||
| 887a491077 | |||
| 253fc4c915 | |||
| 3a51fa2397 | |||
| 306451f94f | |||
| 39811d121b | |||
| 99b962e7bb | |||
| 3dd14a826c | |||
| a99d7097b9 | |||
| 4f97e119ac | |||
| 44ee0066a5 | |||
| e5e899450f | |||
| 05a2f53b67 | |||
| 63bcaa836a | |||
| ba68bcb89e | |||
| 4a162c9a55 | |||
| c2f5f37f40 | |||
| 11201790d2 | |||
| 64804cbc87 | |||
| 75818d6967 | |||
| 14bb4b40f7 | |||
| 0fdb0b128b | |||
| fe28c32400 | |||
| 888159d2b6 | |||
| 397eb0b6ee | |||
| ffeb473918 | |||
| 966bedd38c | |||
| 0e270081fe | |||
| 1612f9c81e | |||
| bff9b06d5d | |||
| 59555cfe1d | |||
| c94d1e237d | |||
| 82a8e07b66 | |||
| e29307125c | |||
| 1eaacdb217 | |||
| c09438d3d0 | |||
| 8b126c0d37 | |||
| 3139189975 | |||
| 4fe078c7c0 | |||
| 083ec127fd | |||
| bcb9756aca | |||
| 981974eac9 | |||
| 5b29306d4f | |||
| e89c5cb429 | |||
| 04f263aa15 | |||
| af717b2172 | |||
| 91a7b5be27 |
@@ -0,0 +1,189 @@
|
||||
name: Build and Push
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build-binaries:
|
||||
name: Build binaries
|
||||
runs-on: ubuntu-latest
|
||||
env: { CGO_ENABLED: 0 }
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v4
|
||||
with: { go-version: '1.21' }
|
||||
|
||||
- name: Build go2rtc_win64
|
||||
env: { GOOS: windows, GOARCH: amd64 }
|
||||
run: go build -ldflags "-s -w" -trimpath
|
||||
- name: Upload go2rtc_win64
|
||||
uses: actions/upload-artifact@v3
|
||||
with: { name: go2rtc_win64, path: go2rtc.exe }
|
||||
|
||||
- name: Build go2rtc_win32
|
||||
env: { GOOS: windows, GOARCH: 386 }
|
||||
run: go build -ldflags "-s -w" -trimpath
|
||||
- name: Upload go2rtc_win32
|
||||
uses: actions/upload-artifact@v3
|
||||
with: { name: go2rtc_win32, path: go2rtc.exe }
|
||||
|
||||
- name: Build go2rtc_win_arm64
|
||||
env: { GOOS: windows, GOARCH: arm64 }
|
||||
run: go build -ldflags "-s -w" -trimpath
|
||||
- name: Upload go2rtc_win_arm64
|
||||
uses: actions/upload-artifact@v3
|
||||
with: { name: go2rtc_win_arm64, path: go2rtc.exe }
|
||||
|
||||
- name: Build go2rtc_linux_amd64
|
||||
env: { GOOS: linux, GOARCH: amd64 }
|
||||
run: go build -ldflags "-s -w" -trimpath
|
||||
- name: Upload go2rtc_linux_amd64
|
||||
uses: actions/upload-artifact@v3
|
||||
with: { name: go2rtc_linux_amd64, path: go2rtc }
|
||||
|
||||
- name: Build go2rtc_linux_i386
|
||||
env: { GOOS: linux, GOARCH: 386 }
|
||||
run: go build -ldflags "-s -w" -trimpath
|
||||
- name: Upload go2rtc_linux_i386
|
||||
uses: actions/upload-artifact@v3
|
||||
with: { name: go2rtc_linux_i386, path: go2rtc }
|
||||
|
||||
- name: Build go2rtc_linux_arm64
|
||||
env: { GOOS: linux, GOARCH: arm64 }
|
||||
run: go build -ldflags "-s -w" -trimpath
|
||||
- name: Upload go2rtc_linux_arm64
|
||||
uses: actions/upload-artifact@v3
|
||||
with: { name: go2rtc_linux_arm64, path: go2rtc }
|
||||
|
||||
- name: Build go2rtc_linux_arm
|
||||
env: { GOOS: linux, GOARCH: arm, GOARM: 7 }
|
||||
run: go build -ldflags "-s -w" -trimpath
|
||||
- name: Upload go2rtc_linux_arm
|
||||
uses: actions/upload-artifact@v3
|
||||
with: { name: go2rtc_linux_arm, path: go2rtc }
|
||||
|
||||
- name: Build go2rtc_linux_armv6
|
||||
env: { GOOS: linux, GOARCH: arm, GOARM: 6 }
|
||||
run: go build -ldflags "-s -w" -trimpath
|
||||
- name: Upload go2rtc_linux_armv6
|
||||
uses: actions/upload-artifact@v3
|
||||
with: { name: go2rtc_linux_armv6, path: go2rtc }
|
||||
|
||||
- name: Build go2rtc_linux_mipsel
|
||||
env: { GOOS: linux, GOARCH: mipsle }
|
||||
run: go build -ldflags "-s -w" -trimpath
|
||||
- name: Upload go2rtc_linux_mipsel
|
||||
uses: actions/upload-artifact@v3
|
||||
with: { name: go2rtc_linux_mipsel, path: go2rtc }
|
||||
|
||||
- name: Build go2rtc_mac_amd64
|
||||
env: { GOOS: darwin, GOARCH: amd64 }
|
||||
run: go build -ldflags "-s -w" -trimpath
|
||||
- name: Upload go2rtc_mac_amd64
|
||||
uses: actions/upload-artifact@v3
|
||||
with: { name: go2rtc_mac_amd64, path: go2rtc }
|
||||
|
||||
- name: Build go2rtc_mac_arm64
|
||||
env: { GOOS: darwin, GOARCH: arm64 }
|
||||
run: go build -ldflags "-s -w" -trimpath
|
||||
- name: Upload go2rtc_mac_arm64
|
||||
uses: actions/upload-artifact@v3
|
||||
with: { name: go2rtc_mac_arm64, path: go2rtc }
|
||||
|
||||
docker-master:
|
||||
name: Build docker master
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}},enable=false
|
||||
type=match,pattern=v(.*),group=1
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/386
|
||||
linux/arm/v7
|
||||
linux/arm64/v8
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
docker-hardware:
|
||||
name: Build docker hardware
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Docker meta
|
||||
id: meta-hw
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ github.repository }}
|
||||
flavor: |
|
||||
suffix=-hardware
|
||||
latest=false
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}},enable=false
|
||||
type=match,pattern=v(.*),group=1
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: hardware.Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta-hw.outputs.tags }}
|
||||
labels: ${{ steps.meta-hw.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -1,78 +0,0 @@
|
||||
name: docker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}},enable=false
|
||||
type=match,pattern=v(.*),group=1
|
||||
|
||||
- name: Docker meta Hardware
|
||||
id: meta-hw
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ github.repository }}
|
||||
flavor: |
|
||||
suffix=-hardware
|
||||
latest=false
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}},enable=false
|
||||
type=match,pattern=v(.*),group=1
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/386
|
||||
linux/arm/v7
|
||||
linux/arm64/v8
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- name: Build and push Hardware
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: hardware.Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta-hw.outputs.tags }}
|
||||
labels: ${{ steps.meta-hw.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -1,99 +0,0 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# push:
|
||||
# tags:
|
||||
# - 'v*'
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Generate changelog
|
||||
run: |
|
||||
echo -e "$(git log $(git describe --tags --abbrev=0)..HEAD --oneline | awk '{print "- "$0}')" > CHANGELOG.md
|
||||
- name: install lipo
|
||||
run: |
|
||||
curl -L -o /tmp/lipo https://github.com/konoui/lipo/releases/latest/download/lipo_Linux_amd64
|
||||
chmod +x /tmp/lipo
|
||||
mv /tmp/lipo /usr/local/bin
|
||||
- name: Build Go binaries
|
||||
run: |
|
||||
#!/bin/bash
|
||||
|
||||
export CGO_ENABLED=0
|
||||
|
||||
mkdir -p artifacts
|
||||
|
||||
export GOOS=windows
|
||||
export GOARCH=amd64
|
||||
export FILENAME=artifacts/go2rtc_win64.zip
|
||||
go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel "$FILENAME" go2rtc.exe
|
||||
|
||||
export GOOS=windows
|
||||
export GOARCH=386
|
||||
export FILENAME=artifacts/go2rtc_win32.zip
|
||||
go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel "$FILENAME" go2rtc.exe
|
||||
|
||||
export GOOS=windows
|
||||
export GOARCH=arm64
|
||||
export FILENAME=artifacts/go2rtc_win_arm64.zip
|
||||
go build -ldflags "-s -w" -trimpath && 7z a -mx9 -sdel "$FILENAME" go2rtc.exe
|
||||
|
||||
export GOOS=linux
|
||||
export GOARCH=amd64
|
||||
export FILENAME=artifacts/go2rtc_linux_amd64
|
||||
go build -ldflags "-s -w" -trimpath -o "$FILENAME"
|
||||
|
||||
export GOOS=linux
|
||||
export GOARCH=386
|
||||
export FILENAME=artifacts/go2rtc_linux_i386
|
||||
go build -ldflags "-s -w" -trimpath -o "$FILENAME"
|
||||
|
||||
export GOOS=linux
|
||||
export GOARCH=arm64
|
||||
export FILENAME=artifacts/go2rtc_linux_arm64
|
||||
go build -ldflags "-s -w" -trimpath -o "$FILENAME"
|
||||
|
||||
export GOOS=linux
|
||||
export GOARCH=arm
|
||||
export GOARM=7
|
||||
export FILENAME=artifacts/go2rtc_linux_arm
|
||||
go build -ldflags "-s -w" -trimpath -o "$FILENAME"
|
||||
|
||||
export GOOS=linux
|
||||
export GOARCH=mipsle
|
||||
export FILENAME=artifacts/go2rtc_linux_mipsel
|
||||
go build -ldflags "-s -w" -trimpath -o "$FILENAME"
|
||||
|
||||
export GOOS=darwin
|
||||
export GOARCH=amd64
|
||||
go build -ldflags "-s -w" -trimpath -o go2rtc.amd64
|
||||
|
||||
export GOOS=darwin
|
||||
export GOARCH=arm64
|
||||
go build -ldflags "-s -w" -trimpath -o go2rtc.arm64
|
||||
|
||||
export FILENAME=artifacts/go2rtc_mac_universal.zip
|
||||
lipo -output go2rtc -create go2rtc.arm64 go2rtc.amd64 && 7z a -mx9 -sdel "$FILENAME" go2rtc
|
||||
|
||||
parallel --jobs $(nproc) "upx {}" ::: artifacts/go2rtc_linux_*
|
||||
- name: Setup tmate session
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
if: ${{ failure() }}
|
||||
- name: Set env
|
||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||
- name: Create GitHub release
|
||||
uses: softprops/action-gh-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
files: artifacts/*
|
||||
generate_release_notes: true
|
||||
name: Release ${{ env.RELEASE_VERSION }}
|
||||
body_path: CHANGELOG.md
|
||||
draft: false
|
||||
prerelease: false
|
||||
@@ -1,11 +1,11 @@
|
||||
name: Test Build and Run
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
pull_request:
|
||||
merge_group:
|
||||
# push:
|
||||
# branches:
|
||||
# - '*'
|
||||
# pull_request:
|
||||
# merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: '1.20'
|
||||
go-version: '1.21'
|
||||
|
||||
- name: Build Go binary
|
||||
run: go build -ldflags "-s -w" -trimpath -o ./go2rtc
|
||||
|
||||
+2
-3
@@ -1,8 +1,7 @@
|
||||
|
||||
.idea/
|
||||
|
||||
.tmp/
|
||||
|
||||
go2rtc.yaml
|
||||
|
||||
go2rtc.json
|
||||
|
||||
0_test.go
|
||||
|
||||
+3
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
# 0. Prepare images
|
||||
ARG PYTHON_VERSION="3.11"
|
||||
ARG GO_VERSION="1.20"
|
||||
ARG GO_VERSION="1.21"
|
||||
ARG NGROK_VERSION="3"
|
||||
|
||||
FROM python:${PYTHON_VERSION}-alpine AS base
|
||||
@@ -41,7 +41,8 @@ FROM base
|
||||
# Install ffmpeg, tini (for signal handling),
|
||||
# and other common tools for the echo source.
|
||||
# alsa-plugins-pulse for ALSA support (+0MB)
|
||||
RUN apk add --no-cache tini ffmpeg bash curl jq alsa-plugins-pulse
|
||||
# font-droid for FFmpeg drawtext filter (+2MB)
|
||||
RUN apk add --no-cache tini ffmpeg bash curl jq alsa-plugins-pulse font-droid
|
||||
|
||||
# Hardware Acceleration for Intel CPU (+50MB)
|
||||
ARG TARGETARCH
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# go2rtc
|
||||
|
||||
[](https://github.com/AlexxIT/go2rtc/stargazers)
|
||||
[](https://hub.docker.com/r/alexxit/go2rtc)
|
||||
[](https://github.com/AlexxIT/go2rtc/releases)
|
||||
[](https://goreportcard.com/report/github.com/AlexxIT/go2rtc)
|
||||
|
||||
Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg, RTMP, etc.
|
||||
|
||||

|
||||
@@ -8,9 +13,10 @@ Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg
|
||||
- zero-delay for many supported protocols (lowest possible streaming latency)
|
||||
- streaming from [RTSP](#source-rtsp), [RTMP](#source-rtmp), [DVRIP](#source-dvrip), [HTTP](#source-http) (FLV/MJPEG/JPEG/TS), [USB Cameras](#source-ffmpeg-device) and [other sources](#module-streams)
|
||||
- streaming from any sources, supported by [FFmpeg](#source-ffmpeg)
|
||||
- streaming to [RTSP](#module-rtsp), [WebRTC](#module-webrtc), [MSE/MP4](#module-mp4), [HLS](#module-hls) or [MJPEG](#module-mjpeg)
|
||||
- streaming to [RTSP](#module-rtsp), [WebRTC](#module-webrtc), [MSE/MP4](#module-mp4), [HomeKit](#module-homekit) [HLS](#module-hls) or [MJPEG](#module-mjpeg)
|
||||
- [publish](#publish-stream) any source to popular streaming services (YouTube, Telegram, etc.)
|
||||
- first project in the World with support streaming from [HomeKit Cameras](#source-homekit)
|
||||
- first project in the World with support H265 for WebRTC in browser (Safari only, [read more](https://github.com/AlexxIT/Blog/issues/5))
|
||||
- support H265 for WebRTC in browser (Safari only, [read more](https://github.com/AlexxIT/Blog/issues/5))
|
||||
- on the fly transcoding for unsupported codecs via [FFmpeg](#source-ffmpeg)
|
||||
- play audio files and live streams on some cameras with [speaker](#stream-to-camera)
|
||||
- multi-source 2-way [codecs negotiation](#codecs-negotiation)
|
||||
@@ -36,6 +42,7 @@ Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg
|
||||
* [go2rtc: Docker](#go2rtc-docker)
|
||||
* [go2rtc: Home Assistant Add-on](#go2rtc-home-assistant-add-on)
|
||||
* [go2rtc: Home Assistant Integration](#go2rtc-home-assistant-integration)
|
||||
* [go2rtc: Dev version](#go2rtc-dev-version)
|
||||
* [Configuration](#configuration)
|
||||
* [Module: Streams](#module-streams)
|
||||
* [Two way audio](#two-way-audio)
|
||||
@@ -48,19 +55,25 @@ Ultimate camera streaming application with support RTSP, WebRTC, HomeKit, FFmpeg
|
||||
* [Source: Exec](#source-exec)
|
||||
* [Source: Echo](#source-echo)
|
||||
* [Source: HomeKit](#source-homekit)
|
||||
* [Source: Bubble](#source-bubble)
|
||||
* [Source: DVRIP](#source-dvrip)
|
||||
* [Source: Tapo](#source-tapo)
|
||||
* [Source: Kasa](#source-kasa)
|
||||
* [Source: Ivideon](#source-ivideon)
|
||||
* [Source: Hass](#source-hass)
|
||||
* [Source: ISAPI](#source-isapi)
|
||||
* [Source: Nest](#source-nest)
|
||||
* [Source: Roborock](#source-roborock)
|
||||
* [Source: WebRTC](#source-webrtc)
|
||||
* [Source: WebTorrent](#source-webtorrent)
|
||||
* [Incoming sources](#incoming-sources)
|
||||
* [Stream to camera](#stream-to-camera)
|
||||
* [Publish stream](#publish-stream)
|
||||
* [Module: API](#module-api)
|
||||
* [Module: RTSP](#module-rtsp)
|
||||
* [Module: RTMP](#module-rtmp)
|
||||
* [Module: WebRTC](#module-webrtc)
|
||||
* [Module: HomeKit](#module-homekit)
|
||||
* [Module: WebTorrent](#module-webtorrent)
|
||||
* [Module: Ngrok](#module-ngrok)
|
||||
* [Module: Hass](#module-hass)
|
||||
@@ -98,11 +111,13 @@ Download binary for your OS from [latest release](https://github.com/AlexxIT/go2
|
||||
|
||||
- `go2rtc_win64.zip` - Windows 64-bit
|
||||
- `go2rtc_win32.zip` - Windows 32-bit
|
||||
- `go2rtc_win_arm64.zip` - Windows ARM 64-bit
|
||||
- `go2rtc_linux_amd64` - Linux 64-bit
|
||||
- `go2rtc_linux_i386` - Linux 32-bit
|
||||
- `go2rtc_linux_arm64` - Linux ARM 64-bit (ex. Raspberry 64-bit OS)
|
||||
- `go2rtc_linux_arm` - Linux ARM 32-bit (ex. Raspberry 32-bit OS)
|
||||
- `go2rtc_linux_mipsel` - Linux MIPS (ex. [Xiaomi Gateway 3](https://github.com/AlexxIT/XiaomiGateway3))
|
||||
- `go2rtc_linux_armv6` - Linux ARMv6 (for old Raspberry 1 and Zero)
|
||||
- `go2rtc_linux_mipsel` - Linux MIPS (ex. [Xiaomi Gateway 3](https://github.com/AlexxIT/XiaomiGateway3), [Wyze cameras](https://github.com/gtxaspec/wz_mini_hacks))
|
||||
- `go2rtc_mac_amd64.zip` - Mac Intel 64-bit
|
||||
- `go2rtc_mac_arm64.zip` - Mac ARM 64-bit
|
||||
|
||||
@@ -125,6 +140,14 @@ Container [alexxit/go2rtc](https://hub.docker.com/r/alexxit/go2rtc) with support
|
||||
|
||||
[WebRTC Camera](https://github.com/AlexxIT/WebRTC) custom component can be used on any [Home Assistant installation](https://www.home-assistant.io/installation/), including [HassWP](https://github.com/AlexxIT/HassWP) on Windows. It can automatically download and use the latest version of go2rtc. Or it can connect to an existing version of go2rtc. Addon installation in this case is optional.
|
||||
|
||||
### go2rtc: Dev version
|
||||
|
||||
Latest, but maybe unstable version:
|
||||
|
||||
- Binary: GitHub > [Actions](https://github.com/AlexxIT/go2rtc/actions) > [Build and Push](https://github.com/AlexxIT/go2rtc/actions/workflows/build.yml) > latest run > Artifacts section (you should be logged in to GitHub)
|
||||
- Docker: `alexxit/go2rtc:master` or `alexxit/go2rtc:master-hardware` versions
|
||||
- Hass Add-on: `go2rtc master` or `go2rtc master hardware` versions
|
||||
|
||||
## Configuration
|
||||
|
||||
- by default go2rtc will search `go2rtc.yaml` in the current work dirrectory
|
||||
@@ -164,8 +187,10 @@ Available source types:
|
||||
- [exec](#source-exec) - get media from external app output
|
||||
- [echo](#source-echo) - get stream link from bash or python
|
||||
- [homekit](#source-homekit) - streaming from HomeKit Camera
|
||||
- [bubble](#source-bubble) - streaming from ESeeCloud/dvr163 NVR
|
||||
- [dvrip](#source-dvrip) - streaming from DVR-IP NVR
|
||||
- [tapo](#source-tapo) - TP-Link Tapo cameras with [two way audio](#two-way-audio) support
|
||||
- [kasa](#source-tapo) - TP-Link Kasa cameras
|
||||
- [ivideon](#source-ivideon) - public cameras from [Ivideon](https://tv.ivideon.com/) service
|
||||
- [hass](#source-hass) - Home Assistant integration
|
||||
- [isapi](#source-isapi) - two way audio for Hikvision (ISAPI) cameras
|
||||
@@ -180,6 +205,7 @@ Read more about [incoming sources](#incoming-sources)
|
||||
Supported for sources:
|
||||
|
||||
- [RTSP cameras](#source-rtsp) with [ONVIF Profile T](https://www.onvif.org/specs/stream/ONVIF-Streaming-Spec.pdf) (back channel connection)
|
||||
- [DVRIP](#source-dvrip) cameras
|
||||
- [TP-Link Tapo](#source-tapo) cameras
|
||||
- [Hikvision ISAPI](#source-isapi) cameras
|
||||
- [Roborock vacuums](#source-roborock) models with cameras
|
||||
@@ -199,7 +225,7 @@ streams:
|
||||
- rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=1
|
||||
amcrest_doorbell:
|
||||
- rtsp://username:password@192.168.1.123:554/cam/realmonitor?channel=1&subtype=0#backchannel=0
|
||||
unify_camera: rtspx://192.168.1.123:7441/fD6ouM72bWoFijxK
|
||||
unifi_camera: rtspx://192.168.1.123:7441/fD6ouM72bWoFijxK
|
||||
glichy_camera: ffmpeg:rstp://username:password@192.168.1.123/live/ch00_1
|
||||
```
|
||||
|
||||
@@ -207,12 +233,32 @@ streams:
|
||||
|
||||
- **Amcrest Doorbell** users may want to disable two way audio, because with an active stream you won't have a call button working. You need to add `#backchannel=0` to the end of your RTSP link in YAML config file
|
||||
- **Dahua Doorbell** users may want to change backchannel [audio codec](https://github.com/AlexxIT/go2rtc/issues/52)
|
||||
- **Unify** users may want to disable HTTPS verification. Use `rtspx://` prefix instead of `rtsps://`. And don't use `?enableSrtp` [suffix](https://github.com/AlexxIT/go2rtc/issues/81)
|
||||
- **Reolink** users may want NOT to use RTSP protocol at all, some camera models have a very awful unusable stream implementation
|
||||
- **Ubiquiti UniFi** users may want to disable HTTPS verification. Use `rtspx://` prefix instead of `rtsps://`. And don't use `?enableSrtp` [suffix](https://github.com/AlexxIT/go2rtc/issues/81)
|
||||
- **TP-Link Tapo** users may skip login and password, because go2rtc support login [without them](https://drmnsamoliu.github.io/video.html)
|
||||
- If your camera has two RTSP links - you can add both of them as sources. This is useful when streams has different codecs, as example AAC audio with main stream and PCMU/PCMA audio with second stream
|
||||
- If the stream from your camera is glitchy, try using [ffmpeg source](#source-ffmpeg). It will not add CPU load if you won't use transcoding
|
||||
- If the stream from your camera is very glitchy, try to use transcoding with [ffmpeg source](#source-ffmpeg)
|
||||
|
||||
**Other options**
|
||||
|
||||
Format: `rtsp...#{param1}#{param2}#{param3}`
|
||||
|
||||
- Add custom timeout `#timeout=30` (in seconds)
|
||||
- Ignore audio - `#media=video` or ignore video - `#media=audio`
|
||||
- Ignore two way audio API `#backchannel=0` - important for some glitchy cameras
|
||||
- Use WebSocket transport `#transport=ws...`
|
||||
|
||||
**RTSP over WebSocket**
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
# WebSocket with authorization, RTSP - without
|
||||
axis-rtsp-ws: rtsp://192.168.1.123:4567/axis-media/media.amp?overview=0&camera=1&resolution=1280x720&videoframeskipmode=empty&Axis-Orig-Sw=true#transport=ws://user:pass@192.168.1.123:4567/rtsp-over-websocket
|
||||
# WebSocket without authorization, RTSP - with
|
||||
dahua-rtsp-ws: rtsp://user:pass@192.168.1.123/cam/realmonitor?channel=1&subtype=1&proto=Private3#transport=ws://192.168.1.123/rtspoverwebsocket
|
||||
```
|
||||
|
||||
#### Source: RTMP
|
||||
|
||||
You can get stream from RTMP server, for example [Frigate](https://docs.frigate.video/configuration/rtmp).
|
||||
@@ -246,6 +292,9 @@ streams:
|
||||
|
||||
# [MJPEG or H.264/H.265 bitstream or MPEG-TS]
|
||||
tcp_magic: tcp://192.168.1.123:12345
|
||||
|
||||
# Add custom header
|
||||
custom_header: "https://mjpeg.sanford.io/count.mjpeg#header=Authorization: Bearer XXX"
|
||||
```
|
||||
|
||||
**PS.** Dahua camera has bug: if you select MJPEG codec for RTSP second stream - snapshot won't work.
|
||||
@@ -301,20 +350,25 @@ But you can override them via YAML config. You can also add your own formats to
|
||||
ffmpeg:
|
||||
bin: ffmpeg # path to ffmpeg binary
|
||||
h264: "-codec:v libx264 -g:v 30 -preset:v superfast -tune:v zerolatency -profile:v main -level:v 4.1"
|
||||
mycodec: "-any args that support ffmpeg..."
|
||||
mycodec: "-any args that supported by ffmpeg..."
|
||||
myinput: "-fflags nobuffer -flags low_delay -timeout 5000000 -i {input}"
|
||||
myraw: "-ss 00:00:20"
|
||||
```
|
||||
|
||||
- You can use `video` and `audio` params multiple times (ex. `#video=copy#audio=copy#audio=pcmu`)
|
||||
- You can use go2rtc stream name as ffmpeg input (ex. `ffmpeg:camera1#video=h264`)
|
||||
- You can use `rotate` params with `90`, `180`, `270` or `-90` values, important with transcoding (ex. `#video=h264#rotate=90`)
|
||||
- You can use `video` and `audio` params multiple times (ex. `#video=copy#audio=copy#audio=pcmu`)
|
||||
- You can use `rotate` param with `90`, `180`, `270` or `-90` values, important with transcoding (ex. `#video=h264#rotate=90`)
|
||||
- You can use `width` and/or `height` params, important with transcoding (ex. `#video=h264#width=1280`)
|
||||
- You can use `drawtext` to add a timestamp (ex. `drawtext=x=2:y=2:fontsize=12:fontcolor=white:box=1:boxcolor=black`)
|
||||
- This will greatly increase the CPU of the server, even with hardware acceleration
|
||||
- You can use `raw` param for any additional FFmpeg arguments (ex. `#raw=-vf transpose=1`)
|
||||
- You can use `input` param to override default input template (ex. `#input=rtsp/udp` will change RTSP transport from TCP to UDP+TCP)
|
||||
- You can use raw input value (ex. `#input=-timeout 5000000 -i {input}`)
|
||||
- You can add your own input templates
|
||||
|
||||
Read more about encoding [hardware acceleration](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration).
|
||||
Read more about [hardware acceleration](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration).
|
||||
|
||||
**PS.** It is recommended to check the available hardware in the WebUI add page.
|
||||
|
||||
#### Source: FFmpeg Device
|
||||
|
||||
@@ -335,6 +389,8 @@ streams:
|
||||
macos_facetime: ffmpeg:device?video=0&audio=1&video_size=1280x720&framerate=30#video=h264#audio=pcma
|
||||
```
|
||||
|
||||
**PS.** It is recommended to check the available devices in the WebUI add page.
|
||||
|
||||
#### Source: Exec
|
||||
|
||||
Exec source can run any external application and expect data from it. Two transports are supported - **pipe** and **RTSP**.
|
||||
@@ -387,9 +443,8 @@ If you see a device but it does not have a pair button - it is paired to some ec
|
||||
**Important:**
|
||||
|
||||
- HomeKit audio uses very non-standard **AAC-ELD** codec with very non-standard params and specification violation
|
||||
- Audio can be transcoded by [ffmpeg](#source-ffmpeg) source with `#async` option
|
||||
- Audio can be played by `ffplay` with `-use_wallclock_as_timestamps 1 -async 1` options
|
||||
- Audio can't be played in `VLC` and probably any other player
|
||||
- Audio should be transcoded for using with MSE, WebRTC, etc.
|
||||
|
||||
Recommended settings for using HomeKit Camera with WebRTC, MSE, MP4, RTSP:
|
||||
|
||||
@@ -397,13 +452,25 @@ Recommended settings for using HomeKit Camera with WebRTC, MSE, MP4, RTSP:
|
||||
streams:
|
||||
aqara_g3:
|
||||
- hass:Camera-Hub-G3-AB12
|
||||
- ffmpeg:aqara_g3#audio=aac#audio=opus#async
|
||||
- ffmpeg:aqara_g3#audio=aac#audio=opus
|
||||
```
|
||||
|
||||
RTSP link with "normal" audio for any player: `rtsp://192.168.1.123:8554/aqara_g3?video&audio=aac`
|
||||
|
||||
**This source is in active development!** Tested only with [Aqara Camera Hub G3](https://www.aqara.com/eu/product/camera-hub-g3) (both EU and CN versions).
|
||||
|
||||
#### Source: Bubble
|
||||
|
||||
Other names: [ESeeCloud](http://www.eseecloud.com/), [dvr163](http://help.dvr163.com/).
|
||||
|
||||
- you can skip `username`, `password`, `port`, `ch` and `stream` if they are default
|
||||
- setup separate streams for different channels and streams
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
camera1: bubble://username:password@192.168.1.123:34567/bubble/live?ch=0&stream=0
|
||||
```
|
||||
|
||||
#### Source: DVRIP
|
||||
|
||||
Other names: DVR-IP, NetSurveillance, Sofia protocol (NETsurveillance ActiveX plugin XMeye SDK).
|
||||
@@ -415,7 +482,11 @@ Other names: DVR-IP, NetSurveillance, Sofia protocol (NETsurveillance ActiveX pl
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
camera1: dvrip://username:password@192.168.1.123:34567?channel=0&subtype=0
|
||||
only_stream: dvrip://username:password@192.168.1.123:34567?channel=0&subtype=0
|
||||
only_tts: dvrip://username:password@192.168.1.123:34567?backchannel=1
|
||||
two_way_audio:
|
||||
- dvrip://username:password@192.168.1.123:34567?channel=0&subtype=0
|
||||
- dvrip://username:password@192.168.1.123:34567?backchannel=1
|
||||
```
|
||||
|
||||
#### Source: Tapo
|
||||
@@ -434,6 +505,15 @@ streams:
|
||||
camera2: tapo://admin:MD5-PASSWORD-HASH@192.168.1.123
|
||||
```
|
||||
|
||||
#### Source: Kasa
|
||||
|
||||
[TP-Link Kasa](https://www.kasasmart.com/) non-standard protocol [more info](https://medium.com/@hu3vjeen/reverse-engineering-tp-link-kc100-bac4641bf1cd).
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
kasa: kasa://user:pass@192.168.1.123:19443/https/stream/mixed
|
||||
```
|
||||
|
||||
#### Source: Ivideon
|
||||
|
||||
Support public cameras from service [Ivideon](https://tv.ivideon.com/).
|
||||
@@ -447,8 +527,10 @@ streams:
|
||||
|
||||
Support import camera links from [Home Assistant](https://www.home-assistant.io/) config files:
|
||||
|
||||
- support [Generic Camera](https://www.home-assistant.io/integrations/generic/), setup via GUI
|
||||
- support [HomeKit Camera](https://www.home-assistant.io/integrations/homekit_controller/)
|
||||
- [Generic Camera](https://www.home-assistant.io/integrations/generic/), setup via GUI
|
||||
- [HomeKit Camera](https://www.home-assistant.io/integrations/homekit_controller/)
|
||||
- [ONVIF](https://www.home-assistant.io/integrations/onvif/)
|
||||
- [Roborock](https://github.com/humbertogontijo/homeassistant-roborock) vacuums with camera
|
||||
|
||||
```yaml
|
||||
hass:
|
||||
@@ -459,7 +541,23 @@ streams:
|
||||
aqara_g3: hass:Camera-Hub-G3-AB12
|
||||
```
|
||||
|
||||
More cameras, like [Tuya](https://www.home-assistant.io/integrations/tuya/), [ONVIF](https://www.home-assistant.io/integrations/onvif/), and possibly others can also be imported by using [this method](https://github.com/felipecrs/hass-expose-camera-stream-source#importing-home-assistant-cameras-to-go2rtc-andor-frigate).
|
||||
**WebRTC Cameras**
|
||||
|
||||
Any cameras in WebRTC format are supported. But at the moment Home Assistant only supports some [Nest](https://www.home-assistant.io/integrations/nest/) cameras in this fomat.
|
||||
|
||||
The Nest API only allows you to get a link to a stream for 5 minutes. So every 5 minutes the stream will be reconnected.
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
# link to Home Assistant Supervised
|
||||
hass-webrtc1: hass://supervisor?entity_id=camera.nest_doorbell
|
||||
# link to external Hass with Long-Lived Access Tokens
|
||||
hass-webrtc2: hass://192.168.1.123:8123?entity_id=camera.nest_doorbell&token=eyXYZ...
|
||||
```
|
||||
|
||||
**RTSP Cameras**
|
||||
|
||||
By default, the Home Assistant API does not allow you to get dynamic RTSP link to a camera stream. So more cameras, like [Tuya](https://www.home-assistant.io/integrations/tuya/), and possibly others can also be imported by using [this method](https://github.com/felipecrs/hass-expose-camera-stream-source#importing-home-assistant-cameras-to-go2rtc-andor-frigate).
|
||||
|
||||
#### Source: ISAPI
|
||||
|
||||
@@ -472,6 +570,17 @@ streams:
|
||||
- isapi://admin:password@192.168.1.123:80/
|
||||
```
|
||||
|
||||
#### Source: Nest
|
||||
|
||||
Currently only WebRTC cameras are supported. Stream reconnects every 5 minutes.
|
||||
|
||||
For simplicity, it is recommended to connect the Nest/WebRTC camera to the [Home Assistant](#source-hass). But if you can somehow get the below parameters - Nest/WebRTC source will work without Hass.
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
nest-doorbell: nest:?client_id=***&client_secret=***&refresh_token=***&project_id=***&device_id=***
|
||||
```
|
||||
|
||||
#### Source: Roborock
|
||||
|
||||
This source type support Roborock vacuums with cameras. Known working models:
|
||||
@@ -485,17 +594,39 @@ If you have graphic pin for your vacuum - add it as numeric pin (lines: 123, 456
|
||||
|
||||
#### Source: WebRTC
|
||||
|
||||
This source type support two connection formats:
|
||||
This source type support four connection formats.
|
||||
|
||||
- [WebRTC/WHEP](https://www.ietf.org/id/draft-murillo-whep-01.html) - is an unapproved standard for WebRTC video/audio viewers. But it may already be supported in some third-party software. It is supported in go2rtc.
|
||||
- `go2rtc/WebSocket` - This format is only supported in go2rtc. Unlike WHEP it supports asynchronous WebRTC connection and two way audio.
|
||||
**whep**
|
||||
|
||||
[WebRTC/WHEP](https://www.ietf.org/id/draft-murillo-whep-02.html) - is an unapproved standard for WebRTC video/audio viewers. But it may already be supported in some third-party software. It is supported in go2rtc.
|
||||
|
||||
**go2rtc**
|
||||
|
||||
This format is only supported in go2rtc. Unlike WHEP it supports asynchronous WebRTC connection and two way audio.
|
||||
|
||||
**openipc**
|
||||
|
||||
Support connection to [OpenIPC](https://openipc.org/) cameras.
|
||||
|
||||
**wyze**
|
||||
|
||||
Supports connection to [Wyze](https://www.wyze.com/) cameras, using WebRTC protocol. You can use [docker-wyze-bridge](https://github.com/mrlt8/docker-wyze-bridge) project to get connection credentials.
|
||||
|
||||
**kinesis**
|
||||
|
||||
Supports [Amazon Kinesis Video Streams](https://aws.amazon.com/kinesis/video-streams/), using WebRTC protocol. You need to specify signalling WebSocket URL with all credentials in query params, `client_id` and `ice_servers` list in [JSON format](https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer).
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
webrtc1: webrtc:http://192.168.1.123:1984/api/webrtc?src=dahua1
|
||||
webrtc2: webrtc:ws://192.168.1.123:1984/api/ws?src=dahua1
|
||||
webrtc-whep: webrtc:http://192.168.1.123:1984/api/webrtc?src=camera1
|
||||
webrtc-go2rtc: webrtc:ws://192.168.1.123:1984/api/ws?src=camera1
|
||||
webrtc-openipc: webrtc:ws://192.168.1.123/webrtc_ws#format=openipc#ice_servers=[{"urls":"stun:stun.kinesisvideo.eu-north-1.amazonaws.com:443"}]
|
||||
webrtc-wyze: webrtc:http://192.168.1.123:5000/signaling/camera1?kvs#format=wyze
|
||||
webrtc-kinesis: webrtc:wss://...amazonaws.com/?...#format=kinesis#client_id=...#ice_servers=[{...},{...}]
|
||||
```
|
||||
|
||||
**PS.** For `kinesis` sources you can use [echo](#source-echo) to get connection params using `bash`/`python` or any other script language.
|
||||
|
||||
#### Source: WebTorrent
|
||||
|
||||
This source can get a stream from another go2rtc via [WebTorrent](#module-webtorrent) protocol.
|
||||
@@ -509,7 +640,7 @@ streams:
|
||||
|
||||
By default, go2rtc establishes a connection to the source when any client requests it. Go2rtc drops the connection to the source when it has no clients left.
|
||||
|
||||
- Go2rtc also can accepts incoming sources in [RTSP](#source-rtsp), [HTTP](#source-http) and **WebRTC/WHIP** formats
|
||||
- Go2rtc also can accepts incoming sources in [RTSP](#module-rtsp), [RTMP](#module-rtmp), [HTTP](#source-http) and **WebRTC/WHIP** formats
|
||||
- Go2rtc won't stop such a source if it has no clients
|
||||
- You can push data only to existing stream (create stream with empty source in config)
|
||||
- You can push multiple incoming sources to same stream
|
||||
@@ -570,35 +701,44 @@ POST http://localhost:1984/api/streams?dst=camera1&src=ffmpeg:http://example.com
|
||||
- you can stop active playback by calling the API with the empty `src` parameter
|
||||
- you will see one active producer and one active consumer in go2rtc WebUI info page during streaming
|
||||
|
||||
### Publish stream
|
||||
|
||||
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)
|
||||
- You don't need to enable [RTMP module](#module-rtmp) listening for this task
|
||||
|
||||
You can use API:
|
||||
|
||||
```
|
||||
POST http://localhost:1984/api/streams?src=camera1&dst=rtmps://...
|
||||
```
|
||||
|
||||
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:
|
||||
- rtmps://xxx-x.rtmp.t.me/s/xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx
|
||||
- rtmps://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=vaapi#audio=aac
|
||||
```
|
||||
|
||||
- **Telegram Desktop App** > Any public or private channel or group (where you admin) > Live stream > Start with... > Start streaming.
|
||||
- **YouTube** > Create > Go live > Stream latency: Ultra low-latency > Copy: Stream URL + Stream key.
|
||||
|
||||
### Module: API
|
||||
|
||||
The HTTP API is the main part for interacting with the application. Default address: `http://127.0.0.1:1984/`.
|
||||
The HTTP API is the main part for interacting with the application. Default address: `http://localhost:1984/`.
|
||||
|
||||
go2rtc has its own JS video player (`video-rtc.js`) with:
|
||||
|
||||
- support technologies:
|
||||
- WebRTC over UDP or TCP
|
||||
- MSE or MP4 or MJPEG over WebSocket
|
||||
- automatic selection best technology according on:
|
||||
- codecs inside your stream
|
||||
- current browser capabilities
|
||||
- current network configuration
|
||||
- automatic stop stream while browser or page not active
|
||||
- automatic stop stream while player not inside page viewport
|
||||
- automatic reconnection
|
||||
|
||||
Technology selection based on priorities:
|
||||
|
||||
1. Video and Audio better than just Video
|
||||
2. H265 better than H264
|
||||
3. WebRTC better than MSE, than MP4, than MJPEG
|
||||
|
||||
go2rtc has simple HTML page (`stream.html`) with support params in URL:
|
||||
|
||||
- multiple streams on page `src=camera1&src=camera2...`
|
||||
- stream technology autoselection `mode=webrtc,mse,mp4,mjpeg`
|
||||
- stream technology comparison `src=camera1&mode=webrtc&mode=mse&mode=mp4`
|
||||
- player width setting in pixels `width=320px` or percents `width=50%`
|
||||
[API description](https://github.com/AlexxIT/go2rtc/tree/master/api).
|
||||
|
||||
**Module config**
|
||||
|
||||
@@ -606,6 +746,7 @@ go2rtc has simple HTML page (`stream.html`) with support params in URL:
|
||||
- you can enable HTTP API only on localhost with `listen: "127.0.0.1:1984"` setting
|
||||
- you can change API `base_path` and host go2rtc on your main app webserver suburl
|
||||
- all files from `static_dir` hosted on root path: `/`
|
||||
- you can use raw TLS cert/key content or path to files
|
||||
|
||||
```yaml
|
||||
api:
|
||||
@@ -615,11 +756,19 @@ api:
|
||||
base_path: "/rtc" # default "", API prefix for serve on suburl (/api => /rtc/api)
|
||||
static_dir: "www" # default "", folder for static files (custom web interface)
|
||||
origin: "*" # default "", allow CORS requests (only * supported)
|
||||
tls_listen: ":443" # default "", enable HTTPS server
|
||||
tls_cert: | # default "", PEM-encoded fullchain certificate for HTTPS
|
||||
-----BEGIN CERTIFICATE-----
|
||||
...
|
||||
-----END CERTIFICATE-----
|
||||
tls_key: | # default "", PEM-encoded private key for HTTPS
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
...
|
||||
-----END PRIVATE KEY-----
|
||||
```
|
||||
|
||||
**PS:**
|
||||
|
||||
- go2rtc doesn't provide HTTPS. Use [Nginx](https://nginx.org/) or [Ngrok](#module-ngrok) or [Home Assistant Add-on](#go2rtc-home-assistant-add-on) for this tasks
|
||||
- MJPEG over WebSocket plays better than native MJPEG because Chrome [bug](https://bugs.chromium.org/p/chromium/issues/detail?id=527446)
|
||||
- MP4 over WebSocket was created only for Apple iOS because it doesn't support MSE and native MP4
|
||||
|
||||
@@ -646,6 +795,17 @@ By default go2rtc provide RTSP-stream with only one first video and only one fir
|
||||
|
||||
Read more about [codecs filters](#codecs-filters).
|
||||
|
||||
### Module: RTMP
|
||||
|
||||
You can get any stream as RTMP-stream: `rtmp://192.168.1.123/{stream_name}`. Only H264/AAC codecs supported right now.
|
||||
|
||||
[Incoming stream](#incoming-sources) in RTMP-format tested only with [OBS Studio](https://obsproject.com/) and Dahua camera. Different FFmpeg versions has differnt problems with this format.
|
||||
|
||||
```yaml
|
||||
rtmp:
|
||||
listen: ":1935" # by default - disabled!
|
||||
```
|
||||
|
||||
### Module: WebRTC
|
||||
|
||||
In most cases [WebRTC](https://en.wikipedia.org/wiki/WebRTC) uses direct peer-to-peer connection from your browser to go2rtc and sends media data via UDP.
|
||||
@@ -713,6 +873,58 @@ webrtc:
|
||||
credential: your_pass
|
||||
```
|
||||
|
||||
### Module: HomeKit
|
||||
|
||||
HomeKit module can work in two modes:
|
||||
|
||||
- export any H264 camera to Apple HomeKit
|
||||
- transparent proxy any Apple HomeKit camera (Aqara, Eve, Eufy, etc.) back to Apple HomeKit, so you will have all camera features in Apple Home and also will have RTSP/WebRTC/MP4/etc. from your HomeKit camera
|
||||
|
||||
**Important**
|
||||
|
||||
- HomeKit cameras supports only H264 video and OPUS audio
|
||||
|
||||
**Minimal config**
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
dahua1: rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0
|
||||
homekit:
|
||||
dahua1: # same stream ID from streams list, default PIN - 19550224
|
||||
```
|
||||
|
||||
**Full config**
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
dahua1:
|
||||
- rtsp://admin:password@192.168.1.123/cam/realmonitor?channel=1&subtype=0
|
||||
- ffmpeg:dahua1#video=h264#hardware # if your camera doesn't support H264, important for HomeKit
|
||||
- ffmpeg:dahua1#audio=opus # only OPUS audio supported by HomeKit
|
||||
|
||||
homekit:
|
||||
dahua1: # same stream ID from streams list
|
||||
pin: 12345678 # custom PIN, default: 19550224
|
||||
name: Dahua camera # custom camera name, default: generated from stream ID
|
||||
device_id: dahua1 # custom ID, default: generated from stream ID
|
||||
device_private: dahua1 # custom key, default: generated from stream ID
|
||||
```
|
||||
|
||||
**Proxy HomeKit camera**
|
||||
|
||||
- Video stream from HomeKit camera to Apple device (iPhone, AppleTV) will be transmitted directly
|
||||
- Video stream from HomeKit camera to RTSP/WebRTC/MP4/etc. will be transmitted via go2rtc
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
aqara1:
|
||||
- homekit://...
|
||||
- ffmpeg:aqara1#audio=aac#audio=opus # optional audio transcoding
|
||||
|
||||
homekit:
|
||||
aqara1: # same stream ID from streams list
|
||||
```
|
||||
|
||||
### Module: WebTorrent
|
||||
|
||||
This module support:
|
||||
@@ -805,7 +1017,8 @@ You have several options on how to add a camera to Home Assistant:
|
||||
2. Camera [any source](#module-streams) => [go2rtc config](#configuration) => [Generic Camera](https://www.home-assistant.io/integrations/generic/)
|
||||
- Install any [go2rtc](#fast-start)
|
||||
- Add your stream to [go2rtc config](#configuration)
|
||||
- Hass > Settings > Integrations > Add Integration > [Generic Camera](https://my.home-assistant.io/redirect/config_flow_start/?domain=generic) > `rtsp://127.0.0.1:8554/camera1` (change to your stream name)
|
||||
- Hass > Settings > Integrations > Add Integration > [ONVIF](https://my.home-assistant.io/redirect/config_flow_start/?domain=onvif) > Host: `127.0.0.1`, Port: `1984`
|
||||
- Hass > Settings > Integrations > Add Integration > [Generic Camera](https://my.home-assistant.io/redirect/config_flow_start/?domain=generic) > Stream Source URL: `rtsp://127.0.0.1:8554/camera1` (change to your stream name, leave everything else as is)
|
||||
|
||||
You have several options on how to watch the stream from the cameras in Home Assistant:
|
||||
|
||||
@@ -826,7 +1039,9 @@ streams:
|
||||
"camera.hall": ffmpeg:{input}#video=copy#audio=opus
|
||||
```
|
||||
|
||||
PS. Default Home Assistant lovelace cards don't support 2-way audio. You can use 2-way audio from [Add-on Web UI](https://my.home-assistant.io/redirect/supervisor_addon/?addon=a889bffc_go2rtc&repository_url=https%3A%2F%2Fgithub.com%2FAlexxIT%2Fhassio-addons). But you need use HTTPS to access the microphone. This is a browser restriction and cannot be avoided.
|
||||
**PS.** Default Home Assistant lovelace cards don't support 2-way audio. You can use 2-way audio from [Add-on Web UI](https://my.home-assistant.io/redirect/supervisor_addon/?addon=a889bffc_go2rtc&repository_url=https%3A%2F%2Fgithub.com%2FAlexxIT%2Fhassio-addons). But you need use HTTPS to access the microphone. This is a browser restriction and cannot be avoided.
|
||||
|
||||
**PS.** There is also another nice card with go2rtc support - [Frigate Lovelace Card](https://github.com/dermotduffy/frigate-hass-card).
|
||||
|
||||
### Module: MP4
|
||||
|
||||
@@ -840,9 +1055,17 @@ API examples:
|
||||
|
||||
- MP4 snapshot: `http://192.168.1.123:1984/api/frame.mp4?src=camera1` (H264, H265)
|
||||
- MP4 stream: `http://192.168.1.123:1984/api/stream.mp4?src=camera1` (H264, H265, AAC)
|
||||
- MP4 file: `http://192.168.1.123:1984/api/stream.mp4?src=camera1` (H264, H265*, AAC, OPUS, MP3, PCMA, PCMU, PCM)
|
||||
- You can use `mp4`, `mp4=flac` and `mp4=all` param for codec filters
|
||||
- You can use `duration` param in seconds (ex. `duration=15`)
|
||||
- You can use `filename` param (ex. `filename=record.mp4`)
|
||||
- You can use `rotate` param with `90`, `180` or `270` values
|
||||
- You can use `scale` param with positive integer values (ex. `scale=4:3`)
|
||||
|
||||
Read more about [codecs filters](#codecs-filters).
|
||||
|
||||
**PS.** Rotate and scale params don't use transcoding and change video using metadata.
|
||||
|
||||
### Module: HLS
|
||||
|
||||
[HLS](https://en.wikipedia.org/wiki/HTTP_Live_Streaming) is the worst technology for real-time streaming. It can only be useful on devices that do not support more modern technology, like [WebRTC](#module-webrtc), [MSE/MP4](#module-mp4).
|
||||
@@ -880,6 +1103,9 @@ API examples:
|
||||
|
||||
- MJPEG stream: `http://192.168.1.123:1984/api/stream.mjpeg?src=camera1`
|
||||
- JPEG snapshots: `http://192.168.1.123:1984/api/frame.jpeg?src=camera1`
|
||||
- You can use `width`/`w` and/or `height`/`h` params
|
||||
- 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)
|
||||
|
||||
### Module: Log
|
||||
|
||||
@@ -949,17 +1175,21 @@ Some examples:
|
||||
|
||||
`AVC/H.264` video can be played almost anywhere. But `HEVC/H.265` has a lot of limitations in supporting with different devices and browsers. It's all about patents and money, you can't do anything about it.
|
||||
|
||||
| Device | WebRTC | MSE | HTTP Progressive Streaming |
|
||||
|---------------------|-------------------------------|-------------------------------|------------------------------------|
|
||||
| *latency* | best | medium | bad |
|
||||
| Desktop Chrome 107+ | H264, OPUS, PCMU, PCMA | H264, H265*, AAC, FLAC*, OPUS | H264, H265*, AAC, FLAC*, OPUS, MP3 |
|
||||
| Desktop Edge | H264, OPUS, PCMU, PCMA | H264, H265*, AAC, FLAC*, OPUS | H264, H265*, AAC, FLAC*, OPUS, MP3 |
|
||||
| Android Chrome 107+ | H264, OPUS, PCMU, PCMA | H264, H265*, AAC, FLAC*, OPUS | H264, H265*, AAC, FLAC*, OPUS, MP3 |
|
||||
| Desktop Firefox | H264, OPUS, PCMU, PCMA | H264, AAC, FLAC*, OPUS | H264, AAC, FLAC*, OPUS |
|
||||
| Desktop Safari | H264, H265*, OPUS, PCMU, PCMA | H264, H265, AAC, FLAC* | **no!** |
|
||||
| iPad Safari 13+ | H264, H265*, OPUS, PCMU, PCMA | H264, H265, AAC, FLAC* | **no!** |
|
||||
| iPhone Safari 13+ | H264, H265*, OPUS, PCMU, PCMA | **no!** | **no!** |
|
||||
| masOS Hass App | no | no | no |
|
||||
| Device | WebRTC | MSE | HTTP | HLS |
|
||||
|---------------------|-------------------------------|-------------------------------|------------------------------------|------------------------|
|
||||
| *latency* | best | medium | bad | bad |
|
||||
| Desktop Chrome 107+ | H264, OPUS, PCMU, PCMA | H264, H265*, AAC, FLAC*, OPUS | H264, H265*, AAC, FLAC*, OPUS, MP3 | no |
|
||||
| Desktop Edge | H264, OPUS, PCMU, PCMA | H264, H265*, AAC, FLAC*, OPUS | H264, H265*, AAC, FLAC*, OPUS, MP3 | no |
|
||||
| Android Chrome 107+ | H264, OPUS, PCMU, PCMA | H264, H265*, AAC, FLAC*, OPUS | H264, H265*, AAC, FLAC*, OPUS, MP3 | no |
|
||||
| Desktop Firefox | H264, OPUS, PCMU, PCMA | H264, AAC, FLAC*, OPUS | H264, AAC, FLAC*, OPUS | no |
|
||||
| Desktop Safari 14+ | H264, H265*, OPUS, PCMU, PCMA | H264, H265, AAC, FLAC* | **no!** | H264, H265, AAC, FLAC* |
|
||||
| iPad Safari 14+ | H264, H265*, OPUS, PCMU, PCMA | H264, H265, AAC, FLAC* | **no!** | H264, H265, AAC, FLAC* |
|
||||
| iPhone Safari 14+ | H264, H265*, OPUS, PCMU, PCMA | **no!** | **no!** | H264, H265, AAC, FLAC* |
|
||||
| macOS [Hass App][1] | no | no | no | H264, H265, AAC, FLAC* |
|
||||
|
||||
[1]: https://apps.apple.com/app/home-assistant/id1099568401
|
||||
|
||||
`HTTP*` - HTTP Progressive Streaming, not related with [Progressive download](https://en.wikipedia.org/wiki/Progressive_download), because the file has no size and no end
|
||||
|
||||
- Chrome H265: [read this](https://chromestatus.com/feature/5186511939567616) and [read this](https://github.com/StaZhu/enable-chromium-hevc-hardware-decoding)
|
||||
- Edge H265: [read this](https://www.reddit.com/r/MicrosoftEdge/comments/v9iw8k/enable_hevc_support_in_edge/)
|
||||
@@ -1049,16 +1279,23 @@ streams:
|
||||
## Projects using go2rtc
|
||||
|
||||
- [Frigate 12+](https://frigate.video/) - open source NVR built around real-time AI object detection
|
||||
- [Frigate Lovelace Card](https://github.com/dermotduffy/frigate-hass-card) - custom card for Home Assistant
|
||||
- [ring-mqtt](https://github.com/tsightler/ring-mqtt) - Ring devices to MQTT Bridge
|
||||
- [EufyP2PStream](https://github.com/oischinger/eufyp2pstream) - A small project that provides a Video/Audio Stream from Eufy cameras that don't directly support RTSP
|
||||
- [ioBroker.euSec](https://github.com/bropat/ioBroker.eusec) - [ioBroker](https://www.iobroker.net/) adapter for control Eufy security devices
|
||||
- [wz_mini_hacks](https://github.com/gtxaspec/wz_mini_hacks) - Custom firmware for Wyze cameras
|
||||
- [MMM-go2rtc](https://github.com/Anonym-tsk/MMM-go2rtc) - MagicMirror² Module
|
||||
- [Alpine Linux](https://pkgs.alpinelinux.org/packages?name=go2rtc)
|
||||
- [NixOS](https://search.nixos.org/packages?query=go2rtc)
|
||||
- [Proxmox Helper Scripts](https://tteck.github.io/Proxmox/)
|
||||
- [Unraid](https://unraid.net/community/apps?q=go2rtc)
|
||||
|
||||
## Cameras experience
|
||||
|
||||
- [Dahua](https://www.dahuasecurity.com/) - reference implementation streaming protocols, a lot of settings, high stream quality, multiple streaming clients
|
||||
- [EZVIZ](https://www.ezviz.com/) - awful RTSP protocol realisation, many bugs in SDP
|
||||
- [Hikvision](https://www.hikvision.com/) - a lot of proprietary streaming technologies
|
||||
- [Reolink](https://reolink.com/) - some models has awful unusable RTSP realisation and not best HTTP-FLV alternative (I recommend that you contact Reolink support for new firmware), few settings
|
||||
- [Reolink](https://reolink.com/) - some models has awful unusable RTSP realisation and not best RTMP alternative (I recommend that you contact Reolink support for new firmware), few settings
|
||||
- [Sonoff](https://sonoff.tech/) - very low stream quality, no settings, not best protocol implementation
|
||||
- [TP-Link](https://www.tp-link.com/) - few streaming clients, packet loss?
|
||||
- Chinese cheap noname cameras, Wyze Cams, Xiaomi cameras with hacks (usual has `/live/ch00_1` in RTSP URL) - awful but usable RTSP protocol realisation, low stream quality, few settings, packet loss?
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
# API
|
||||
|
||||
Fill free to make any API design proposals.
|
||||
|
||||
## HTTP API
|
||||
|
||||
Interactive [OpenAPI](https://alexxit.github.io/go2rtc/api/).
|
||||
|
||||
`www/stream.html` - universal viewer with support params in URL:
|
||||
|
||||
- multiple streams on page `src=camera1&src=camera2...`
|
||||
- stream technology autoselection `mode=webrtc,webrtc/tcp,mse,hls,mp4,mjpeg`
|
||||
- stream technology comparison `src=camera1&mode=webrtc&mode=mse&mode=mp4`
|
||||
- player width setting in pixels `width=320px` or percents `width=50%`
|
||||
|
||||
`www/webrtc.html` - WebRTC viewer with support two way audio and params in URL:
|
||||
|
||||
- `media=video+audio` - simple viewer
|
||||
- `media=video+audio+microphone` - two way audio from camera
|
||||
- `media=camera+microphone` - stream from browser
|
||||
- `media=display+speaker` - stream from desktop
|
||||
|
||||
## JavaScript API
|
||||
|
||||
- You can write your viewer from the scratch
|
||||
- You can extend the built-in viewer - `www/video-rtc.js`
|
||||
- Check example - `www/video-stream.js`
|
||||
- Check example - https://github.com/AlexxIT/WebRTC
|
||||
|
||||
`video-rtc.js` features:
|
||||
|
||||
- support technologies:
|
||||
- WebRTC over UDP or TCP
|
||||
- MSE or HLS or MP4 or MJPEG over WebSocket
|
||||
- automatic selection best technology according on:
|
||||
- codecs inside your stream
|
||||
- current browser capabilities
|
||||
- current network configuration
|
||||
- automatic stop stream while browser or page not active
|
||||
- automatic stop stream while player not inside page viewport
|
||||
- automatic reconnection
|
||||
|
||||
Technology selection based on priorities:
|
||||
|
||||
1. Video and Audio better than just Video
|
||||
2. H265 better than H264
|
||||
3. WebRTC better than MSE, than HLS, than MJPEG
|
||||
|
||||
## WebSocket API
|
||||
|
||||
Endpoint: `/api/ws`
|
||||
|
||||
Query parameters:
|
||||
|
||||
- `src` (required) - Stream name
|
||||
|
||||
### WebRTC
|
||||
|
||||
Request SDP:
|
||||
|
||||
```json
|
||||
{"type":"webrtc/offer","value":"v=0\r\n..."}
|
||||
```
|
||||
|
||||
Response SDP:
|
||||
|
||||
```json
|
||||
{"type":"webrtc/answer","value":"v=0\r\n..."}
|
||||
```
|
||||
|
||||
Request/response candidate:
|
||||
|
||||
- empty value also allowed and optional
|
||||
|
||||
```json
|
||||
{"type":"webrtc/candidate","value":"candidate:3277516026 1 udp 2130706431 192.168.1.123 54321 typ host"}
|
||||
```
|
||||
|
||||
### MSE
|
||||
|
||||
Request:
|
||||
|
||||
- codecs list optional
|
||||
|
||||
```json
|
||||
{"type":"mse","value":"avc1.640029,avc1.64002A,avc1.640033,hvc1.1.6.L153.B0,mp4a.40.2,mp4a.40.5,flac,opus"}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{"type":"mse","value":"video/mp4; codecs=\"avc1.64001F,mp4a.40.2\""}
|
||||
```
|
||||
|
||||
### HLS
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{"type":"hls","value":"avc1.640029,avc1.64002A,avc1.640033,hvc1.1.6.L153.B0,mp4a.40.2,mp4a.40.5,flac"}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
- you MUST rewrite full HTTP path to `http://192.168.1.123:1984/api/hls/playlist.m3u8`
|
||||
|
||||
```json
|
||||
{"type":"hls","value":"#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS=\"avc1.64001F,mp4a.40.2\"\nhls/playlist.m3u8?id=DvmHdd9w"}
|
||||
```
|
||||
|
||||
### MJPEG
|
||||
|
||||
Request/response:
|
||||
|
||||
```json
|
||||
{"type":"mjpeg"}
|
||||
```
|
||||
@@ -0,0 +1,530 @@
|
||||
openapi: 3.1.0
|
||||
|
||||
info:
|
||||
title: go2rtc
|
||||
license: { name: MIT,url: https://opensource.org/licenses/MIT }
|
||||
version: 1.0.0
|
||||
contact: { url: https://github.com/AlexxIT/go2rtc }
|
||||
description: |
|
||||
Ultimate camera streaming application with support RTSP, RTMP, HTTP-FLV, WebRTC, MSE, HLS, MP4, MJPEG, HomeKit, FFmpeg, etc.
|
||||
|
||||
servers:
|
||||
- url: http://localhost:1984
|
||||
|
||||
components:
|
||||
parameters:
|
||||
stream_src_path:
|
||||
name: src
|
||||
in: path
|
||||
description: Source stream name
|
||||
required: true
|
||||
schema: { type: string }
|
||||
example: camera1
|
||||
stream_dst_path:
|
||||
name: dst
|
||||
in: path
|
||||
description: Destination stream name
|
||||
required: true
|
||||
schema: { type: string }
|
||||
example: camera1
|
||||
stream_src_query:
|
||||
name: src
|
||||
in: query
|
||||
description: Source stream name
|
||||
required: true
|
||||
schema: { type: string }
|
||||
example: camera1
|
||||
mp4_filter:
|
||||
name: mp4
|
||||
in: query
|
||||
description: MP4 codecs filter
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum: [ "", flac, all ]
|
||||
example: flac
|
||||
video_filter:
|
||||
name: video
|
||||
in: query
|
||||
description: Video codecs filter
|
||||
schema:
|
||||
type: string
|
||||
enum: [ "", all, h264, h265, mjpeg ]
|
||||
example: h264,h265
|
||||
audio_filter:
|
||||
name: audio
|
||||
in: query
|
||||
description: Audio codecs filter
|
||||
schema:
|
||||
type: string
|
||||
enum: [ "", all, aac, opus, pcm, pcmu, pcma ]
|
||||
example: aac
|
||||
responses:
|
||||
discovery:
|
||||
description: ""
|
||||
content:
|
||||
application/json:
|
||||
example: { streams: [ { "name": "Camera 1","url": "..." } ] }
|
||||
webtorrent:
|
||||
description: ""
|
||||
content:
|
||||
application/json:
|
||||
example: { share: AKDypPy4zz, pwd: H0Km1HLTTP }
|
||||
|
||||
tags:
|
||||
- name: Application
|
||||
description: "[Module: API](https://github.com/AlexxIT/go2rtc#module-api)"
|
||||
- name: Config
|
||||
description: "[Configuration](https://github.com/AlexxIT/go2rtc#configuration)"
|
||||
- name: Streams list
|
||||
description: "[Module: Streams](https://github.com/AlexxIT/go2rtc#module-streams)"
|
||||
- name: Consume stream
|
||||
- name: Snapshot
|
||||
- name: Produce stream
|
||||
- name: Discovery
|
||||
- name: ONVIF
|
||||
- name: RTSPtoWebRTC
|
||||
- name: WebTorrent
|
||||
description: "[Module: WebTorrent](https://github.com/AlexxIT/go2rtc#module-webtorrent)"
|
||||
- name: Debug
|
||||
|
||||
paths:
|
||||
/api:
|
||||
get:
|
||||
summary: Get application info
|
||||
tags: [ Application ]
|
||||
responses:
|
||||
"200":
|
||||
description: ""
|
||||
content:
|
||||
application/json:
|
||||
example: { config_path: "/config/go2rtc.yaml",host: "192.168.1.123:1984",rtsp: { listen: ":8554",default_query: "video&audio" },version: "1.5.0" }
|
||||
|
||||
/api/exit:
|
||||
post:
|
||||
summary: Close application
|
||||
tags: [ Application ]
|
||||
parameters:
|
||||
- name: code
|
||||
in: query
|
||||
description: Application exit code
|
||||
required: false
|
||||
schema: { type: integer }
|
||||
example: 100
|
||||
responses:
|
||||
default:
|
||||
description: Default response
|
||||
|
||||
|
||||
|
||||
/api/config:
|
||||
get:
|
||||
summary: Get main config file content
|
||||
tags: [ Config ]
|
||||
responses:
|
||||
"200":
|
||||
description: ""
|
||||
content:
|
||||
application/yaml: { example: "streams:..." }
|
||||
post:
|
||||
summary: Rewrite main config file
|
||||
tags: [ Config ]
|
||||
requestBody:
|
||||
content:
|
||||
"*/*": { example: "streams:..." }
|
||||
responses:
|
||||
default:
|
||||
description: Default response
|
||||
patch:
|
||||
summary: Merge changes to main config file
|
||||
tags: [ Config ]
|
||||
requestBody:
|
||||
content:
|
||||
"*/*": { example: "streams:..." }
|
||||
responses:
|
||||
default:
|
||||
description: Default response
|
||||
|
||||
|
||||
|
||||
/api/streams:
|
||||
get:
|
||||
summary: Get all streams info
|
||||
tags: [ Streams list ]
|
||||
responses:
|
||||
"200":
|
||||
description: ""
|
||||
content:
|
||||
application/json: { example: { camera1: { producers: [ ],consumers: [ ] } } }
|
||||
put:
|
||||
summary: Create new stream
|
||||
tags: [ Streams list ]
|
||||
parameters:
|
||||
- name: src
|
||||
in: query
|
||||
description: Stream source (URI)
|
||||
required: true
|
||||
schema: { type: string }
|
||||
example: "rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0"
|
||||
- name: name
|
||||
in: query
|
||||
description: Stream name
|
||||
required: false
|
||||
schema: { type: string }
|
||||
example: camera1
|
||||
responses:
|
||||
default:
|
||||
description: Default response
|
||||
patch:
|
||||
summary: Update stream source
|
||||
tags: [ Streams list ]
|
||||
parameters:
|
||||
- name: src
|
||||
in: query
|
||||
description: Stream source (URI)
|
||||
required: true
|
||||
schema: { type: string }
|
||||
example: "rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0"
|
||||
- name: name
|
||||
in: query
|
||||
description: Stream name
|
||||
required: true
|
||||
schema: { type: string }
|
||||
example: camera1
|
||||
responses:
|
||||
default:
|
||||
description: Default response
|
||||
delete:
|
||||
summary: Delete stream
|
||||
tags: [ Streams list ]
|
||||
parameters:
|
||||
- name: src
|
||||
in: query
|
||||
description: Stream name
|
||||
required: true
|
||||
schema: { type: string }
|
||||
example: camera1
|
||||
responses:
|
||||
default:
|
||||
description: Default response
|
||||
post:
|
||||
summary: Send stream from source to destination
|
||||
description: "[Stream to camera](https://github.com/AlexxIT/go2rtc#stream-to-camera)"
|
||||
tags: [ Streams list ]
|
||||
parameters:
|
||||
- name: src
|
||||
in: query
|
||||
description: Stream source (URI)
|
||||
required: true
|
||||
schema: { type: string }
|
||||
example: "ffmpeg:http://example.com/song.mp3#audio=pcma#input=file"
|
||||
- name: dst
|
||||
in: query
|
||||
description: Destination stream name
|
||||
required: true
|
||||
schema: { type: string }
|
||||
example: camera1
|
||||
responses:
|
||||
default:
|
||||
description: Default response
|
||||
|
||||
|
||||
|
||||
/api/streams?src={src}:
|
||||
get:
|
||||
summary: Get stream info in JSON format
|
||||
tags: [ Consume stream ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_src_path"
|
||||
responses:
|
||||
"200":
|
||||
description: ""
|
||||
content:
|
||||
application/json:
|
||||
example: { producers: [ { url: "rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0" } ], consumers: [ ] }
|
||||
|
||||
/api/webrtc?src={src}:
|
||||
post:
|
||||
summary: Get stream in WebRTC format (WHEP)
|
||||
description: "[Module: WebRTC](https://github.com/AlexxIT/go2rtc#module-webrtc)"
|
||||
tags: [ Consume stream ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_src_path"
|
||||
requestBody:
|
||||
description: |
|
||||
Support:
|
||||
- JSON format (`Content-Type: application/json`)
|
||||
- WHEP standard (`Content-Type: application/sdp`)
|
||||
- raw SDP (`Content-Type: anything`)
|
||||
required: true
|
||||
content:
|
||||
application/json: { example: { type: offer, sdp: "v=0..." } }
|
||||
"application/sdp": { example: "v=0..." }
|
||||
"*/*": { example: "v=0..." }
|
||||
|
||||
responses:
|
||||
"200":
|
||||
description: "Response on JSON or raw SDP"
|
||||
content:
|
||||
application/json: { example: { type: answer, sdp: "v=0..." } }
|
||||
application/sdp: { example: "v=0..." }
|
||||
"201":
|
||||
description: "Response on `Content-Type: application/sdp`"
|
||||
content:
|
||||
application/sdp: { example: "v=0..." }
|
||||
|
||||
/api/stream.mp4?src={src}:
|
||||
get:
|
||||
summary: Get stream in MP4 format (HTTP progressive)
|
||||
description: "[Module: MP4](https://github.com/AlexxIT/go2rtc#module-mp4)"
|
||||
tags: [ Consume stream ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_src_path"
|
||||
- name: duration
|
||||
in: query
|
||||
description: Limit the length of the stream in seconds
|
||||
required: false
|
||||
schema: { type: string }
|
||||
example: 15
|
||||
- name: filename
|
||||
in: query
|
||||
description: Download as a file with this name
|
||||
required: false
|
||||
schema: { type: string }
|
||||
example: camera1.mp4
|
||||
- $ref: "#/components/parameters/mp4_filter"
|
||||
- $ref: "#/components/parameters/video_filter"
|
||||
- $ref: "#/components/parameters/audio_filter"
|
||||
responses:
|
||||
200:
|
||||
description: ""
|
||||
content: { video/mp4: { example: "" } }
|
||||
|
||||
/api/stream.m3u8?src={src}:
|
||||
get:
|
||||
summary: Get stream in HLS format
|
||||
description: "[Module: HLS](https://github.com/AlexxIT/go2rtc#module-hls)"
|
||||
tags: [ Consume stream ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_src_path"
|
||||
- $ref: "#/components/parameters/mp4_filter"
|
||||
- $ref: "#/components/parameters/video_filter"
|
||||
- $ref: "#/components/parameters/audio_filter"
|
||||
responses:
|
||||
200:
|
||||
description: ""
|
||||
content: { application/vnd.apple.mpegurl: { example: "" } }
|
||||
|
||||
/api/stream.mjpeg?src={src}:
|
||||
get:
|
||||
summary: Get stream in MJPEG format
|
||||
description: "[Module: MJPEG](https://github.com/AlexxIT/go2rtc#module-mjpeg)"
|
||||
tags: [ Consume stream ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_src_path"
|
||||
responses:
|
||||
200:
|
||||
description: ""
|
||||
content: { multipart/x-mixed-replace: { example: "" } }
|
||||
|
||||
|
||||
|
||||
/api/frame.jpeg?src={src}:
|
||||
get:
|
||||
summary: Get snapshot in JPEG format
|
||||
description: "[Module: MJPEG](https://github.com/AlexxIT/go2rtc#module-mjpeg)"
|
||||
tags: [ Snapshot ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_src_path"
|
||||
responses:
|
||||
200:
|
||||
description: ""
|
||||
content: { image/jpeg: { example: "" } }
|
||||
/api/frame.mp4?src={src}:
|
||||
get:
|
||||
summary: Get snapshot in MP4 format
|
||||
description: "[Module: MP4](https://github.com/AlexxIT/go2rtc#module-mp4)"
|
||||
tags: [ Snapshot ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_src_path"
|
||||
responses:
|
||||
200:
|
||||
description: ""
|
||||
content: { video/mp4: { example: "" } }
|
||||
|
||||
|
||||
|
||||
/api/webrtc?dst={dst}:
|
||||
post:
|
||||
summary: Post stream in WebRTC format
|
||||
description: "[Incoming: WebRTC/WHIP](https://github.com/AlexxIT/go2rtc#incoming-webrtcwhip)"
|
||||
tags: [ Produce stream ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_dst_path"
|
||||
responses:
|
||||
default:
|
||||
description: Default response
|
||||
/api/stream.flv?dst={dst}:
|
||||
post:
|
||||
summary: Post stream in FLV format
|
||||
description: "[Incoming sources](https://github.com/AlexxIT/go2rtc#incoming-sources)"
|
||||
tags: [ Produce stream ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_dst_path"
|
||||
responses:
|
||||
default:
|
||||
description: Default response
|
||||
/api/stream.ts?dst={dst}:
|
||||
post:
|
||||
summary: Post stream in MPEG-TS format
|
||||
description: "[Incoming sources](https://github.com/AlexxIT/go2rtc#incoming-sources)"
|
||||
tags: [ Produce stream ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_dst_path"
|
||||
responses:
|
||||
default:
|
||||
description: Default response
|
||||
/api/stream.mjpeg?dst={dst}:
|
||||
post:
|
||||
summary: Post stream in MJPEG format
|
||||
description: "[Incoming sources](https://github.com/AlexxIT/go2rtc#incoming-sources)"
|
||||
tags: [ Produce stream ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_dst_path"
|
||||
responses:
|
||||
default:
|
||||
description: Default response
|
||||
|
||||
|
||||
|
||||
/api/dvrip:
|
||||
get:
|
||||
summary: DVRIP cameras discovery
|
||||
description: "[Source: DVRIP](https://github.com/AlexxIT/go2rtc#source-dvrip)"
|
||||
tags: [ Discovery ]
|
||||
responses:
|
||||
default:
|
||||
description: Default response
|
||||
|
||||
/api/ffmpeg/devices:
|
||||
get:
|
||||
summary: FFmpeg USB devices discovery
|
||||
description: "[Source: FFmpeg Device](https://github.com/AlexxIT/go2rtc#source-ffmpeg-device)"
|
||||
tags: [ Discovery ]
|
||||
responses:
|
||||
default:
|
||||
description: Default response
|
||||
/api/ffmpeg/hardware:
|
||||
get:
|
||||
summary: FFmpeg hardware transcoding discovery
|
||||
description: "[Hardware acceleration](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration)"
|
||||
tags: [ Discovery ]
|
||||
responses:
|
||||
default:
|
||||
description: Default response
|
||||
/api/hass:
|
||||
get:
|
||||
summary: Home Assistant cameras discovery
|
||||
description: "[Source: Hass](https://github.com/AlexxIT/go2rtc#source-hass)"
|
||||
tags: [ Discovery ]
|
||||
responses:
|
||||
default:
|
||||
description: Default response
|
||||
/api/homekit:
|
||||
get:
|
||||
summary: HomeKit cameras discovery
|
||||
description: "[Source: HomeKit](https://github.com/AlexxIT/go2rtc#source-homekit)"
|
||||
tags: [ Discovery ]
|
||||
responses:
|
||||
default:
|
||||
description: Default response
|
||||
/api/nest:
|
||||
get:
|
||||
summary: Nest cameras discovery
|
||||
tags: [ Discovery ]
|
||||
responses:
|
||||
default:
|
||||
description: Default response
|
||||
/api/onvif:
|
||||
get:
|
||||
summary: ONVIF cameras discovery
|
||||
description: "[Source: ONVIF](https://github.com/AlexxIT/go2rtc#source-onvif)"
|
||||
tags: [ Discovery ]
|
||||
responses:
|
||||
default:
|
||||
description: Default response
|
||||
/api/roborock:
|
||||
get:
|
||||
summary: Roborock vacuums discovery
|
||||
description: "[Source: Roborock](https://github.com/AlexxIT/go2rtc#source-roborock)"
|
||||
tags: [ Discovery ]
|
||||
responses:
|
||||
default:
|
||||
description: Default response
|
||||
|
||||
|
||||
|
||||
/onvif/:
|
||||
get:
|
||||
summary: ONVIF server implementation
|
||||
description: Simple realisation of the ONVIF protocol. Accepts any suburl requests
|
||||
tags: [ ONVIF ]
|
||||
responses:
|
||||
default:
|
||||
description: Default response
|
||||
|
||||
|
||||
|
||||
/stream/:
|
||||
get:
|
||||
summary: RTSPtoWebRTC server implementation
|
||||
description: Simple API for support [RTSPtoWebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) integration
|
||||
tags: [ RTSPtoWebRTC ]
|
||||
responses:
|
||||
default:
|
||||
description: Default response
|
||||
|
||||
|
||||
|
||||
/api/webtorrent?src={src}:
|
||||
get:
|
||||
summary: Get WebTorrent share info
|
||||
tags: [ WebTorrent ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_src_path"
|
||||
responses:
|
||||
200: { $ref: "#/components/responses/webtorrent" }
|
||||
post:
|
||||
summary: Add WebTorrent share
|
||||
tags: [ WebTorrent ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_src_path"
|
||||
responses:
|
||||
200: { $ref: "#/components/responses/webtorrent" }
|
||||
|
||||
delete:
|
||||
summary: Delete WebTorrent share
|
||||
tags: [ WebTorrent ]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stream_src_path"
|
||||
responses:
|
||||
default:
|
||||
description: Default response
|
||||
|
||||
/api/webtorrent:
|
||||
get:
|
||||
summary: Get all WebTorrent shares info
|
||||
tags: [ WebTorrent ]
|
||||
responses:
|
||||
200: { $ref: "#/components/responses/discovery" }
|
||||
|
||||
|
||||
|
||||
/api/stack:
|
||||
get:
|
||||
summary: Show list unknown goroutines
|
||||
tags: [ Debug ]
|
||||
responses:
|
||||
200:
|
||||
description: ""
|
||||
content: { text/plain: { example: "" } }
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 295 KiB After Width: | Height: | Size: 202 KiB |
@@ -0,0 +1,20 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/hass"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app.Init()
|
||||
streams.Init()
|
||||
|
||||
api.Init()
|
||||
|
||||
hass.Init()
|
||||
|
||||
shell.RunUntilSignal()
|
||||
}
|
||||
@@ -4,9 +4,7 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -15,9 +13,5 @@ func main() {
|
||||
|
||||
rtsp.Init()
|
||||
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigs
|
||||
|
||||
println("exit OK")
|
||||
shell.RunUntilSignal()
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
)
|
||||
|
||||
var servs = map[string]string{
|
||||
"3E": "Accessory Information",
|
||||
"7E": "Security System",
|
||||
"85": "Motion Sensor",
|
||||
"96": "Battery",
|
||||
"A2": "Protocol Information",
|
||||
"110": "Camera RTP Stream Management",
|
||||
"112": "Microphone",
|
||||
"113": "Speaker",
|
||||
"121": "Doorbell",
|
||||
"129": "Data Stream Transport Management",
|
||||
"204": "Camera Recording Management",
|
||||
"21A": "Camera Operating Mode",
|
||||
"22A": "Wi-Fi Transport",
|
||||
"239": "Accessory Runtime Information",
|
||||
}
|
||||
|
||||
var chars = map[string]string{
|
||||
"14": "Identify",
|
||||
"20": "Manufacturer",
|
||||
"21": "Model",
|
||||
"23": "Name",
|
||||
"30": "Serial Number",
|
||||
"52": "Firmware Revision",
|
||||
"53": "Hardware Revision",
|
||||
"220": "Product Data",
|
||||
"A6": "Accessory Flags",
|
||||
|
||||
"22": "Motion Detected",
|
||||
"75": "Status Active",
|
||||
|
||||
"11A": "Mute",
|
||||
"119": "Volume",
|
||||
|
||||
"B0": "Active",
|
||||
"209": "Selected Camera Recording Configuration",
|
||||
"207": "Supported Audio Recording Configuration",
|
||||
"205": "Supported Camera Recording Configuration",
|
||||
"206": "Supported Video Recording Configuration",
|
||||
"226": "Recording Audio Active",
|
||||
|
||||
"223": "Event Snapshots Active",
|
||||
"225": "Periodic Snapshots Active",
|
||||
"21B": "HomeKit Camera Active",
|
||||
"21C": "Third Party Camera Active",
|
||||
"21D": "Camera Operating Mode Indicator",
|
||||
"11B": "Night Vision",
|
||||
"129": "Supported Data Stream Transport Configuration",
|
||||
"37": "Version",
|
||||
"131": "Setup Data Stream Transport",
|
||||
"130": "Supported Data Stream Transport Configuration",
|
||||
|
||||
"120": "Streaming Status",
|
||||
"115": "Supported Audio Stream Configuration",
|
||||
"116": "Supported RTP Configuration",
|
||||
"114": "Supported Video Stream Configuration",
|
||||
"117": "Selected RTP Stream Configuration",
|
||||
"118": "Setup Endpoints",
|
||||
|
||||
"22B": "Current Transport",
|
||||
"22C": "Wi-Fi Capabilities",
|
||||
"22D": "Wi-Fi Configuration Control",
|
||||
|
||||
"23C": "Ping",
|
||||
|
||||
"68": "Battery Level",
|
||||
"79": "Status Low Battery",
|
||||
"8F": "Charging State",
|
||||
|
||||
"73": "Programmable Switch Event",
|
||||
"232": "Operating State Response",
|
||||
|
||||
"66": "Security System Current State",
|
||||
"67": "Security System Target State",
|
||||
}
|
||||
|
||||
func main() {
|
||||
src := os.Args[1]
|
||||
dst := os.Args[2]
|
||||
|
||||
f, err := os.Open(src)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
var v hap.JSONAccessories
|
||||
if err = json.NewDecoder(f).Decode(&v); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
for _, acc := range v.Value {
|
||||
for _, srv := range acc.Services {
|
||||
if srv.Desc == "" {
|
||||
srv.Desc = servs[srv.Type]
|
||||
}
|
||||
for _, chr := range srv.Characters {
|
||||
if chr.Desc == "" {
|
||||
chr.Desc = chars[chr.Type]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
f, err = os.Create(dst)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(f)
|
||||
enc.SetIndent("", " ")
|
||||
if err = enc.Encode(v); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var service = mdns.ServiceHAP
|
||||
|
||||
if len(os.Args) >= 2 {
|
||||
service = os.Args[1]
|
||||
}
|
||||
|
||||
onentry := func(entry *mdns.ServiceEntry) bool {
|
||||
log.Printf("name=%s, addr=%s, info=%s\n", entry.Name, entry.Addr(), entry.Info)
|
||||
return false
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
if len(os.Args) >= 3 {
|
||||
host := os.Args[2]
|
||||
|
||||
log.Printf("run discovery service=%s host=%s\n", service, host)
|
||||
|
||||
err = mdns.QueryOrDiscovery(host, service, onentry)
|
||||
} else {
|
||||
log.Printf("run discovery service=%s\n", service)
|
||||
|
||||
err = mdns.Discovery(service, onentry)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
@@ -1,61 +1,44 @@
|
||||
module github.com/AlexxIT/go2rtc
|
||||
|
||||
go 1.20
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/brutella/hap v0.0.17
|
||||
github.com/deepch/vdk v0.0.19
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/hashicorp/mdns v1.0.5
|
||||
github.com/pion/ice/v2 v2.3.1
|
||||
github.com/pion/interceptor v0.1.12
|
||||
github.com/miekg/dns v1.1.55
|
||||
github.com/pion/ice/v2 v2.3.11
|
||||
github.com/pion/interceptor v0.1.19
|
||||
github.com/pion/rtcp v1.2.10
|
||||
github.com/pion/rtp v1.7.13
|
||||
github.com/pion/rtp v1.8.1
|
||||
github.com/pion/sdp/v3 v3.0.6
|
||||
github.com/pion/srtp/v2 v2.0.12
|
||||
github.com/pion/stun v0.4.0
|
||||
github.com/pion/webrtc/v3 v3.1.58
|
||||
github.com/rs/zerolog v1.29.0
|
||||
github.com/pion/srtp/v2 v2.0.17
|
||||
github.com/pion/stun v0.6.1
|
||||
github.com/pion/webrtc/v3 v3.2.19
|
||||
github.com/rs/zerolog v1.30.0
|
||||
github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3
|
||||
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f
|
||||
github.com/stretchr/testify v1.8.2
|
||||
github.com/stretchr/testify v1.8.4
|
||||
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9
|
||||
golang.org/x/crypto v0.13.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/brutella/dnssd v1.2.5 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/go-chi/chi v1.5.4 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/google/uuid v1.3.1 // indirect
|
||||
github.com/kr/pretty v0.2.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||
github.com/miekg/dns v1.1.52 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/pion/datachannel v1.5.5 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.6 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.7 // indirect
|
||||
github.com/pion/logging v0.2.2 // indirect
|
||||
github.com/pion/mdns v0.0.7 // indirect
|
||||
github.com/pion/mdns v0.0.9 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/sctp v1.8.6 // indirect
|
||||
github.com/pion/transport/v2 v2.0.2 // indirect
|
||||
github.com/pion/turn/v2 v2.1.0 // indirect
|
||||
github.com/pion/udp/v2 v2.0.1 // indirect
|
||||
github.com/pion/sctp v1.8.9 // indirect
|
||||
github.com/pion/transport/v2 v2.2.4 // indirect
|
||||
github.com/pion/turn/v2 v2.1.3 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/xiam/to v0.0.0-20200126224905-d60d31e03561 // indirect
|
||||
golang.org/x/crypto v0.7.0 // indirect
|
||||
golang.org/x/mod v0.9.0 // indirect
|
||||
golang.org/x/net v0.8.0 // indirect
|
||||
golang.org/x/sys v0.6.0 // indirect
|
||||
golang.org/x/text v0.8.0 // indirect
|
||||
golang.org/x/tools v0.7.0 // indirect
|
||||
)
|
||||
|
||||
replace (
|
||||
// windows support: https://github.com/brutella/dnssd/pull/35
|
||||
github.com/brutella/dnssd v1.2.2 => github.com/rblenkinsopp/dnssd v1.2.3-0.20220516082132-0923f3c787a1
|
||||
// RTP tlv8 fix
|
||||
github.com/brutella/hap v0.0.17 => github.com/AlexxIT/hap v0.0.15-0.20221108133010-d8a45b7a7045
|
||||
// fix reading AAC config bytes
|
||||
github.com/deepch/vdk v0.0.19 => github.com/AlexxIT/vdk v0.0.18-0.20221108193131-6168555b4f92
|
||||
golang.org/x/mod v0.12.0 // indirect
|
||||
golang.org/x/net v0.15.0 // indirect
|
||||
golang.org/x/sys v0.12.0 // indirect
|
||||
golang.org/x/tools v0.13.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
github.com/AlexxIT/hap v0.0.15-0.20221108133010-d8a45b7a7045 h1:xJf3FxQJReJSDyYXJfI1NUWv8tUEAGNV9xigLqNtmrI=
|
||||
github.com/AlexxIT/hap v0.0.15-0.20221108133010-d8a45b7a7045/go.mod h1:QNA3sm16zE5uUyC8+E/gNkMvQWjqQLuxQKkU5PMi8N4=
|
||||
github.com/AlexxIT/vdk v0.0.18-0.20221108193131-6168555b4f92 h1:cIeYMGaAirSZnrKRDTb5VgZDDYqPLhYiczElMg4sQW0=
|
||||
github.com/AlexxIT/vdk v0.0.18-0.20221108193131-6168555b4f92/go.mod h1:7ydHfSkflMZxBXfWR79dMjrT54xzvLxnPaByOa9Jpzg=
|
||||
github.com/brutella/dnssd v1.2.3/go.mod h1:JoW2sJUrmVIef25G6lrLj7HS6Xdwh6q8WUIvMkkBYXs=
|
||||
github.com/brutella/dnssd v1.2.5 h1:b8syhho41/5ikw3X2X4baR9NWEBSlpZnfQgujsv7bk4=
|
||||
github.com/brutella/dnssd v1.2.5/go.mod h1:JoW2sJUrmVIef25G6lrLj7HS6Xdwh6q8WUIvMkkBYXs=
|
||||
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs=
|
||||
github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
@@ -28,12 +19,10 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
|
||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/mdns v1.0.5 h1:1M5hW1cunYeoXOqHwEb/GBDDHAFo0Yqb/uz/beC6LbE=
|
||||
github.com/hashicorp/mdns v1.0.5/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
||||
@@ -46,12 +35,10 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
|
||||
github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME=
|
||||
github.com/miekg/dns v1.1.52 h1:Bmlc/qsNNULOe6bpXcUTsuOajd0DzRHwup6D9k1An0c=
|
||||
github.com/miekg/dns v1.1.52/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo=
|
||||
github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
@@ -63,48 +50,53 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
|
||||
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||
github.com/pion/datachannel v1.5.5 h1:10ef4kwdjije+M9d7Xm9im2Y3O6A6ccQb0zcqZcJew8=
|
||||
github.com/pion/datachannel v1.5.5/go.mod h1:iMz+lECmfdCMqFRhXhcA/219B0SQlbpoR2V118yimL0=
|
||||
github.com/pion/dtls/v2 v2.2.6 h1:yXMxKr0Skd+Ub6A8UqXTRLSywskx93ooMRHsQUtd+Z4=
|
||||
github.com/pion/dtls/v2 v2.2.6/go.mod h1:t8fWJCIquY5rlQZwA2yWxUS1+OCrAdXrhVKXB5oD/wY=
|
||||
github.com/pion/ice/v2 v2.3.1 h1:FQCmUfZe2Jpe7LYStVBOP6z1DiSzbIateih3TztgTjc=
|
||||
github.com/pion/ice/v2 v2.3.1/go.mod h1:aq2kc6MtYNcn4XmMhobAv6hTNJiHzvD0yXRz80+bnP8=
|
||||
github.com/pion/interceptor v0.1.12 h1:CslaNriCFUItiXS5o+hh5lpL0t0ytQkFnUcbbCs2Zq8=
|
||||
github.com/pion/interceptor v0.1.12/go.mod h1:bDtgAD9dRkBZpWHGKaoKb42FhDHTG2rX8Ii9LRALLVA=
|
||||
github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8=
|
||||
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
||||
github.com/pion/ice/v2 v2.3.11 h1:rZjVmUwyT55cmN8ySMpL7rsS8KYsJERsrxJLLxpKhdw=
|
||||
github.com/pion/ice/v2 v2.3.11/go.mod h1:hPcLC3kxMa+JGRzMHqQzjoSj3xtE9F+eoncmXLlCL4E=
|
||||
github.com/pion/interceptor v0.1.18/go.mod h1:tpvvF4cPM6NGxFA1DUMbhabzQBxdWMATDGEUYOR9x6I=
|
||||
github.com/pion/interceptor v0.1.19 h1:tq0TGBzuZQqipyBhaC1mVUCfCh8XjDKUuibq9rIl5t4=
|
||||
github.com/pion/interceptor v0.1.19/go.mod h1:VANhFxdJezB8mwToMMmrmyHyP9gym6xLqIUch31xryg=
|
||||
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
|
||||
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
||||
github.com/pion/mdns v0.0.7 h1:P0UB4Sr6xDWEox0kTVxF0LmQihtCbSAdW0H2nEgkA3U=
|
||||
github.com/pion/mdns v0.0.7/go.mod h1:4iP2UbeFhLI/vWju/bw6ZfwjJzk0z8DNValjGxR/dD8=
|
||||
github.com/pion/mdns v0.0.8/go.mod h1:hYE72WX8WDveIhg7fmXgMKivD3Puklk0Ymzog0lSyaI=
|
||||
github.com/pion/mdns v0.0.9 h1:7Ue5KZsqq8EuqStnpPWV33vYYEH0+skdDN5L7EiEsI4=
|
||||
github.com/pion/mdns v0.0.9/go.mod h1:2JA5exfxwzXiCihmxpTKgFUpiQws2MnipoPK09vecIc=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtcp v1.2.10 h1:nkr3uj+8Sp97zyItdN60tE/S6vk4al5CPRR6Gejsdjc=
|
||||
github.com/pion/rtcp v1.2.10/go.mod h1:ztfEwXZNLGyF1oQDttz/ZKIBaeeg/oWbRYqzBM9TL1I=
|
||||
github.com/pion/rtp v1.7.13 h1:qcHwlmtiI50t1XivvoawdCGTP4Uiypzfrsap+bijcoA=
|
||||
github.com/pion/rtp v1.7.13/go.mod h1:bDb5n+BFZxXx0Ea7E5qe+klMuqiBrP+w8XSjiWtCUko=
|
||||
github.com/pion/rtp v1.8.1 h1:26OxTc6lKg/qLSGir5agLyj0QKaOv8OP5wps2SFnVNQ=
|
||||
github.com/pion/rtp v1.8.1/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
|
||||
github.com/pion/sctp v1.8.5/go.mod h1:SUFFfDpViyKejTAdwD1d/HQsCu+V/40cCs2nZIvC3s0=
|
||||
github.com/pion/sctp v1.8.6 h1:CUex11Vkt9YS++VhLf8b55O3VqKrWL6W3SDwX4jAqsI=
|
||||
github.com/pion/sctp v1.8.6/go.mod h1:SUFFfDpViyKejTAdwD1d/HQsCu+V/40cCs2nZIvC3s0=
|
||||
github.com/pion/sctp v1.8.8/go.mod h1:igF9nZBrjh5AtmKc7U30jXltsFHicFCXSmWA2GWRaWs=
|
||||
github.com/pion/sctp v1.8.9 h1:TP5ZVxV5J7rz7uZmbyvnUvsn7EJ2x/5q9uhsTtXbI3g=
|
||||
github.com/pion/sctp v1.8.9/go.mod h1:cMLT45jqw3+jiJCrtHVwfQLnfR0MGZ4rgOJwUOIqLkI=
|
||||
github.com/pion/sdp/v3 v3.0.6 h1:WuDLhtuFUUVpTfus9ILC4HRyHsW6TdugjEX/QY9OiUw=
|
||||
github.com/pion/sdp/v3 v3.0.6/go.mod h1:iiFWFpQO8Fy3S5ldclBkpXqmWy02ns78NOKoLLL0YQw=
|
||||
github.com/pion/srtp/v2 v2.0.12 h1:WrmiVCubGMOAObBU1vwWjG0H3VSyQHawKeer2PVA5rY=
|
||||
github.com/pion/srtp/v2 v2.0.12/go.mod h1:C3Ep44hlOo2qEYaq4ddsmK5dL63eLehXFbHaZ9F5V9Y=
|
||||
github.com/pion/stun v0.4.0 h1:vgRrbBE2htWHy7l3Zsxckk7rkjnjOsSM7PHZnBwo8rk=
|
||||
github.com/pion/stun v0.4.0/go.mod h1:QPsh1/SbXASntw3zkkrIk3ZJVKz4saBY2G7S10P3wCw=
|
||||
github.com/pion/srtp/v2 v2.0.17 h1:ECuOk+7uIpY6HUlTb0nXhfvu4REG2hjtC4ronYFCZE4=
|
||||
github.com/pion/srtp/v2 v2.0.17/go.mod h1:y5WSHcJY4YfNB/5r7ca5YjHeIr1H3LM1rKArGGs8jMc=
|
||||
github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4=
|
||||
github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8=
|
||||
github.com/pion/transport v0.14.1 h1:XSM6olwW+o8J4SCmOBb/BpwZypkHeyM0PGFCxNQBr40=
|
||||
github.com/pion/transport v0.14.1/go.mod h1:4tGmbk00NeYA3rUa9+n+dzCCoKkcy3YlYb99Jn2fNnI=
|
||||
github.com/pion/transport/v2 v2.0.0/go.mod h1:HS2MEBJTwD+1ZI2eSXSvHJx/HnzQqRy2/LXxt6eVMHc=
|
||||
github.com/pion/transport/v2 v2.0.2 h1:St+8o+1PEzPT51O9bv+tH/KYYLMNR5Vwm5Z3Qkjsywg=
|
||||
github.com/pion/transport/v2 v2.0.2/go.mod h1:vrz6bUbFr/cjdwbnxq8OdDDzHf7JJfGsIRkxfpZoTA0=
|
||||
github.com/pion/turn/v2 v2.1.0 h1:5wGHSgGhJhP/RpabkUb/T9PdsAjkGLS6toYz5HNzoSI=
|
||||
github.com/pion/turn/v2 v2.1.0/go.mod h1:yrT5XbXSGX1VFSF31A3c1kCNB5bBZgk/uu5LET162qs=
|
||||
github.com/pion/udp/v2 v2.0.1 h1:xP0z6WNux1zWEjhC7onRA3EwwSliXqu1ElUZAQhUP54=
|
||||
github.com/pion/udp/v2 v2.0.1/go.mod h1:B7uvTMP00lzWdyMr/1PVZXtV3wpPIxBRd4Wl6AksXn8=
|
||||
github.com/pion/webrtc/v3 v3.1.58 h1:husXqiKQuk6gbOqJlPHs185OskAyxUW6iAEgHghgCrc=
|
||||
github.com/pion/webrtc/v3 v3.1.58/go.mod h1:jJdqoqGBlZiE3y8Z1tg1fjSkyEDCZLL+foypUBn0Lhk=
|
||||
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
|
||||
github.com/pion/transport/v2 v2.2.2/go.mod h1:OJg3ojoBJopjEeECq2yJdXH9YVrUJ1uQ++NjXLOUorc=
|
||||
github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
|
||||
github.com/pion/transport/v2 v2.2.4 h1:41JJK6DZQYSeVLxILA2+F4ZkKb4Xd/tFJZRFZQ9QAlo=
|
||||
github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
|
||||
github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM=
|
||||
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
|
||||
github.com/pion/turn/v2 v2.1.3 h1:pYxTVWG2gpC97opdRc5IGsQ1lJ9O/IlNhkzj7MMrGAA=
|
||||
github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
||||
github.com/pion/webrtc/v3 v3.2.19 h1:XNu5e62mkzafw1qYuKtQ+Dviw4JpbzC/SLx3zZt49JY=
|
||||
github.com/pion/webrtc/v3 v3.2.19/go.mod h1:vVURQTBOG5BpWKOJz3nlr23NfTDeyKVmubRNqzQp+Tg=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w=
|
||||
github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.30.0 h1:SymVODrcRsaRaSInD9yQtKbtWqwsfoPcRff/oRXLj4c=
|
||||
github.com/rs/zerolog v1.30.0/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w=
|
||||
github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
|
||||
github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3 h1:aQKxg3+2p+IFXXg97McgDGT5zcMrQoi0EICZs8Pgchs=
|
||||
github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA=
|
||||
@@ -113,58 +105,56 @@ github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f/go.mod h1:vQhwQ4meQEDf
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 h1:aeN+ghOV0b2VCmKKO3gqnDQ8mLbpABZgRR2FVYx4ouI=
|
||||
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9/go.mod h1:roo6cZ/uqpwKMuvPG0YmzI5+AmUiMWfjCBZpGXqbTxE=
|
||||
github.com/xiam/to v0.0.0-20200126224905-d60d31e03561 h1:SVoNK97S6JlaYlHcaC+79tg3JUlQABcc0dH2VQ4Y+9s=
|
||||
github.com/xiam/to v0.0.0-20200126224905-d60d31e03561/go.mod h1:cqbG7phSzrbdg3aj+Kn63bpVruzwDZi58CpxlZkjwzw=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
|
||||
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
|
||||
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
||||
golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
|
||||
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs=
|
||||
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
|
||||
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ=
|
||||
golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
|
||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -175,10 +165,7 @@ golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -187,31 +174,43 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo=
|
||||
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
|
||||
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.7.0 h1:W4OVu8VVOaIO0yzWMNdepAulS7YfoS3Zabrm8DOXXU4=
|
||||
golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -231,7 +230,6 @@ gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMy
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+8
-2
@@ -3,7 +3,7 @@
|
||||
# 0. Prepare images
|
||||
# only debian 12 (bookworm) has latest ffmpeg
|
||||
ARG DEBIAN_VERSION="bookworm-slim"
|
||||
ARG GO_VERSION="1.20-buster"
|
||||
ARG GO_VERSION="1.21-bookworm"
|
||||
ARG NGROK_VERSION="3"
|
||||
|
||||
FROM debian:${DEBIAN_VERSION} AS base
|
||||
@@ -12,7 +12,13 @@ FROM ngrok/ngrok:${NGROK_VERSION} AS ngrok
|
||||
|
||||
|
||||
# 1. Build go2rtc binary
|
||||
FROM go AS build
|
||||
FROM --platform=$BUILDPLATFORM go AS build
|
||||
ARG TARGETPLATFORM
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
ENV GOOS=${TARGETOS}
|
||||
ENV GOARCH=${TARGETARCH}
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
## Go
|
||||
|
||||
```
|
||||
go mod why github.com/pion/rtcp
|
||||
go list -deps .\cmd\go2rtc_rtsp\
|
||||
```
|
||||
|
||||
## Useful links
|
||||
|
||||
- https://github.com/golang-standards/project-layout
|
||||
- https://github.com/micro/micro
|
||||
@@ -0,0 +1,4 @@
|
||||
## Exit codes
|
||||
|
||||
- https://tldp.org/LDP/abs/html/exitcodes.html
|
||||
- https://komodor.com/learn/exit-codes-in-containers-and-kubernetes-the-complete-guide/
|
||||
+124
-25
@@ -1,15 +1,19 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/rs/zerolog"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
@@ -21,11 +25,14 @@ func Init() {
|
||||
BasePath string `yaml:"base_path"`
|
||||
StaticDir string `yaml:"static_dir"`
|
||||
Origin string `yaml:"origin"`
|
||||
TLSListen string `yaml:"tls_listen"`
|
||||
TLSCert string `yaml:"tls_cert"`
|
||||
TLSKey string `yaml:"tls_key"`
|
||||
} `yaml:"api"`
|
||||
}
|
||||
|
||||
// default config
|
||||
cfg.Mod.Listen = ":1984"
|
||||
cfg.Mod.Listen = "0.0.0.0:1984"
|
||||
|
||||
// load config from YAML
|
||||
app.LoadConfig(&cfg)
|
||||
@@ -38,15 +45,15 @@ func Init() {
|
||||
log = app.GetLogger("api")
|
||||
|
||||
initStatic(cfg.Mod.StaticDir)
|
||||
initWS(cfg.Mod.Origin)
|
||||
|
||||
HandleFunc("api", apiHandler)
|
||||
HandleFunc("api/config", configHandler)
|
||||
HandleFunc("api/exit", exitHandler)
|
||||
HandleFunc("api/ws", apiWS)
|
||||
HandleFunc("api/restart", restartHandler)
|
||||
|
||||
// ensure we can listen without errors
|
||||
listener, err := net.Listen("tcp", cfg.Mod.Listen)
|
||||
var err error
|
||||
ln, err = net.Listen("tcp", cfg.Mod.Listen)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("[api] listen")
|
||||
return
|
||||
@@ -71,12 +78,61 @@ func Init() {
|
||||
go func() {
|
||||
s := http.Server{}
|
||||
s.Handler = Handler
|
||||
if err = s.Serve(listener); err != nil {
|
||||
if err = s.Serve(ln); err != nil {
|
||||
log.Fatal().Err(err).Msg("[api] serve")
|
||||
}
|
||||
}()
|
||||
|
||||
// Initialize the HTTPS server
|
||||
if cfg.Mod.TLSListen != "" && cfg.Mod.TLSCert != "" && cfg.Mod.TLSKey != "" {
|
||||
var cert tls.Certificate
|
||||
if strings.IndexByte(cfg.Mod.TLSCert, '\n') < 0 && strings.IndexByte(cfg.Mod.TLSKey, '\n') < 0 {
|
||||
// check if file path
|
||||
cert, err = tls.LoadX509KeyPair(cfg.Mod.TLSCert, cfg.Mod.TLSKey)
|
||||
} else {
|
||||
// if text file content
|
||||
cert, err = tls.X509KeyPair([]byte(cfg.Mod.TLSCert), []byte(cfg.Mod.TLSKey))
|
||||
}
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
tlsListener, err := net.Listen("tcp", cfg.Mod.TLSListen)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().Str("addr", cfg.Mod.TLSListen).Msg("[api] tls listen")
|
||||
|
||||
tlsServer := &http.Server{
|
||||
Handler: Handler,
|
||||
TLSConfig: &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
},
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := tlsServer.ServeTLS(tlsListener, "", ""); err != nil {
|
||||
log.Fatal().Err(err).Msg("[api] tls serve")
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func Port() int {
|
||||
if ln == nil {
|
||||
return 0
|
||||
}
|
||||
return ln.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
const (
|
||||
MimeJSON = "application/json"
|
||||
MimeText = "text/plain"
|
||||
)
|
||||
|
||||
var Handler http.Handler
|
||||
|
||||
// HandleFunc handle pattern with relative path:
|
||||
@@ -90,6 +146,33 @@ func HandleFunc(pattern string, handler http.HandlerFunc) {
|
||||
http.HandleFunc(pattern, handler)
|
||||
}
|
||||
|
||||
// ResponseJSON important always add Content-Type
|
||||
// so go won't need to call http.DetectContentType
|
||||
func ResponseJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", MimeJSON)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func ResponsePrettyJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", MimeJSON)
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(v)
|
||||
}
|
||||
|
||||
func Response(w http.ResponseWriter, body any, contentType string) {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
|
||||
switch v := body.(type) {
|
||||
case []byte:
|
||||
_, _ = w.Write(v)
|
||||
case string:
|
||||
_, _ = w.Write([]byte(v))
|
||||
default:
|
||||
_, _ = fmt.Fprint(w, body)
|
||||
}
|
||||
}
|
||||
|
||||
const StreamNotFound = "stream not found"
|
||||
|
||||
var basePath string
|
||||
@@ -126,6 +209,7 @@ func middlewareCORS(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
var ln net.Listener
|
||||
var mu sync.Mutex
|
||||
|
||||
func apiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -133,9 +217,7 @@ func apiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
app.Info["host"] = r.Host
|
||||
mu.Unlock()
|
||||
|
||||
if err := json.NewEncoder(w).Encode(app.Info); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
ResponseJSON(w, app.Info)
|
||||
}
|
||||
|
||||
func exitHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -149,22 +231,39 @@ func exitHandler(w http.ResponseWriter, r *http.Request) {
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
type Stream struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func ResponseStreams(w http.ResponseWriter, streams []Stream) {
|
||||
if len(streams) == 0 {
|
||||
http.Error(w, "no streams", http.StatusNotFound)
|
||||
func restartHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Streams []Stream `json:"streams"`
|
||||
}
|
||||
response.Streams = streams
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
go shell.Restart()
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Info string `json:"info,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
}
|
||||
|
||||
func ResponseSources(w http.ResponseWriter, sources []*Source) {
|
||||
if len(sources) == 0 {
|
||||
http.Error(w, "no sources", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var response = struct {
|
||||
Sources []*Source `json:"sources"`
|
||||
}{
|
||||
Sources: sources,
|
||||
}
|
||||
ResponseJSON(w, response)
|
||||
}
|
||||
|
||||
func Error(w http.ResponseWriter, err error) {
|
||||
log.Error().Err(err).Caller(1).Send()
|
||||
|
||||
http.Error(w, err.Error(), http.StatusInsufficientStorage)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"gopkg.in/yaml.v3"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func configHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -21,9 +22,8 @@ func configHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if _, err = w.Write(data); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
// https://www.ietf.org/archive/id/draft-ietf-httpapi-yaml-mediatypes-00.html
|
||||
Response(w, data, "application/yaml")
|
||||
|
||||
case "POST", "PATCH":
|
||||
data, err := io.ReadAll(r.Body)
|
||||
@@ -41,8 +41,7 @@ func configHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
} else {
|
||||
// validate config
|
||||
var tmp struct{}
|
||||
if err = yaml.Unmarshal(data, &tmp); err != nil {
|
||||
if err = yaml.Unmarshal(data, map[string]any{}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
package api
|
||||
package ws
|
||||
|
||||
import (
|
||||
"github.com/gorilla/websocket"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var cfg struct {
|
||||
Mod struct {
|
||||
Origin string `yaml:"origin"`
|
||||
} `yaml:"api"`
|
||||
}
|
||||
|
||||
app.LoadConfig(&cfg)
|
||||
|
||||
initWS(cfg.Mod.Origin)
|
||||
|
||||
api.HandleFunc("api/ws", apiWS)
|
||||
}
|
||||
|
||||
// Message - struct for data exchange in Web API
|
||||
type Message struct {
|
||||
Type string `json:"type"`
|
||||
@@ -33,7 +52,7 @@ func (m *Message) GetString(key string) string {
|
||||
|
||||
type WSHandler func(tr *Transport, msg *Message) error
|
||||
|
||||
func HandleWS(msgType string, handler WSHandler) {
|
||||
func HandleFunc(msgType string, handler WSHandler) {
|
||||
wsHandlers[msgType] = handler
|
||||
}
|
||||
|
||||
@@ -84,13 +103,13 @@ func apiWS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
tr := &Transport{Request: r}
|
||||
tr.OnWrite(func(msg any) {
|
||||
tr.OnWrite(func(msg any) error {
|
||||
_ = ws.SetWriteDeadline(time.Now().Add(time.Second * 5))
|
||||
|
||||
if data, ok := msg.([]byte); ok {
|
||||
_ = ws.WriteMessage(websocket.BinaryMessage, data)
|
||||
return ws.WriteMessage(websocket.BinaryMessage, data)
|
||||
} else {
|
||||
_ = ws.WriteJSON(msg)
|
||||
return ws.WriteJSON(msg)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -130,11 +149,11 @@ type Transport struct {
|
||||
wrmx sync.Mutex
|
||||
|
||||
onChange func()
|
||||
onWrite func(msg any)
|
||||
onWrite func(msg any) error
|
||||
onClose []func()
|
||||
}
|
||||
|
||||
func (t *Transport) OnWrite(f func(msg any)) {
|
||||
func (t *Transport) OnWrite(f func(msg any) error) {
|
||||
t.mx.Lock()
|
||||
if t.onChange != nil {
|
||||
t.onChange()
|
||||
@@ -145,7 +164,7 @@ func (t *Transport) OnWrite(f func(msg any)) {
|
||||
|
||||
func (t *Transport) Write(msg any) {
|
||||
t.wrmx.Lock()
|
||||
t.onWrite(msg)
|
||||
_ = t.onWrite(msg)
|
||||
t.wrmx.Unlock()
|
||||
}
|
||||
|
||||
@@ -183,3 +202,20 @@ func (t *Transport) WithContext(f func(ctx map[any]any)) {
|
||||
f(t.ctx)
|
||||
t.mx.Unlock()
|
||||
}
|
||||
|
||||
func (t *Transport) Writer() io.Writer {
|
||||
return &writer{t: t}
|
||||
}
|
||||
|
||||
type writer struct {
|
||||
t *Transport
|
||||
}
|
||||
|
||||
func (w *writer) Write(p []byte) (n int, err error) {
|
||||
w.t.wrmx.Lock()
|
||||
if err = w.t.onWrite(p); err == nil {
|
||||
n = len(p)
|
||||
}
|
||||
w.t.wrmx.Unlock()
|
||||
return
|
||||
}
|
||||
+21
-2
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -11,12 +12,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
"github.com/AlexxIT/go2rtc/pkg/yaml"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var Version = "1.5.0"
|
||||
var Version = ""
|
||||
var UserAgent = "go2rtc/" + Version
|
||||
|
||||
var ConfigPath string
|
||||
@@ -81,6 +82,8 @@ func Init() {
|
||||
modules = cfg.Mod
|
||||
|
||||
log.Info().Msgf("go2rtc version %s %s/%s", Version, runtime.GOOS, runtime.GOARCH)
|
||||
|
||||
migrateStore()
|
||||
}
|
||||
|
||||
func NewLogger(format string, level string) zerolog.Logger {
|
||||
@@ -123,6 +126,22 @@ func GetLogger(module string) zerolog.Logger {
|
||||
return log.Logger
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/rs/zerolog/log"
|
||||
"os"
|
||||
)
|
||||
|
||||
const name = "go2rtc.json"
|
||||
|
||||
var store map[string]any
|
||||
|
||||
func load() {
|
||||
data, _ := os.ReadFile(name)
|
||||
if data != nil {
|
||||
if err := json.Unmarshal(data, &store); err != nil {
|
||||
// TODO: log
|
||||
log.Warn().Err(err).Msg("[app] read storage")
|
||||
}
|
||||
}
|
||||
|
||||
if store == nil {
|
||||
store = make(map[string]any)
|
||||
}
|
||||
}
|
||||
|
||||
func save() error {
|
||||
data, err := json.Marshal(store)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(name, data, 0644)
|
||||
}
|
||||
|
||||
func GetRaw(key string) any {
|
||||
if store == nil {
|
||||
load()
|
||||
}
|
||||
|
||||
return store[key]
|
||||
}
|
||||
|
||||
func GetDict(key string) map[string]any {
|
||||
raw := GetRaw(key)
|
||||
if raw != nil {
|
||||
return raw.(map[string]any)
|
||||
}
|
||||
|
||||
return make(map[string]any)
|
||||
}
|
||||
|
||||
func Set(key string, v any) error {
|
||||
if store == nil {
|
||||
load()
|
||||
}
|
||||
|
||||
store[key] = v
|
||||
|
||||
return save()
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package bubble
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/bubble"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
)
|
||||
|
||||
var stackSkip = [][]byte{
|
||||
@@ -23,6 +25,9 @@ var stackSkip = [][]byte{
|
||||
[]byte("created by github.com/AlexxIT/go2rtc/internal/rtsp.Init"),
|
||||
[]byte("created by github.com/AlexxIT/go2rtc/internal/srtp.Init"),
|
||||
|
||||
// homekit
|
||||
[]byte("created by github.com/AlexxIT/go2rtc/internal/homekit.Init"),
|
||||
|
||||
// webrtc/api.go
|
||||
[]byte("created by github.com/pion/ice/v2.NewTCPMuxDefault"),
|
||||
[]byte("created by github.com/pion/ice/v2.NewUDPMuxDefault"),
|
||||
@@ -51,7 +56,5 @@ func stackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
"Total: %d, Skipped: %d", runtime.NumGoroutine(), skipped),
|
||||
)
|
||||
|
||||
if _, err := w.Write(buf[:i]); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
api.Response(w, buf[:i], api.MimeText)
|
||||
}
|
||||
|
||||
+158
-9
@@ -1,25 +1,174 @@
|
||||
package dvrip
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/dvrip"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("dvrip", handle)
|
||||
|
||||
// DVRIP client autodiscovery
|
||||
api.HandleFunc("api/dvrip", apiDvrip)
|
||||
}
|
||||
|
||||
func handle(url string) (core.Producer, error) {
|
||||
conn := dvrip.NewClient(url)
|
||||
if err := conn.Dial(); err != nil {
|
||||
client, err := dvrip.Dial(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := conn.Play(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := conn.Handle(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
return client, nil
|
||||
}
|
||||
|
||||
const Port = 34569 // UDP port number for dvrip discovery
|
||||
|
||||
func apiDvrip(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := discover()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
api.ResponseSources(w, items)
|
||||
}
|
||||
|
||||
func discover() ([]*api.Source, error) {
|
||||
addr := &net.UDPAddr{
|
||||
Port: Port,
|
||||
IP: net.IP{239, 255, 255, 250},
|
||||
}
|
||||
|
||||
conn, err := net.ListenUDP("udp4", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
go sendBroadcasts(conn)
|
||||
|
||||
var items []*api.Source
|
||||
|
||||
for _, info := range getResponses(conn) {
|
||||
if info.HostIP == "" || info.HostName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
host, err := hexToDecimalBytes(info.HostIP)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
items = append(items, &api.Source{
|
||||
Name: info.HostName,
|
||||
URL: "dvrip://user:pass@" + host + "?channel=0&subtype=0",
|
||||
})
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func sendBroadcasts(conn *net.UDPConn) {
|
||||
// broadcasting the same multiple times because the devies some times don't answer
|
||||
data, err := hex.DecodeString("ff00000000000000000000000000fa0500000000")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
addr := &net.UDPAddr{
|
||||
Port: Port,
|
||||
IP: net.IP{255, 255, 255, 255},
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
if _, err = conn.WriteToUDP(data, addr); err != nil {
|
||||
log.Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
NetCommon NetCommon `json:"NetWork.NetCommon"`
|
||||
Ret int `json:"Ret"`
|
||||
SessionID string `json:"SessionID"`
|
||||
}
|
||||
|
||||
type NetCommon struct {
|
||||
BuildDate string `json:"BuildDate"`
|
||||
ChannelNum int `json:"ChannelNum"`
|
||||
DeviceType int `json:"DeviceType"`
|
||||
GateWay string `json:"GateWay"`
|
||||
HostIP string `json:"HostIP"`
|
||||
HostName string `json:"HostName"`
|
||||
HttpPort int `json:"HttpPort"`
|
||||
MAC string `json:"MAC"`
|
||||
MonMode string `json:"MonMode"`
|
||||
NetConnectState int `json:"NetConnectState"`
|
||||
OtherFunction string `json:"OtherFunction"`
|
||||
SN string `json:"SN"`
|
||||
SSLPort int `json:"SSLPort"`
|
||||
Submask string `json:"Submask"`
|
||||
TCPMaxConn int `json:"TCPMaxConn"`
|
||||
TCPPort int `json:"TCPPort"`
|
||||
UDPPort int `json:"UDPPort"`
|
||||
UseHSDownLoad bool `json:"UseHSDownLoad"`
|
||||
Version string `json:"Version"`
|
||||
}
|
||||
|
||||
func getResponses(conn *net.UDPConn) (infos []*NetCommon) {
|
||||
if err := conn.SetReadDeadline(time.Now().Add(time.Second * 2)); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var ips []net.IP // processed IPs
|
||||
|
||||
b := make([]byte, 4096)
|
||||
loop:
|
||||
for {
|
||||
n, addr, err := conn.ReadFromUDP(b)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
for _, ip := range ips {
|
||||
if ip.Equal(addr.IP) {
|
||||
continue loop
|
||||
}
|
||||
}
|
||||
|
||||
if n <= 20+1 {
|
||||
continue
|
||||
}
|
||||
|
||||
var msg Message
|
||||
|
||||
if err = json.Unmarshal(b[20:n-1], &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
infos = append(infos, &msg.NetCommon)
|
||||
ips = append(ips, addr.IP)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func hexToDecimalBytes(hexIP string) (string, error) {
|
||||
b, err := hex.DecodeString(hexIP[2:]) // remove the '0x' prefix
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%d.%d.%d.%d", b[3], b[2], b[1], b[0]), nil
|
||||
}
|
||||
|
||||
@@ -2,28 +2,28 @@ package echo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os/exec"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
log := app.GetLogger("echo")
|
||||
|
||||
streams.HandleFunc("echo", func(url string) (core.Producer, error) {
|
||||
streams.RedirectFunc("echo", func(url string) (string, error) {
|
||||
args := shell.QuoteSplit(url[5:])
|
||||
|
||||
b, err := exec.Command(args[0], args[1:]...).Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
|
||||
b = bytes.TrimSpace(b)
|
||||
|
||||
log.Debug().Str("url", url).Msgf("[echo] %s", b)
|
||||
|
||||
return streams.GetProducer(string(b))
|
||||
return string(b), nil
|
||||
})
|
||||
}
|
||||
|
||||
+6
-13
@@ -5,6 +5,11 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
@@ -13,10 +18,6 @@ import (
|
||||
pkg "github.com/AlexxIT/go2rtc/pkg/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
"github.com/rs/zerolog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
@@ -82,15 +83,7 @@ func handlePipe(url string, cmd *exec.Cmd) (core.Producer, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := magic.NewClient(r)
|
||||
if err = client.Probe(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client.Desc = "exec active producer"
|
||||
client.URL = url
|
||||
|
||||
return client, nil
|
||||
return magic.Open(r)
|
||||
}
|
||||
|
||||
func handleRTSP(url, path string, cmd *exec.Cmd) (core.Producer, error) {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"bufio"
|
||||
"io"
|
||||
"os/exec"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
// PipeCloser - return StdoutPipe that Kill cmd on Close call
|
||||
@@ -13,14 +15,16 @@ func PipeCloser(cmd *exec.Cmd) (io.ReadCloser, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return pipeCloser{stdout, cmd}, nil
|
||||
// add buffer for pipe reader to reduce syscall
|
||||
return pipeCloser{bufio.NewReaderSize(stdout, core.BufferSize), stdout, cmd}, nil
|
||||
}
|
||||
|
||||
type pipeCloser struct {
|
||||
io.ReadCloser
|
||||
io.Reader
|
||||
io.Closer
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
func (p pipeCloser) Close() error {
|
||||
return core.Any(p.ReadCloser.Close(), p.cmd.Process.Kill(), p.cmd.Wait())
|
||||
return core.Any(p.Closer.Close(), p.cmd.Process.Kill(), p.cmd.Wait())
|
||||
}
|
||||
|
||||
@@ -1,26 +1,44 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
// https://trac.ffmpeg.org/wiki/Capture/Webcam
|
||||
const deviceInputPrefix = "-f avfoundation"
|
||||
func queryToInput(query url.Values) string {
|
||||
video := query.Get("video")
|
||||
audio := query.Get("audio")
|
||||
|
||||
func deviceInputSuffix(video, audio string) string {
|
||||
switch {
|
||||
case video != "" && audio != "":
|
||||
return `"` + video + `:` + audio + `"`
|
||||
case video != "":
|
||||
return `"` + video + `"`
|
||||
case audio != "":
|
||||
return `":` + audio + `"`
|
||||
if video == "" && audio == "" {
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
|
||||
// https://ffmpeg.org/ffmpeg-devices.html#avfoundation
|
||||
input := "-f avfoundation"
|
||||
|
||||
if video != "" {
|
||||
video = indexToItem(videos, video)
|
||||
|
||||
for key, value := range query {
|
||||
switch key {
|
||||
case "resolution":
|
||||
input += " -video_size " + value[0]
|
||||
case "pixel_format", "framerate", "video_size", "capture_cursor", "capture_mouse_clicks", "capture_raw_data":
|
||||
input += " -" + key + " " + value[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if audio != "" {
|
||||
audio = indexToItem(audios, audio)
|
||||
}
|
||||
|
||||
return input + ` -i "` + video + `:` + audio + `"`
|
||||
}
|
||||
|
||||
func initDevices() {
|
||||
@@ -61,7 +79,7 @@ func initDevices() {
|
||||
audios = append(audios, name)
|
||||
}
|
||||
|
||||
streams = append(streams, api.Stream{
|
||||
streams = append(streams, &api.Source{
|
||||
Name: name, URL: "ffmpeg:device?" + kind + "=" + name,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,21 +1,47 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
// https://trac.ffmpeg.org/wiki/Capture/Webcam
|
||||
const deviceInputPrefix = "-f v4l2"
|
||||
func queryToInput(query url.Values) string {
|
||||
if video := query.Get("video"); video != "" {
|
||||
// https://ffmpeg.org/ffmpeg-devices.html#video4linux2_002c-v4l2
|
||||
input := "-f v4l2"
|
||||
|
||||
func deviceInputSuffix(video, audio string) string {
|
||||
if video != "" {
|
||||
return video
|
||||
for key, value := range query {
|
||||
switch key {
|
||||
case "resolution":
|
||||
input += " -video_size " + value[0]
|
||||
case "video_size", "pixel_format", "input_format", "framerate", "use_libv4l2":
|
||||
input += " -" + key + " " + value[0]
|
||||
}
|
||||
}
|
||||
|
||||
return input + " -i " + indexToItem(videos, video)
|
||||
}
|
||||
|
||||
if audio := query.Get("audio"); audio != "" {
|
||||
// https://trac.ffmpeg.org/wiki/Capture/ALSA
|
||||
input := "-f alsa"
|
||||
|
||||
for key, value := range query {
|
||||
switch key {
|
||||
case "channels", "sample_rate":
|
||||
input += " -" + key + " " + value[0]
|
||||
}
|
||||
}
|
||||
|
||||
return input + " -i " + indexToItem(audios, audio)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -44,8 +70,9 @@ func initDevices() {
|
||||
m := re.FindAllStringSubmatch(string(b), -1)
|
||||
for _, i := range m {
|
||||
size, _, _ := strings.Cut(i[4], " ")
|
||||
stream := api.Stream{
|
||||
Name: i[3] + " | " + i[4],
|
||||
stream := &api.Source{
|
||||
Name: i[3],
|
||||
Info: i[4],
|
||||
URL: "ffmpeg:device?video=" + name + "&input_format=" + i[2] + "&video_size=" + size,
|
||||
}
|
||||
|
||||
@@ -57,4 +84,16 @@ func initDevices() {
|
||||
streams = append(streams, stream)
|
||||
}
|
||||
}
|
||||
|
||||
err = exec.Command(Bin, "-f", "alsa", "-i", "default", "-t", "1", "-f", "null", "-").Run()
|
||||
if err == nil {
|
||||
stream := &api.Source{
|
||||
Name: "ALSA default",
|
||||
Info: " ",
|
||||
URL: "ffmpeg:device?audio=default&channels=1&sample_rate=16000&#audio=opus",
|
||||
}
|
||||
|
||||
audios = append(audios, "default")
|
||||
streams = append(streams, stream)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,61 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
// https://trac.ffmpeg.org/wiki/DirectShow
|
||||
const deviceInputPrefix = "-f dshow"
|
||||
func queryToInput(query url.Values) string {
|
||||
video := query.Get("video")
|
||||
audio := query.Get("audio")
|
||||
|
||||
if video == "" && audio == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// https://ffmpeg.org/ffmpeg-devices.html#dshow
|
||||
input := "-f dshow"
|
||||
|
||||
if video != "" {
|
||||
video = indexToItem(videos, video)
|
||||
|
||||
for key, value := range query {
|
||||
switch key {
|
||||
case "resolution":
|
||||
input += " -video_size " + value[0]
|
||||
case "video_size", "framerate", "pixel_format":
|
||||
input += " -" + key + " " + value[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if audio != "" {
|
||||
audio = indexToItem(audios, audio)
|
||||
|
||||
for key, value := range query {
|
||||
switch key {
|
||||
case "sample_rate", "sample_size", "channels", "audio_buffer_size":
|
||||
input += " -" + key + " " + value[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if video != "" {
|
||||
input += ` -i video="` + video + `"`
|
||||
|
||||
if audio != "" {
|
||||
input += `:audio="` + audio + `"`
|
||||
}
|
||||
} else {
|
||||
input += ` -i audio="` + audio + `"`
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
func deviceInputSuffix(video, audio string) string {
|
||||
switch {
|
||||
@@ -33,7 +80,7 @@ func initDevices() {
|
||||
name := m[1]
|
||||
kind := m[2]
|
||||
|
||||
stream := api.Stream{
|
||||
stream := &api.Source{
|
||||
Name: name, URL: "ffmpeg:device?" + kind + "=" + name,
|
||||
}
|
||||
|
||||
@@ -43,6 +90,7 @@ func initDevices() {
|
||||
stream.URL += "#video=h264#hardware"
|
||||
case core.KindAudio:
|
||||
audios = append(audios, name)
|
||||
stream.URL += "&channels=1&sample_rate=16000&audio_buffer_size=10"
|
||||
}
|
||||
|
||||
streams = append(streams, stream)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
)
|
||||
|
||||
func Init(bin string) {
|
||||
@@ -16,55 +18,40 @@ func Init(bin string) {
|
||||
}
|
||||
|
||||
func GetInput(src string) (string, error) {
|
||||
i := strings.IndexByte(src, '?')
|
||||
if i < 0 {
|
||||
return "", errors.New("empty query: " + src)
|
||||
}
|
||||
|
||||
query, err := url.ParseQuery(src[i+1:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
runonce.Do(initDevices)
|
||||
|
||||
input := deviceInputPrefix
|
||||
|
||||
var video, audio string
|
||||
|
||||
if i := strings.IndexByte(src, '?'); i > 0 {
|
||||
query, err := url.ParseQuery(src[i+1:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for key, value := range query {
|
||||
switch key {
|
||||
case "video":
|
||||
video = value[0]
|
||||
case "audio":
|
||||
audio = value[0]
|
||||
case "resolution":
|
||||
input += " -video_size " + value[0]
|
||||
default: // "input_format", "framerate", "video_size"
|
||||
input += " -" + key + " " + value[0]
|
||||
}
|
||||
}
|
||||
if input := queryToInput(query); input != "" {
|
||||
return input, nil
|
||||
}
|
||||
|
||||
if video != "" {
|
||||
if i, err := strconv.Atoi(video); err == nil && i < len(videos) {
|
||||
video = videos[i]
|
||||
}
|
||||
}
|
||||
if audio != "" {
|
||||
if i, err := strconv.Atoi(audio); err == nil && i < len(audios) {
|
||||
audio = audios[i]
|
||||
}
|
||||
}
|
||||
|
||||
input += " -i " + deviceInputSuffix(video, audio)
|
||||
|
||||
return input, nil
|
||||
return "", errors.New("wrong query: " + src)
|
||||
}
|
||||
|
||||
var Bin string
|
||||
|
||||
var videos, audios []string
|
||||
var streams []api.Stream
|
||||
var streams []*api.Source
|
||||
var runonce sync.Once
|
||||
|
||||
func apiDevices(w http.ResponseWriter, r *http.Request) {
|
||||
runonce.Do(initDevices)
|
||||
|
||||
api.ResponseStreams(w, streams)
|
||||
api.ResponseSources(w, streams)
|
||||
}
|
||||
|
||||
func indexToItem(items []string, index string) string {
|
||||
if i, err := strconv.Atoi(index); err == nil && i < len(items) {
|
||||
return items[i]
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
+48
-38
@@ -1,16 +1,15 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/ffmpeg/device"
|
||||
"github.com/AlexxIT/go2rtc/internal/ffmpeg/hardware"
|
||||
"github.com/AlexxIT/go2rtc/internal/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/ffmpeg"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
@@ -26,12 +25,9 @@ func Init() {
|
||||
defaults["global"] += " -v error"
|
||||
}
|
||||
|
||||
streams.HandleFunc("ffmpeg", func(url string) (core.Producer, error) {
|
||||
args := parseArgs(url[7:]) // remove `ffmpeg:`
|
||||
if args == nil {
|
||||
return nil, errors.New("can't generate ffmpeg command")
|
||||
}
|
||||
return streams.GetProducer("exec:" + args.String())
|
||||
streams.RedirectFunc("ffmpeg", func(url string) (string, error) {
|
||||
args := parseArgs(url[7:])
|
||||
return "exec:" + args.String(), nil
|
||||
})
|
||||
|
||||
device.Init(defaults["bin"])
|
||||
@@ -45,7 +41,7 @@ var defaults = map[string]string{
|
||||
// inputs
|
||||
"file": "-re -i {input}",
|
||||
"http": "-fflags nobuffer -flags low_delay -i {input}",
|
||||
"rtsp": "-fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_transport tcp -i {input}",
|
||||
"rtsp": "-fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i {input}",
|
||||
|
||||
"rtsp/udp": "-fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -i {input}",
|
||||
|
||||
@@ -56,13 +52,17 @@ var defaults = map[string]string{
|
||||
// `-preset superfast` - we can't use ultrafast because it doesn't support `-profile main -level 4.1`
|
||||
// `-tune zerolatency` - for minimal latency
|
||||
// `-profile high -level 4.1` - most used streaming profile
|
||||
"h264": "-c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuvj420p",
|
||||
// `-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",
|
||||
"mjpeg": "-c:v mjpeg",
|
||||
//"mjpeg": "-c:v mjpeg -force_duplicated_matrix:v 1 -huffman:v 0 -pix_fmt:v yuvj420p",
|
||||
|
||||
// https://ffmpeg.org/ffmpeg-codecs.html#libopus-1
|
||||
"opus": "-c:a libopus -ar:a 48000 -ac:a 2 -application:a voip -compression_level:a 0",
|
||||
// https://github.com/pion/webrtc/issues/1514
|
||||
// https://ffmpeg.org/ffmpeg-resampler.html
|
||||
// `-async 1` or `-min_comp 0` - force frame_size=960, important for WebRTC audio quality
|
||||
"opus": "-c:a libopus -application:a lowdelay -frame_duration 20 -min_comp 0",
|
||||
"pcmu": "-c:a pcm_mulaw -ar:a 8000 -ac:a 1",
|
||||
"pcmu/16000": "-c:a pcm_mulaw -ar:a 16000 -ac:a 1",
|
||||
"pcmu/48000": "-c:a pcm_mulaw -ar:a 48000 -ac:a 1",
|
||||
@@ -75,6 +75,8 @@ var defaults = map[string]string{
|
||||
"pcm": "-c:a pcm_s16be -ar:a 8000 -ac:a 1",
|
||||
"pcm/16000": "-c:a pcm_s16be -ar:a 16000 -ac:a 1",
|
||||
"pcm/48000": "-c:a pcm_s16be -ar:a 48000 -ac:a 1",
|
||||
"pcml": "-c:a pcm_s16le -ar:a 8000 -ac:a 1",
|
||||
"pcml/44100": "-c:a pcm_s16le -ar:a 44100 -ac:a 1",
|
||||
|
||||
// hardware Intel and AMD on Linux
|
||||
// better not to set `-async_depth:v 1` like for QSV, because framedrops
|
||||
@@ -89,8 +91,8 @@ var defaults = map[string]string{
|
||||
|
||||
// hardware NVidia on Linux and Windows
|
||||
// preset=p2 - faster, tune=ll - low latency
|
||||
"h264/cuda": "-c:v h264_nvenc -g 50 -profile:v high -level:v auto -preset:v p2 -tune:v ll",
|
||||
"h265/cuda": "-c:v hevc_nvenc -g 50 -profile:v high -level:v auto",
|
||||
"h264/cuda": "-c:v h264_nvenc -g 50 -bf 0 -profile:v high -level:v auto -preset:v p2 -tune:v ll",
|
||||
"h265/cuda": "-c:v hevc_nvenc -g 50 -bf 0 -profile:v high -level:v auto",
|
||||
|
||||
// hardware Intel on Windows
|
||||
"h264/dxva2": "-c:v h264_qsv -g 50 -bf 0 -profile:v high -level:v 4.1 -async_depth:v 1",
|
||||
@@ -102,15 +104,21 @@ var defaults = map[string]string{
|
||||
"h265/videotoolbox": "-c:v hevc_videotoolbox -g 50 -bf 0 -profile:v high -level:v 5.1",
|
||||
}
|
||||
|
||||
// configTemplate - return template from config (defaults) if exist or return raw template
|
||||
func configTemplate(template string) string {
|
||||
if s := defaults[template]; s != "" {
|
||||
return s
|
||||
}
|
||||
return template
|
||||
}
|
||||
|
||||
// inputTemplate - select input template from YAML config by template name
|
||||
// if query has input param - select another tempalte by this name
|
||||
// if query has input param - select another template by this name
|
||||
// if there is no another template - use input param as template
|
||||
func inputTemplate(name, s string, query url.Values) string {
|
||||
var template string
|
||||
if input := query.Get("input"); input != "" {
|
||||
if template = defaults[input]; template == "" {
|
||||
template = input
|
||||
}
|
||||
template = configTemplate(input)
|
||||
} else {
|
||||
template = defaults[name]
|
||||
}
|
||||
@@ -127,7 +135,7 @@ func parseArgs(s string) *ffmpeg.Args {
|
||||
|
||||
var query url.Values
|
||||
if i := strings.IndexByte(s, '#'); i > 0 {
|
||||
query = parseQuery(s[i+1:])
|
||||
query = streams.ParseQuery(s[i+1:])
|
||||
args.Video = len(query["video"])
|
||||
args.Audio = len(query["audio"])
|
||||
s = s[:i]
|
||||
@@ -191,6 +199,8 @@ func parseArgs(s string) *ffmpeg.Args {
|
||||
if query != nil {
|
||||
// 1. Process raw params for FFmpeg
|
||||
for _, raw := range query["raw"] {
|
||||
// support templates https://github.com/AlexxIT/go2rtc/issues/487
|
||||
raw = configTemplate(raw)
|
||||
args.AddCodec(raw)
|
||||
}
|
||||
|
||||
@@ -226,6 +236,18 @@ func parseArgs(s string) *ffmpeg.Args {
|
||||
}
|
||||
}
|
||||
|
||||
for _, drawtext := range query["drawtext"] {
|
||||
// support templates https://github.com/AlexxIT/go2rtc/issues/487
|
||||
drawtext = configTemplate(drawtext)
|
||||
|
||||
// support default timestamp format
|
||||
if !strings.Contains(drawtext, "text=") {
|
||||
drawtext += `:text='%{localtime\:%Y-%m-%d %X}'`
|
||||
}
|
||||
|
||||
args.AddFilter("drawtext=" + drawtext)
|
||||
}
|
||||
|
||||
// 3. Process video codecs
|
||||
if args.Video > 0 {
|
||||
for _, video := range query["video"] {
|
||||
@@ -239,8 +261,6 @@ func parseArgs(s string) *ffmpeg.Args {
|
||||
args.AddCodec("-c:v copy")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
args.AddCodec("-vn")
|
||||
}
|
||||
|
||||
// 4. Process audio codecs
|
||||
@@ -256,8 +276,6 @@ func parseArgs(s string) *ffmpeg.Args {
|
||||
args.AddCodec("-c:a copy")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
args.AddCodec("-an")
|
||||
}
|
||||
|
||||
if query["hardware"] != nil {
|
||||
@@ -265,8 +283,13 @@ func parseArgs(s string) *ffmpeg.Args {
|
||||
}
|
||||
}
|
||||
|
||||
if args.Codecs == nil {
|
||||
switch {
|
||||
case args.Video == 0 && args.Audio == 0:
|
||||
args.AddCodec("-c copy")
|
||||
case args.Video == 0:
|
||||
args.AddCodec("-vn")
|
||||
case args.Audio == 0:
|
||||
args.AddCodec("-an")
|
||||
}
|
||||
|
||||
// transcoding to only mjpeg
|
||||
@@ -278,16 +301,3 @@ func parseArgs(s string) *ffmpeg.Args {
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
func parseQuery(s string) map[string][]string {
|
||||
query := map[string][]string{}
|
||||
for _, key := range strings.Split(s, "#") {
|
||||
var value string
|
||||
i := strings.IndexByte(key, '=')
|
||||
if i > 0 {
|
||||
key, value = key[:i], key[i+1:]
|
||||
}
|
||||
query[key] = append(query[key], value)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
+204
-12
@@ -1,23 +1,215 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseArgs(t *testing.T) {
|
||||
args := parseArgs("rtsp://example.com#video=h264#rotate=180")
|
||||
assert.Equal(t, "ffmpeg -hide_banner -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_transport tcp -i rtsp://example.com -c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuvj420p -an -vf transpose=1,transpose=1 -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}", args.String())
|
||||
func TestParseArgsFile(t *testing.T) {
|
||||
// [FILE] all tracks will be copied without transcoding codecs
|
||||
args := parseArgs("/media/bbb.mp4")
|
||||
require.Equal(t, `ffmpeg -hide_banner -re -i /media/bbb.mp4 -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
args = parseArgs("rtsp://example.com#video=h264#rotate=180#hardware=vaapi")
|
||||
assert.Equal(t, "ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_transport tcp -i rtsp://example.com -c:v h264_vaapi -g 50 -bf 0 -profile:v high -level:v 4.1 -sei:v 0 -an -vf format=vaapi|nv12,hwupload,transpose_vaapi=4 -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}", args.String())
|
||||
// [FILE] video will be transcoded to H264, audio will be skipped
|
||||
args = parseArgs("/media/bbb.mp4#video=h264")
|
||||
require.Equal(t, `ffmpeg -hide_banner -re -i /media/bbb.mp4 -c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [FILE] video will be copied, audio will be transcoded to pcmu
|
||||
args = parseArgs("/media/bbb.mp4#video=copy#audio=pcmu")
|
||||
require.Equal(t, `ffmpeg -hide_banner -re -i /media/bbb.mp4 -c:v copy -c:a pcm_mulaw -ar:a 8000 -ac:a 1 -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [FILE] video will be transcoded to H265 and rotate 270º, audio will be skipped
|
||||
args = parseArgs("/media/bbb.mp4#video=h265#rotate=-90")
|
||||
require.Equal(t, `ffmpeg -hide_banner -re -i /media/bbb.mp4 -c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency -an -vf "transpose=2" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [FILE] video will be output for MJPEG to pipe, audio will be skipped
|
||||
args = parseArgs("/media/bbb.mp4#video=mjpeg")
|
||||
assert.Equal(t, "ffmpeg -hide_banner -re -i /media/bbb.mp4 -c:v mjpeg -an -f mjpeg -", args.String())
|
||||
require.Equal(t, `ffmpeg -hide_banner -re -i /media/bbb.mp4 -c:v mjpeg -an -f mjpeg -`, args.String())
|
||||
|
||||
args = parseArgs("/media/bbb.mp4#video=mjpeg#hardware=vaapi")
|
||||
assert.Equal(t, "ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -re -i /media/bbb.mp4 -c:v mjpeg_vaapi -an -vf format=vaapi|nv12,hwupload -f mjpeg -", args.String())
|
||||
|
||||
args = parseArgs("device?video=0&input_format=mjpeg&video_size=1920x1080")
|
||||
assert.Equal(t, `ffmpeg -hide_banner -f dshow -input_format mjpeg -video_size 1920x1080 -i video="0" -c copy -f mjpeg -`, args.String())
|
||||
// https://github.com/AlexxIT/go2rtc/issues/509
|
||||
args = parseArgs("ffmpeg:test.mp4#raw=-ss 00:00:20")
|
||||
require.Equal(t, `ffmpeg -hide_banner -re -i ffmpeg:test.mp4 -ss 00:00:20 -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
}
|
||||
|
||||
func TestParseArgsDevice(t *testing.T) {
|
||||
// [DEVICE] video will be output for MJPEG to pipe, with size 1920x1080
|
||||
args := parseArgs("device?video=0&video_size=1920x1080")
|
||||
require.Equal(t, `ffmpeg -hide_banner -f dshow -video_size 1920x1080 -i video="0" -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [DEVICE] video will be transcoded to H265 with framerate 20, audio will be skipped
|
||||
//args = parseArgs("device?video=0&video_size=1280x720&framerate=20#video=h265#audio=pcma")
|
||||
args = parseArgs("device?video=0&framerate=20#video=h265#audio=pcma")
|
||||
require.Equal(t, `ffmpeg -hide_banner -f dshow -framerate 20 -i video="0" -c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency -c:a pcm_alaw -ar:a 8000 -ac:a 1 -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
}
|
||||
|
||||
func TestParseArgsIpCam(t *testing.T) {
|
||||
// [HTTP] video will be copied
|
||||
args := parseArgs("http://example.com")
|
||||
require.Equal(t, `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -i http://example.com -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [HTTP-MJPEG] video will be transcoded to H264
|
||||
args = parseArgs("http://example.com#video=h264")
|
||||
require.Equal(t, `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -i http://example.com -c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [HLS] video will be copied, audio will be skipped
|
||||
args = parseArgs("https://example.com#video=copy")
|
||||
require.Equal(t, `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -i https://example.com -c:v copy -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTSP] video will be copied without transcoding codecs
|
||||
args = parseArgs("rtsp://example.com")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types video+audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTSP] video with resize to 1280x720, should be transcoded, so select H265
|
||||
args = parseArgs("rtsp://example.com#video=h265#width=1280#height=720")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency -an -vf "scale=1280:720" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTSP] video will be copied, changing RTSP transport from TCP to UDP+TCP
|
||||
args = parseArgs("rtsp://example.com#input=rtsp/udp")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types video+audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -i rtsp://example.com -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTMP] video will be copied, changing RTSP transport from TCP to UDP+TCP
|
||||
args = parseArgs("rtmp://example.com#input=rtsp/udp")
|
||||
require.Equal(t, `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -i rtmp://example.com -c copy -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
}
|
||||
|
||||
func TestParseArgsAudio(t *testing.T) {
|
||||
// [AUDIO] audio will be transcoded to AAC, video will be skipped
|
||||
args := parseArgs("rtsp:///example.com#audio=aac")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp:///example.com -c:a aac -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [AUDIO] audio will be transcoded to AAC/16000, video will be skipped
|
||||
args = parseArgs("rtsp:///example.com#audio=aac/16000")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp:///example.com -c:a aac -ar:a 16000 -ac:a 1 -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [AUDIO] audio will be transcoded to OPUS, video will be skipped
|
||||
args = parseArgs("rtsp:///example.com#audio=opus")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp:///example.com -c:a libopus -application:a lowdelay -frame_duration 20 -min_comp 0 -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [AUDIO] audio will be transcoded to PCMU, video will be skipped
|
||||
args = parseArgs("rtsp:///example.com#audio=pcmu")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp:///example.com -c:a pcm_mulaw -ar:a 8000 -ac:a 1 -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [AUDIO] audio will be transcoded to PCMU/16000, video will be skipped
|
||||
args = parseArgs("rtsp:///example.com#audio=pcmu/16000")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp:///example.com -c:a pcm_mulaw -ar:a 16000 -ac:a 1 -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [AUDIO] audio will be transcoded to PCMU/48000, video will be skipped
|
||||
args = parseArgs("rtsp:///example.com#audio=pcmu/48000")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp:///example.com -c:a pcm_mulaw -ar:a 48000 -ac:a 1 -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [AUDIO] audio will be transcoded to PCMA, video will be skipped
|
||||
args = parseArgs("rtsp:///example.com#audio=pcma")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp:///example.com -c:a pcm_alaw -ar:a 8000 -ac:a 1 -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [AUDIO] audio will be transcoded to PCMA/16000, video will be skipped
|
||||
args = parseArgs("rtsp:///example.com#audio=pcma/16000")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp:///example.com -c:a pcm_alaw -ar:a 16000 -ac:a 1 -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [AUDIO] audio will be transcoded to PCMA/48000, video will be skipped
|
||||
args = parseArgs("rtsp:///example.com#audio=pcma/48000")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types audio -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp:///example.com -c:a pcm_alaw -ar:a 48000 -ac:a 1 -vn -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
}
|
||||
|
||||
func TestParseArgsHwVaapi(t *testing.T) {
|
||||
// [HTTP-MJPEG] video will be transcoded to H264
|
||||
args := parseArgs("http:///example.com#video=h264#hardware=vaapi")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_flags allow_profile_mismatch -fflags nobuffer -flags low_delay -i http:///example.com -c:v h264_vaapi -g 50 -bf 0 -profile:v high -level:v 4.1 -sei:v 0 -an -vf "format=vaapi|nv12,hwupload,scale_vaapi=out_range=tv" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTSP] video with rotation, should be transcoded, so select H264
|
||||
args = parseArgs("rtsp://example.com#video=h264#rotate=180#hardware=vaapi")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_flags allow_profile_mismatch -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v h264_vaapi -g 50 -bf 0 -profile:v high -level:v 4.1 -sei:v 0 -an -vf "format=vaapi|nv12,hwupload,transpose_vaapi=4,scale_vaapi=out_range=tv" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTSP] video with resize to 1280x720, should be transcoded, so select H265
|
||||
args = parseArgs("rtsp://example.com#video=h265#width=1280#height=720#hardware=vaapi")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_flags allow_profile_mismatch -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v hevc_vaapi -g 50 -bf 0 -profile:v high -level:v 5.1 -sei:v 0 -an -vf "format=vaapi|nv12,hwupload,scale_vaapi=1280:720:out_range=tv" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [FILE] video will be output for MJPEG to pipe, audio will be skipped
|
||||
args = parseArgs("/media/bbb.mp4#video=mjpeg#hardware=vaapi")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_flags allow_profile_mismatch -re -i /media/bbb.mp4 -c:v mjpeg_vaapi -an -vf "format=vaapi|nv12,hwupload,scale_vaapi=out_range=tv" -f mjpeg -`, args.String())
|
||||
|
||||
// [DEVICE] MJPEG video with size 1920x1080 will be transcoded to H265
|
||||
args = parseArgs("device?video=0&video_size=1920x1080#video=h265#hardware=vaapi")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_flags allow_profile_mismatch -f dshow -video_size 1920x1080 -i video="0" -c:v hevc_vaapi -g 50 -bf 0 -profile:v high -level:v 5.1 -sei:v 0 -an -vf "format=vaapi|nv12,hwupload,scale_vaapi=out_range=tv" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
}
|
||||
|
||||
func TestParseArgsHwV4l2m2m(t *testing.T) {
|
||||
// [HTTP-MJPEG] video will be transcoded to H264
|
||||
args := parseArgs("http:///example.com#video=h264#hardware=v4l2m2m")
|
||||
require.Equal(t, `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -i http:///example.com -c:v h264_v4l2m2m -g 50 -bf 0 -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTSP] video with rotation, should be transcoded, so select H264
|
||||
args = parseArgs("rtsp://example.com#video=h264#rotate=180#hardware=v4l2m2m")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v h264_v4l2m2m -g 50 -bf 0 -an -vf "transpose=1,transpose=1" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTSP] video with resize to 1280x720, should be transcoded, so select H265
|
||||
args = parseArgs("rtsp://example.com#video=h265#width=1280#height=720#hardware=v4l2m2m")
|
||||
require.Equal(t, `ffmpeg -hide_banner -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v hevc_v4l2m2m -g 50 -bf 0 -an -vf "scale=1280:720" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [DEVICE] MJPEG video with size 1920x1080 will be transcoded to H265
|
||||
args = parseArgs("device?video=0&video_size=1920x1080#video=h265#hardware=v4l2m2m")
|
||||
require.Equal(t, `ffmpeg -hide_banner -f dshow -video_size 1920x1080 -i video="0" -c:v hevc_v4l2m2m -g 50 -bf 0 -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
}
|
||||
|
||||
func TestParseArgsHwCuda(t *testing.T) {
|
||||
// [HTTP-MJPEG] video will be transcoded to H264
|
||||
args := parseArgs("http:///example.com#video=h264#hardware=cuda")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel cuda -hwaccel_output_format cuda -fflags nobuffer -flags low_delay -i http:///example.com -c:v h264_nvenc -g 50 -bf 0 -profile:v high -level:v auto -preset:v p2 -tune:v ll -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTSP] video with rotation, should be transcoded, so select H264
|
||||
args = parseArgs("rtsp://example.com#video=h264#rotate=180#hardware=cuda")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel cuda -hwaccel_output_format nv12 -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v h264_nvenc -g 50 -bf 0 -profile:v high -level:v auto -preset:v p2 -tune:v ll -an -vf "transpose=1,transpose=1,hwupload" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTSP] video with resize to 1280x720, should be transcoded, so select H265
|
||||
args = parseArgs("rtsp://example.com#video=h265#width=1280#height=720#hardware=cuda")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel cuda -hwaccel_output_format cuda -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v hevc_nvenc -g 50 -bf 0 -profile:v high -level:v auto -an -vf "scale_cuda=1280:720" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [DEVICE] MJPEG video with size 1920x1080 will be transcoded to H265
|
||||
args = parseArgs("device?video=0&video_size=1920x1080#video=h265#hardware=cuda")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel cuda -hwaccel_output_format cuda -f dshow -video_size 1920x1080 -i video="0" -c:v hevc_nvenc -g 50 -bf 0 -profile:v high -level:v auto -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
}
|
||||
|
||||
func TestParseArgsHwDxva2(t *testing.T) {
|
||||
// [HTTP-MJPEG] video will be transcoded to H264
|
||||
args := parseArgs("http:///example.com#video=h264#hardware=dxva2")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel dxva2 -hwaccel_output_format dxva2_vld -fflags nobuffer -flags low_delay -i http:///example.com -c:v h264_qsv -g 50 -bf 0 -profile:v high -level:v 4.1 -async_depth:v 1 -an -vf "hwmap=derive_device=qsv,format=qsv" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTSP] video with rotation, should be transcoded, so select H264
|
||||
args = parseArgs("rtsp://example.com#video=h264#rotate=180#hardware=dxva2")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel dxva2 -hwaccel_output_format dxva2_vld -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v h264_qsv -g 50 -bf 0 -profile:v high -level:v 4.1 -async_depth:v 1 -an -vf "hwmap=derive_device=qsv,format=qsv,transpose=1,transpose=1" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTSP] video with resize to 1280x720, should be transcoded, so select H265
|
||||
args = parseArgs("rtsp://example.com#video=h265#width=1280#height=720#hardware=dxva2")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel dxva2 -hwaccel_output_format dxva2_vld -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v hevc_qsv -g 50 -bf 0 -profile:v high -level:v 5.1 -async_depth:v 1 -an -vf "hwmap=derive_device=qsv,format=qsv,scale_qsv=1280:720" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [FILE] video will be output for MJPEG to pipe, audio will be skipped
|
||||
args = parseArgs("/media/bbb.mp4#video=mjpeg#hardware=dxva2")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel dxva2 -hwaccel_output_format dxva2_vld -re -i /media/bbb.mp4 -c:v mjpeg_qsv -profile:v high -level:v 5.1 -an -vf "hwmap=derive_device=qsv,format=qsv" -f mjpeg -`, args.String())
|
||||
|
||||
// [DEVICE] MJPEG video with size 1920x1080 will be transcoded to H265
|
||||
args = parseArgs("device?video=0&video_size=1920x1080#video=h265#hardware=dxva2")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel dxva2 -hwaccel_output_format dxva2_vld -f dshow -video_size 1920x1080 -i video="0" -c:v hevc_qsv -g 50 -bf 0 -profile:v high -level:v 5.1 -async_depth:v 1 -an -vf "hwmap=derive_device=qsv,format=qsv" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
}
|
||||
|
||||
func TestParseArgsHwVideotoolbox(t *testing.T) {
|
||||
// [HTTP-MJPEG] video will be transcoded to H264
|
||||
args := parseArgs("http:///example.com#video=h264#hardware=videotoolbox")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel videotoolbox -hwaccel_output_format videotoolbox_vld -fflags nobuffer -flags low_delay -i http:///example.com -c:v h264_videotoolbox -g 50 -bf 0 -profile:v high -level:v 4.1 -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTSP] video with rotation, should be transcoded, so select H264
|
||||
args = parseArgs("rtsp://example.com#video=h264#rotate=180#hardware=videotoolbox")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel videotoolbox -hwaccel_output_format videotoolbox_vld -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v h264_videotoolbox -g 50 -bf 0 -profile:v high -level:v 4.1 -an -vf "transpose=1,transpose=1" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [RTSP] video with resize to 1280x720, should be transcoded, so select H265
|
||||
args = parseArgs("rtsp://example.com#video=h265#width=1280#height=720#hardware=videotoolbox")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel videotoolbox -hwaccel_output_format videotoolbox_vld -allowed_media_types video -fflags nobuffer -flags low_delay -timeout 5000000 -user_agent go2rtc/ffmpeg -rtsp_flags prefer_tcp -i rtsp://example.com -c:v hevc_videotoolbox -g 50 -bf 0 -profile:v high -level:v 5.1 -an -vf "scale=1280:720" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
|
||||
// [DEVICE] MJPEG video with size 1920x1080 will be transcoded to H265
|
||||
args = parseArgs("device?video=0&video_size=1920x1080#video=h265#hardware=videotoolbox")
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel videotoolbox -hwaccel_output_format videotoolbox_vld -f dshow -video_size 1920x1080 -i video="0" -c:v hevc_videotoolbox -g 50 -bf 0 -profile:v high -level:v 5.1 -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
}
|
||||
|
||||
func TestDeckLink(t *testing.T) {
|
||||
args := parseArgs(`DeckLink SDI (2)#video=h264#hardware=vaapi#input=-format_code Hp29 -f decklink -i "{input}"`)
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_flags allow_profile_mismatch -format_code Hp29 -f decklink -i "DeckLink SDI (2)" -c:v h264_vaapi -g 50 -bf 0 -profile:v high -level:v 4.1 -sei:v 0 -an -vf "format=vaapi|nv12,hwupload,scale_vaapi=out_range=tv" -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, args.String())
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package hardware
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/pkg/ffmpeg"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/pkg/ffmpeg"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -21,7 +22,7 @@ const (
|
||||
|
||||
func Init(bin string) {
|
||||
api.HandleFunc("api/ffmpeg/hardware", func(w http.ResponseWriter, r *http.Request) {
|
||||
api.ResponseStreams(w, ProbeAll(bin))
|
||||
api.ResponseSources(w, ProbeAll(bin))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -55,33 +56,53 @@ func MakeHardware(args *ffmpeg.Args, engine string, defaults map[string]string)
|
||||
|
||||
switch engine {
|
||||
case EngineVAAPI:
|
||||
args.Input = "-hwaccel vaapi -hwaccel_output_format vaapi " + args.Input
|
||||
args.Codecs[i] = defaults[name+"/"+engine]
|
||||
|
||||
for i, filter := range args.Filters {
|
||||
if strings.HasPrefix(filter, "scale=") {
|
||||
args.Filters[i] = "scale_vaapi=" + filter[6:]
|
||||
}
|
||||
if strings.HasPrefix(filter, "transpose=") {
|
||||
if filter == "transpose=1,transpose=1" { // 180 degrees half-turn
|
||||
args.Filters[i] = "transpose_vaapi=4" // reversal
|
||||
} else {
|
||||
args.Filters[i] = "transpose_vaapi=" + filter[10:]
|
||||
fixYCbCrRange(args)
|
||||
|
||||
if !args.HasFilters("drawtext=") {
|
||||
args.Input = "-hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_flags allow_profile_mismatch " + args.Input
|
||||
|
||||
for i, filter := range args.Filters {
|
||||
if strings.HasPrefix(filter, "scale=") {
|
||||
args.Filters[i] = "scale_vaapi=" + filter[6:]
|
||||
}
|
||||
if strings.HasPrefix(filter, "transpose=") {
|
||||
if filter == "transpose=1,transpose=1" { // 180 degrees half-turn
|
||||
args.Filters[i] = "transpose_vaapi=4" // reversal
|
||||
} else {
|
||||
args.Filters[i] = "transpose_vaapi=" + filter[10:]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fix if input doesn't support hwaccel, do nothing when support
|
||||
// insert as first filter before hardware scale and transpose
|
||||
args.InsertFilter("format=vaapi|nv12,hwupload")
|
||||
} else {
|
||||
// enable software pixel for drawtext, scale and transpose
|
||||
args.Input = "-hwaccel vaapi -hwaccel_output_format nv12 -hwaccel_flags allow_profile_mismatch " + args.Input
|
||||
|
||||
args.AddFilter("hwupload")
|
||||
}
|
||||
|
||||
// fix if input doesn't support hwaccel, do nothing when support
|
||||
args.InsertFilter("format=vaapi|nv12,hwupload")
|
||||
|
||||
case EngineCUDA:
|
||||
args.Input = "-hwaccel cuda -hwaccel_output_format cuda -extra_hw_frames 2 " + args.Input
|
||||
args.Codecs[i] = defaults[name+"/"+engine]
|
||||
|
||||
for i, filter := range args.Filters {
|
||||
if strings.HasPrefix(filter, "scale=") {
|
||||
args.Filters[i] = "scale_cuda=" + filter[6:]
|
||||
// CUDA doesn't support hardware transpose
|
||||
// https://github.com/AlexxIT/go2rtc/issues/389
|
||||
if !args.HasFilters("drawtext=", "transpose=") {
|
||||
args.Input = "-hwaccel cuda -hwaccel_output_format cuda " + args.Input
|
||||
|
||||
for i, filter := range args.Filters {
|
||||
if strings.HasPrefix(filter, "scale=") {
|
||||
args.Filters[i] = "scale_cuda=" + filter[6:]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
args.Input = "-hwaccel cuda -hwaccel_output_format nv12 " + args.Input
|
||||
|
||||
args.AddFilter("hwupload")
|
||||
}
|
||||
|
||||
case EngineDXVA2:
|
||||
@@ -135,3 +156,21 @@ func cut(s string, sep byte, pos int) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// fixYCbCrRange convert jpeg/pc range to mpeg/tv range
|
||||
// vaapi(pc, bt709, progressive) == yuvj420p (jpeg/full/pc)
|
||||
// vaapi(tv, bt709, progressive) == yuv420p (mpeg/limited/tv)
|
||||
// https://ffmpeg.org/ffmpeg-all.html#scale-1
|
||||
func fixYCbCrRange(args *ffmpeg.Args) {
|
||||
for i, filter := range args.Filters {
|
||||
if strings.HasPrefix(filter, "scale=") {
|
||||
if !strings.Contains(filter, "out_range=") {
|
||||
args.Filters[i] = filter + ":out_range=tv"
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// scale=out_color_matrix=bt709:out_range=tv
|
||||
args.Filters = append(args.Filters, "scale=out_range=tv")
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
)
|
||||
|
||||
const ProbeVideoToolboxH264 = "-f lavfi -i testsrc2 -t 1 -c h264_videotoolbox -f null -"
|
||||
const ProbeVideoToolboxH265 = "-f lavfi -i testsrc2 -t 1 -c hevc_videotoolbox -f null -"
|
||||
const ProbeVideoToolboxH264 = "-f lavfi -i testsrc2=size=svga -t 1 -c h264_videotoolbox -f null -"
|
||||
const ProbeVideoToolboxH265 = "-f lavfi -i testsrc2=size=svga -t 1 -c hevc_videotoolbox -f null -"
|
||||
|
||||
func ProbeAll(bin string) []api.Stream {
|
||||
return []api.Stream{
|
||||
func ProbeAll(bin string) []*api.Source {
|
||||
return []*api.Source{
|
||||
{
|
||||
Name: runToString(bin, ProbeVideoToolboxH264),
|
||||
URL: "ffmpeg:...#video=h264#hardware=" + EngineVideoToolbox,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package hardware
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"runtime"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
)
|
||||
|
||||
const ProbeV4L2M2MH264 = "-f lavfi -i testsrc2 -t 1 -c h264_v4l2m2m -f null -"
|
||||
@@ -13,9 +14,9 @@ const ProbeVAAPIJPEG = "-init_hw_device vaapi -f lavfi -i testsrc2 -t 1 -vf form
|
||||
const ProbeCUDAH264 = "-init_hw_device cuda -f lavfi -i testsrc2 -t 1 -c h264_nvenc -f null -"
|
||||
const ProbeCUDAH265 = "-init_hw_device cuda -f lavfi -i testsrc2 -t 1 -c hevc_nvenc -f null -"
|
||||
|
||||
func ProbeAll(bin string) []api.Stream {
|
||||
func ProbeAll(bin string) []*api.Source {
|
||||
if runtime.GOARCH == "arm64" || runtime.GOARCH == "arm" {
|
||||
return []api.Stream{
|
||||
return []*api.Source{
|
||||
{
|
||||
Name: runToString(bin, ProbeV4L2M2MH264),
|
||||
URL: "ffmpeg:...#video=h264#hardware=" + EngineV4L2M2M,
|
||||
@@ -27,7 +28,7 @@ func ProbeAll(bin string) []api.Stream {
|
||||
}
|
||||
}
|
||||
|
||||
return []api.Stream{
|
||||
return []*api.Source{
|
||||
{
|
||||
Name: runToString(bin, ProbeVAAPIH264),
|
||||
URL: "ffmpeg:...#video=h264#hardware=" + EngineVAAPI,
|
||||
|
||||
@@ -8,8 +8,8 @@ const ProbeDXVA2JPEG = "-init_hw_device dxva2 -f lavfi -i testsrc2 -t 1 -c mjpeg
|
||||
const ProbeCUDAH264 = "-init_hw_device cuda -f lavfi -i testsrc2 -t 1 -c h264_nvenc -f null -"
|
||||
const ProbeCUDAH265 = "-init_hw_device cuda -f lavfi -i testsrc2 -t 1 -c hevc_nvenc -f null -"
|
||||
|
||||
func ProbeAll(bin string) []api.Stream {
|
||||
return []api.Stream{
|
||||
func ProbeAll(bin string) []*api.Source {
|
||||
return []*api.Source{
|
||||
{
|
||||
Name: runToString(bin, ProbeDXVA2H264),
|
||||
URL: "ffmpeg:...#video=h264#hardware=" + EngineDXVA2,
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func TranscodeToJPEG(b []byte) ([]byte, error) {
|
||||
cmd := exec.Command("ffmpeg", "-hide_banner", "-i", "-", "-f", "mjpeg", "-")
|
||||
cmd.Stdin = bytes.NewBuffer(b)
|
||||
return cmd.Output()
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/ffmpeg/hardware"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/ffmpeg"
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
)
|
||||
|
||||
func JPEGWithQuery(b []byte, query url.Values) ([]byte, error) {
|
||||
args := parseQuery(query)
|
||||
return transcode(b, args.String())
|
||||
}
|
||||
|
||||
func JPEGWithScale(b []byte, width, height int) ([]byte, error) {
|
||||
args := defaultArgs()
|
||||
args.AddFilter(fmt.Sprintf("scale=%d:%d", width, height))
|
||||
return transcode(b, args.String())
|
||||
}
|
||||
|
||||
func transcode(b []byte, args string) ([]byte, error) {
|
||||
cmdArgs := shell.QuoteSplit(args)
|
||||
cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...)
|
||||
cmd.Stdin = bytes.NewBuffer(b)
|
||||
return cmd.Output()
|
||||
}
|
||||
|
||||
func defaultArgs() *ffmpeg.Args {
|
||||
return &ffmpeg.Args{
|
||||
Bin: defaults["bin"],
|
||||
Global: defaults["global"],
|
||||
Input: "-i -",
|
||||
Codecs: []string{defaults["mjpeg"]},
|
||||
Output: defaults["output/mjpeg"],
|
||||
}
|
||||
}
|
||||
|
||||
func parseQuery(query url.Values) *ffmpeg.Args {
|
||||
args := defaultArgs()
|
||||
|
||||
var width = -1
|
||||
var height = -1
|
||||
var r, hw string
|
||||
|
||||
for k, v := range query {
|
||||
switch k {
|
||||
case "width", "w":
|
||||
width = core.Atoi(v[0])
|
||||
case "height", "h":
|
||||
height = core.Atoi(v[0])
|
||||
case "rotate":
|
||||
r = v[0]
|
||||
case "hardware", "hw":
|
||||
hw = v[0]
|
||||
}
|
||||
}
|
||||
|
||||
if width > 0 || height > 0 {
|
||||
args.AddFilter(fmt.Sprintf("scale=%d:%d", width, height))
|
||||
}
|
||||
|
||||
if r != "" {
|
||||
switch r {
|
||||
case "90":
|
||||
args.AddFilter("transpose=1") // 90 degrees clockwise
|
||||
case "180":
|
||||
args.AddFilter("transpose=1,transpose=1")
|
||||
case "-90", "270":
|
||||
args.AddFilter("transpose=2") // 90 degrees counterclockwise
|
||||
}
|
||||
}
|
||||
|
||||
if hw != "" {
|
||||
hardware.MakeHardware(args, hw, defaults)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseQuery(t *testing.T) {
|
||||
args := parseQuery(nil)
|
||||
require.Equal(t, `ffmpeg -hide_banner -i - -c:v mjpeg -f mjpeg -`, args.String())
|
||||
|
||||
query, err := url.ParseQuery("h=480")
|
||||
require.Nil(t, err)
|
||||
args = parseQuery(query)
|
||||
require.Equal(t, `ffmpeg -hide_banner -i - -c:v mjpeg -vf "scale=-1:480" -f mjpeg -`, args.String())
|
||||
|
||||
query, err = url.ParseQuery("hw=vaapi")
|
||||
require.Nil(t, err)
|
||||
args = parseQuery(query)
|
||||
require.Equal(t, `ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -i - -c:v mjpeg_vaapi -vf "format=vaapi|nv12,hwupload" -f mjpeg -`, args.String())
|
||||
}
|
||||
+62
-74
@@ -3,87 +3,75 @@ package hass
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/internal/webrtc"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/internal/webrtc"
|
||||
)
|
||||
|
||||
func initAPI() {
|
||||
ok := func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":1,"payload":{}}`))
|
||||
}
|
||||
func apiOK(w http.ResponseWriter, r *http.Request) {
|
||||
api.Response(w, `{"status":1,"payload":{}}`, api.MimeJSON)
|
||||
}
|
||||
|
||||
// support https://www.home-assistant.io/integrations/rtsp_to_webrtc/
|
||||
api.HandleFunc("/static", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
api.HandleFunc("/streams", ok)
|
||||
|
||||
// api from RTSPtoWeb
|
||||
api.HandleFunc("/stream/", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
// /stream/{id}/add
|
||||
case strings.HasSuffix(r.RequestURI, "/add"):
|
||||
var v addJSON
|
||||
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// we can get three types of links:
|
||||
// 1. link to go2rtc stream: rtsp://...:8554/{stream_name}
|
||||
// 2. static link to Hass camera
|
||||
// 3. dynamic link to Hass camera
|
||||
stream := streams.Get(v.Name)
|
||||
if stream == nil {
|
||||
stream = streams.NewTemplate(v.Name, v.Channels.First.Url)
|
||||
}
|
||||
|
||||
stream.SetSource(v.Channels.First.Url)
|
||||
|
||||
ok(w, r)
|
||||
|
||||
// /stream/{id}/channel/0/webrtc
|
||||
default:
|
||||
i := strings.IndexByte(r.RequestURI[8:], '/')
|
||||
if i <= 0 {
|
||||
log.Warn().Msgf("wrong request: %s", r.RequestURI)
|
||||
return
|
||||
}
|
||||
name := r.RequestURI[8 : 8+i]
|
||||
|
||||
stream := streams.Get(name)
|
||||
if stream == nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] parse form")
|
||||
return
|
||||
}
|
||||
|
||||
s := r.FormValue("data")
|
||||
offer, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] sdp64 decode")
|
||||
return
|
||||
}
|
||||
|
||||
s, err = webrtc.ExchangeSDP(stream, string(offer), "WebRTC/Hass sync", r.UserAgent())
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] exchange SDP")
|
||||
return
|
||||
}
|
||||
|
||||
s = base64.StdEncoding.EncodeToString([]byte(s))
|
||||
_, _ = w.Write([]byte(s))
|
||||
func apiStream(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
// /stream/{id}/add
|
||||
case strings.HasSuffix(r.RequestURI, "/add"):
|
||||
var v addJSON
|
||||
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
// we can get three types of links:
|
||||
// 1. link to go2rtc stream: rtsp://...:8554/{stream_name}
|
||||
// 2. static link to Hass camera
|
||||
// 3. dynamic link to Hass camera
|
||||
if streams.Patch(v.Name, v.Channels.First.Url) != nil {
|
||||
apiOK(w, r)
|
||||
} else {
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// /stream/{id}/channel/0/webrtc
|
||||
default:
|
||||
i := strings.IndexByte(r.RequestURI[8:], '/')
|
||||
if i <= 0 {
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
name := r.RequestURI[8 : 8+i]
|
||||
stream := streams.Get(name)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s := r.FormValue("data")
|
||||
offer, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s, err = webrtc.ExchangeSDP(stream, string(offer), "WebRTC/Hass sync", r.UserAgent())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s = base64.StdEncoding.EncodeToString([]byte(s))
|
||||
_, _ = w.Write([]byte(s))
|
||||
}
|
||||
}
|
||||
|
||||
func HassioAddr() string {
|
||||
|
||||
+81
-27
@@ -4,15 +4,18 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"sync"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/roborock"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hass"
|
||||
"github.com/rs/zerolog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
@@ -29,10 +32,33 @@ func Init() {
|
||||
|
||||
log = app.GetLogger("hass")
|
||||
|
||||
initAPI()
|
||||
// support API for https://www.home-assistant.io/integrations/rtsp_to_webrtc/
|
||||
api.HandleFunc("/static", apiOK)
|
||||
api.HandleFunc("/streams", apiOK)
|
||||
api.HandleFunc("/stream/", apiStream)
|
||||
|
||||
streams.RedirectFunc("hass", func(url string) (string, error) {
|
||||
if location := entities[url[5:]]; location != "" {
|
||||
return location, nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
})
|
||||
|
||||
streams.HandleFunc("hass", func(url 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
|
||||
})
|
||||
|
||||
// load static entries from Hass config
|
||||
if err := importConfig(conf.Mod.Config); err != nil {
|
||||
log.Debug().Msgf("[hass] can't import config: %s", err)
|
||||
|
||||
entries := importEntries(conf.Mod.Config)
|
||||
if entries == nil {
|
||||
api.HandleFunc("api/hass", func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "no hass config", http.StatusNotFound)
|
||||
})
|
||||
@@ -40,18 +66,22 @@ func Init() {
|
||||
}
|
||||
|
||||
api.HandleFunc("api/hass", func(w http.ResponseWriter, _ *http.Request) {
|
||||
var items []api.Stream
|
||||
for name, url := range entries {
|
||||
items = append(items, api.Stream{Name: name, URL: url})
|
||||
}
|
||||
api.ResponseStreams(w, items)
|
||||
})
|
||||
once.Do(func() {
|
||||
// load WebRTC entities from Hass API, works only for add-on version
|
||||
if token := hass.SupervisorToken(); token != "" {
|
||||
if err := importWebRTC(token); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
streams.HandleFunc("hass", func(url string) (core.Producer, error) {
|
||||
if hurl := entries[url[5:]]; hurl != "" {
|
||||
return streams.GetProducer(hurl)
|
||||
var items []*api.Source
|
||||
for name, url := range entities {
|
||||
items = append(items, &api.Source{
|
||||
Name: name, URL: "hass:" + name, Location: url,
|
||||
})
|
||||
}
|
||||
return nil, fmt.Errorf("can't get url: %s", url)
|
||||
api.ResponseSources(w, items)
|
||||
})
|
||||
|
||||
// for Addon listen on hassio interface, so WebUI feature will work
|
||||
@@ -68,12 +98,12 @@ func Init() {
|
||||
}
|
||||
}
|
||||
|
||||
func importEntries(config string) map[string]string {
|
||||
func importConfig(config string) error {
|
||||
// support load cameras from Hass config file
|
||||
filename := path.Join(config, ".storage/core.config_entries")
|
||||
b, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
var storage struct {
|
||||
@@ -88,11 +118,9 @@ func importEntries(config string) map[string]string {
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(b, &storage); err != nil {
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
urls := map[string]string{}
|
||||
|
||||
for _, entrie := range storage.Data.Entries {
|
||||
switch entrie.Domain {
|
||||
case "generic":
|
||||
@@ -102,7 +130,7 @@ func importEntries(config string) map[string]string {
|
||||
if err = json.Unmarshal(entrie.Options, &options); err != nil {
|
||||
continue
|
||||
}
|
||||
urls[entrie.Title] = options.StreamSource
|
||||
entities[entrie.Title] = options.StreamSource
|
||||
|
||||
case "homekit_controller":
|
||||
if !bytes.Contains(entrie.Data, []byte("iOSPairingId")) {
|
||||
@@ -121,7 +149,7 @@ func importEntries(config string) map[string]string {
|
||||
if err = json.Unmarshal(entrie.Data, &data); err != nil {
|
||||
continue
|
||||
}
|
||||
urls[entrie.Title] = fmt.Sprintf(
|
||||
entities[entrie.Title] = fmt.Sprintf(
|
||||
"homekit://%s:%d?client_id=%s&client_private=%s%s&device_id=%s&device_public=%s",
|
||||
data.DeviceHost, data.DevicePort,
|
||||
data.ClientID, data.ClientPrivate, data.ClientPublic,
|
||||
@@ -143,22 +171,48 @@ func importEntries(config string) map[string]string {
|
||||
}
|
||||
|
||||
if data.Username != "" && data.Password != "" {
|
||||
urls[entrie.Title] = fmt.Sprintf(
|
||||
entities[entrie.Title] = fmt.Sprintf(
|
||||
"onvif://%s:%s@%s:%d", data.Username, data.Password, data.Host, data.Port,
|
||||
)
|
||||
} else {
|
||||
urls[entrie.Title] = fmt.Sprintf("onvif://%s:%d", data.Host, data.Port)
|
||||
entities[entrie.Title] = fmt.Sprintf("onvif://%s:%d", data.Host, data.Port)
|
||||
}
|
||||
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
log.Info().Str("url", "hass:"+entrie.Title).Msg("[hass] load stream")
|
||||
log.Debug().Str("url", "hass:"+entrie.Title).Msg("[hass] load config")
|
||||
//streams.Get("hass:" + entrie.Title)
|
||||
}
|
||||
|
||||
return urls
|
||||
return nil
|
||||
}
|
||||
|
||||
func importWebRTC(token string) error {
|
||||
hassAPI, err := hass.NewAPI("ws://supervisor/core/websocket", token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
webrtcEntities, err := hassAPI.GetWebRTCEntities()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(webrtcEntities) == 0 {
|
||||
log.Debug().Msg("[hass] webrtc cameras not found")
|
||||
}
|
||||
|
||||
for name, entityID := range webrtcEntities {
|
||||
entities[name] = "hass://supervisor?entity_id=" + entityID
|
||||
|
||||
log.Debug().Msgf("[hass] load webrtc name=%s entity_id=%s", name, entityID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var entities = map[string]string{}
|
||||
var log zerolog.Logger
|
||||
var once sync.Once
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
## Useful links
|
||||
|
||||
- https://walterebert.com/playground/video/hls/
|
||||
+56
-115
@@ -1,21 +1,24 @@
|
||||
package hls
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"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/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mp4"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mpegts"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/rs/zerolog/log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
log = app.GetLogger("hls")
|
||||
|
||||
api.HandleFunc("api/stream.m3u8", handlerStream)
|
||||
api.HandleFunc("api/hls/playlist.m3u8", handlerPlaylist)
|
||||
|
||||
@@ -25,31 +28,16 @@ func Init() {
|
||||
// HLS (fMP4)
|
||||
api.HandleFunc("api/hls/init.mp4", handlerInit)
|
||||
api.HandleFunc("api/hls/segment.m4s", handlerSegmentMP4)
|
||||
|
||||
ws.HandleFunc("hls", handlerWSHLS)
|
||||
}
|
||||
|
||||
type Consumer interface {
|
||||
core.Consumer
|
||||
Listen(f core.EventFunc)
|
||||
Init() ([]byte, error)
|
||||
MimeCodecs() string
|
||||
Start()
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
cons Consumer
|
||||
playlist string
|
||||
init []byte
|
||||
segment []byte
|
||||
seq int
|
||||
alive *time.Timer
|
||||
mu sync.Mutex
|
||||
}
|
||||
var log zerolog.Logger
|
||||
|
||||
const keepalive = 5 * time.Second
|
||||
|
||||
var sessions = map[string]*Session{}
|
||||
|
||||
// once I saw 404 on MP4 segment, so better to use mutex
|
||||
var sessions = map[string]*Session{}
|
||||
var sessionsMu sync.RWMutex
|
||||
|
||||
func handlerStream(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -63,88 +51,51 @@ func handlerStream(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
src := r.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var cons Consumer
|
||||
var cons core.Consumer
|
||||
|
||||
// use fMP4 with codecs filter and TS without
|
||||
medias := mp4.ParseQuery(r.URL.Query())
|
||||
if medias != nil {
|
||||
cons = &mp4.Consumer{
|
||||
RemoteAddr: tcp.RemoteAddr(r),
|
||||
UserAgent: r.UserAgent(),
|
||||
Medias: medias,
|
||||
}
|
||||
c := mp4.NewConsumer(medias)
|
||||
c.Type = "HLS/fMP4 consumer"
|
||||
c.RemoteAddr = tcp.RemoteAddr(r)
|
||||
c.UserAgent = r.UserAgent()
|
||||
cons = c
|
||||
} else {
|
||||
cons = &mpegts.Consumer{
|
||||
RemoteAddr: tcp.RemoteAddr(r),
|
||||
UserAgent: r.UserAgent(),
|
||||
}
|
||||
c := mpegts.NewConsumer()
|
||||
c.Type = "HLS/TS consumer"
|
||||
c.RemoteAddr = tcp.RemoteAddr(r)
|
||||
c.UserAgent = r.UserAgent()
|
||||
cons = c
|
||||
}
|
||||
|
||||
session := &Session{cons: cons}
|
||||
|
||||
cons.Listen(func(msg any) {
|
||||
if data, ok := msg.([]byte); ok {
|
||||
session.mu.Lock()
|
||||
session.segment = append(session.segment, data...)
|
||||
session.mu.Unlock()
|
||||
}
|
||||
})
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
session := NewSession(cons)
|
||||
session.alive = time.AfterFunc(keepalive, func() {
|
||||
sessionsMu.Lock()
|
||||
delete(sessions, session.id)
|
||||
sessionsMu.Unlock()
|
||||
|
||||
stream.RemoveConsumer(cons)
|
||||
})
|
||||
session.init, _ = cons.Init()
|
||||
|
||||
cons.Start()
|
||||
|
||||
sid := core.RandString(8, 62)
|
||||
|
||||
// two segments important for Chromecast
|
||||
if medias != nil {
|
||||
session.playlist = `#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:1
|
||||
#EXT-X-MEDIA-SEQUENCE:%d
|
||||
#EXT-X-MAP:URI="init.mp4?id=` + sid + `"
|
||||
#EXTINF:0.500,
|
||||
segment.m4s?id=` + sid + `&n=%d
|
||||
#EXTINF:0.500,
|
||||
segment.m4s?id=` + sid + `&n=%d`
|
||||
} else {
|
||||
session.playlist = `#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:1
|
||||
#EXT-X-MEDIA-SEQUENCE:%d
|
||||
#EXTINF:0.500,
|
||||
segment.ts?id=` + sid + `&n=%d
|
||||
#EXTINF:0.500,
|
||||
segment.ts?id=` + sid + `&n=%d`
|
||||
}
|
||||
|
||||
sessionsMu.Lock()
|
||||
sessions[sid] = session
|
||||
sessions[session.id] = session
|
||||
sessionsMu.Unlock()
|
||||
|
||||
// Apple Safari can play FLAC codec, but fail it it in m3u8 playlist
|
||||
codecs := strings.Replace(cons.MimeCodecs(), mp4.MimeFlac, mp4.MimeAAC, 1)
|
||||
go session.Run()
|
||||
|
||||
// bandwidth important for Safari, codecs useful for smooth playback
|
||||
data := []byte(`#EXTM3U
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS="` + codecs + `"
|
||||
hls/playlist.m3u8?id=` + sid)
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
if _, err := w.Write(session.Main()); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
@@ -167,9 +118,7 @@ func handlerPlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
s := fmt.Sprintf(session.playlist, session.seq, session.seq, session.seq+1)
|
||||
|
||||
if _, err := w.Write([]byte(s)); err != nil {
|
||||
if _, err := w.Write(session.Playlist()); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
@@ -194,22 +143,13 @@ func handlerSegmentTS(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
session.alive.Reset(keepalive)
|
||||
|
||||
var i byte
|
||||
for len(session.segment) == 0 {
|
||||
if i++; i > 10 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
data := session.Segment()
|
||||
if data == nil {
|
||||
log.Warn().Msgf("[hls] can't get segment %s", r.URL.RawQuery)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
session.mu.Lock()
|
||||
data := session.segment
|
||||
// important to start new segment with init
|
||||
session.segment = session.init
|
||||
session.seq++
|
||||
session.mu.Unlock()
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
@@ -233,7 +173,14 @@ func handlerInit(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := w.Write(session.init); err != nil {
|
||||
data := session.Init()
|
||||
if data == nil {
|
||||
log.Warn().Msgf("[hls] can't get init %s", r.URL.RawQuery)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
@@ -243,11 +190,13 @@ func handlerSegmentMP4(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Content-Type", "video/iso.segment")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET")
|
||||
return
|
||||
}
|
||||
|
||||
sid := r.URL.Query().Get("id")
|
||||
query := r.URL.Query()
|
||||
|
||||
sid := query.Get("id")
|
||||
sessionsMu.RLock()
|
||||
session := sessions[sid]
|
||||
sessionsMu.RUnlock()
|
||||
@@ -258,21 +207,13 @@ func handlerSegmentMP4(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
session.alive.Reset(keepalive)
|
||||
|
||||
var i byte
|
||||
for len(session.segment) == 0 {
|
||||
if i++; i > 10 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
data := session.Segment()
|
||||
if data == nil {
|
||||
log.Warn().Msgf("[hls] can't get segment %s", r.URL.RawQuery)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
session.mu.Lock()
|
||||
data := session.segment
|
||||
session.segment = nil
|
||||
session.seq++
|
||||
session.mu.Unlock()
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package hls
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mp4"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
cons core.Consumer
|
||||
id string
|
||||
template string
|
||||
init []byte
|
||||
buffer []byte
|
||||
seq int
|
||||
alive *time.Timer
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewSession(cons core.Consumer) *Session {
|
||||
s := &Session{
|
||||
id: core.RandString(8, 62),
|
||||
cons: cons,
|
||||
}
|
||||
|
||||
// two segments important for Chromecast
|
||||
if _, ok := cons.(*mp4.Consumer); ok {
|
||||
s.template = `#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:1
|
||||
#EXT-X-MEDIA-SEQUENCE:%d
|
||||
#EXT-X-MAP:URI="init.mp4?id=` + s.id + `"
|
||||
#EXTINF:0.500,
|
||||
segment.m4s?id=` + s.id + `&n=%d
|
||||
#EXTINF:0.500,
|
||||
segment.m4s?id=` + s.id + `&n=%d`
|
||||
} else {
|
||||
s.template = `#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:1
|
||||
#EXT-X-MEDIA-SEQUENCE:%d
|
||||
#EXTINF:0.500,
|
||||
segment.ts?id=` + s.id + `&n=%d
|
||||
#EXTINF:0.500,
|
||||
segment.ts?id=` + s.id + `&n=%d`
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Session) Write(p []byte) (n int, err error) {
|
||||
s.mu.Lock()
|
||||
if s.init == nil {
|
||||
s.init = p
|
||||
} else {
|
||||
s.buffer = append(s.buffer, p...)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (s *Session) Run() {
|
||||
_, _ = s.cons.(io.WriterTo).WriteTo(s)
|
||||
}
|
||||
|
||||
func (s *Session) Main() []byte {
|
||||
type withCodecs interface {
|
||||
Codecs() []*core.Codec
|
||||
}
|
||||
|
||||
codecs := mp4.MimeCodecs(s.cons.(withCodecs).Codecs())
|
||||
codecs = strings.Replace(codecs, mp4.MimeFlac, "fLaC", 1)
|
||||
|
||||
// bandwidth important for Safari, codecs useful for smooth playback
|
||||
return []byte(`#EXTM3U
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=192000,CODECS="` + codecs + `"
|
||||
hls/playlist.m3u8?id=` + s.id)
|
||||
}
|
||||
|
||||
func (s *Session) Playlist() []byte {
|
||||
return []byte(fmt.Sprintf(s.template, s.seq, s.seq, s.seq+1))
|
||||
}
|
||||
|
||||
func (s *Session) Init() (init []byte) {
|
||||
for i := 0; i < 60 && init == nil; i++ {
|
||||
if i > 0 {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
// return init only when have some buffer
|
||||
if len(s.buffer) > 0 {
|
||||
init = s.init
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Session) Segment() (segment []byte) {
|
||||
for i := 0; i < 60 && segment == nil; i++ {
|
||||
if i > 0 {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if len(s.buffer) > 0 {
|
||||
segment = s.buffer
|
||||
if _, ok := s.cons.(*mp4.Consumer); ok {
|
||||
s.buffer = nil
|
||||
} else {
|
||||
// for TS important to start new segment with init
|
||||
s.buffer = s.init
|
||||
}
|
||||
s.seq++
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package hls
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"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 {
|
||||
stream := streams.GetOrPatch(tr.Request.URL.Query())
|
||||
if stream == nil {
|
||||
return errors.New(api.StreamNotFound)
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
log.Trace().Msgf("[hls] new ws consumer codecs=%s", codecs)
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return err
|
||||
}
|
||||
|
||||
session := NewSession(cons)
|
||||
|
||||
session.alive = time.AfterFunc(keepalive, func() {
|
||||
sessionsMu.Lock()
|
||||
delete(sessions, session.id)
|
||||
sessionsMu.Unlock()
|
||||
|
||||
stream.RemoveConsumer(cons)
|
||||
})
|
||||
|
||||
sessionsMu.Lock()
|
||||
sessions[session.id] = session
|
||||
sessionsMu.Unlock()
|
||||
|
||||
go session.Run()
|
||||
|
||||
main := session.Main()
|
||||
tr.Write(&ws.Message{Type: "hls", Value: string(main)})
|
||||
|
||||
return nil
|
||||
}
|
||||
+98
-99
@@ -1,140 +1,139 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/internal/app/store"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/mdns"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
||||
)
|
||||
|
||||
func apiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
items := make([]any, 0)
|
||||
sources, err := discovery()
|
||||
if err != nil {
|
||||
api.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
for name, src := range store.GetDict("streams") {
|
||||
if src := src.(string); strings.HasPrefix(src, "homekit") {
|
||||
u, err := url.Parse(src)
|
||||
if err != nil {
|
||||
continue
|
||||
urls := findHomeKitURLs()
|
||||
for id, u := range urls {
|
||||
deviceID := u.Query().Get("device_id")
|
||||
for _, source := range sources {
|
||||
if strings.Contains(source.URL, deviceID) {
|
||||
source.Location = id
|
||||
break
|
||||
}
|
||||
device := Device{
|
||||
Name: name,
|
||||
Addr: u.Host,
|
||||
Paired: true,
|
||||
}
|
||||
items = append(items, device)
|
||||
}
|
||||
}
|
||||
|
||||
for info := range mdns.GetAll() {
|
||||
if !strings.HasSuffix(info.Name, mdns.Suffix) {
|
||||
continue
|
||||
for _, source := range sources {
|
||||
if source.Location == "" {
|
||||
source.Location = " "
|
||||
}
|
||||
name := info.Name[:len(info.Name)-len(mdns.Suffix)]
|
||||
device := Device{
|
||||
Name: strings.ReplaceAll(name, "\\", ""),
|
||||
Addr: fmt.Sprintf("%s:%d", info.AddrV4, info.Port),
|
||||
}
|
||||
for _, field := range info.InfoFields {
|
||||
switch field[:2] {
|
||||
case "id":
|
||||
device.ID = field[3:]
|
||||
case "md":
|
||||
device.Model = field[3:]
|
||||
case "sf":
|
||||
device.Paired = field[3] == '0'
|
||||
}
|
||||
}
|
||||
items = append(items, device)
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(items)
|
||||
api.ResponseSources(w, sources)
|
||||
|
||||
case "POST":
|
||||
// TODO: post params...
|
||||
if err := r.ParseMultipartForm(1024); err != nil {
|
||||
api.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
id := r.URL.Query().Get("id")
|
||||
pin := r.URL.Query().Get("pin")
|
||||
name := r.URL.Query().Get("name")
|
||||
if err := hkPair(id, pin, name); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
_, err = w.Write([]byte(err.Error()))
|
||||
if err := apiPair(r.Form.Get("id"), r.Form.Get("url")); err != nil {
|
||||
api.Error(w, err)
|
||||
}
|
||||
|
||||
case "DELETE":
|
||||
src := r.URL.Query().Get("src")
|
||||
if err := hkDelete(src); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
_, err = w.Write([]byte(err.Error()))
|
||||
if err := r.ParseMultipartForm(1024); err != nil {
|
||||
api.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := apiUnpair(r.Form.Get("id")); err != nil {
|
||||
api.Error(w, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hkPair(deviceID, pin, name string) (err error) {
|
||||
var conn *hap.Conn
|
||||
func discovery() ([]*api.Source, error) {
|
||||
var sources []*api.Source
|
||||
|
||||
if conn, err = hap.Pair(deviceID, pin); err != nil {
|
||||
return
|
||||
}
|
||||
// 1. Get streams from Discovery
|
||||
err := mdns.Discovery(mdns.ServiceHAP, func(entry *mdns.ServiceEntry) bool {
|
||||
log.Trace().Msgf("[homekit] mdns=%s", entry)
|
||||
|
||||
streams.New(name, conn.URL())
|
||||
|
||||
dict := store.GetDict("streams")
|
||||
dict[name] = conn.URL()
|
||||
|
||||
return store.Set("streams", dict)
|
||||
}
|
||||
|
||||
func hkDelete(name string) (err error) {
|
||||
dict := store.GetDict("streams")
|
||||
for key, rawURL := range dict {
|
||||
if key != name {
|
||||
continue
|
||||
}
|
||||
|
||||
var conn *hap.Conn
|
||||
|
||||
if conn, err = hap.NewConn(rawURL.(string)); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = conn.Dial(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err = conn.Handle(); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
category := entry.Info[hap.TXTCategory]
|
||||
if entry.Complete() && (category == hap.CategoryCamera || category == hap.CategoryDoorbell) {
|
||||
source := &api.Source{
|
||||
Name: entry.Name,
|
||||
Info: entry.Info[hap.TXTModel],
|
||||
URL: fmt.Sprintf(
|
||||
"homekit://%s:%d?device_id=%s&feature=%s&status=%s",
|
||||
entry.IP, entry.Port, entry.Info[hap.TXTDeviceID],
|
||||
entry.Info[hap.TXTFeatureFlags], entry.Info[hap.TXTStatusFlags],
|
||||
),
|
||||
}
|
||||
}()
|
||||
|
||||
if err = conn.ListPairings(); err != nil {
|
||||
return
|
||||
sources = append(sources, source)
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
if err = conn.DeletePairing(conn.ClientID); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
|
||||
delete(dict, name)
|
||||
|
||||
return store.Set("streams", dict)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil
|
||||
return sources, nil
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Addr string `json:"addr"`
|
||||
Model string `json:"model"`
|
||||
Paired bool `json:"paired"`
|
||||
//Type string `json:"type"`
|
||||
func apiPair(id, url string) error {
|
||||
conn, err := hap.Pair(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
streams.New(id, conn.URL())
|
||||
|
||||
return app.PatchConfig(id, conn.URL(), "streams")
|
||||
}
|
||||
|
||||
func apiUnpair(id string) error {
|
||||
stream := streams.Get(id)
|
||||
if stream == nil {
|
||||
return errors.New(api.StreamNotFound)
|
||||
}
|
||||
|
||||
rawURL := findHomeKitURL(stream)
|
||||
if rawURL == "" {
|
||||
return errors.New("not homekit source")
|
||||
}
|
||||
|
||||
if err := hap.Unpair(rawURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
streams.Delete(id)
|
||||
|
||||
return app.PatchConfig(id, nil, "streams")
|
||||
}
|
||||
|
||||
func findHomeKitURLs() map[string]*url.URL {
|
||||
urls := map[string]*url.URL{}
|
||||
for id, stream := range streams.Streams() {
|
||||
if rawURL := findHomeKitURL(stream); rawURL != "" {
|
||||
if u, err := url.Parse(rawURL); err == nil {
|
||||
urls[id] = u
|
||||
}
|
||||
}
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
+173
-8
@@ -1,32 +1,197 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/srtp"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/camera"
|
||||
"github.com/AlexxIT/go2rtc/pkg/homekit"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
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"`
|
||||
} `yaml:"homekit"`
|
||||
}
|
||||
app.LoadConfig(&cfg)
|
||||
|
||||
log = app.GetLogger("homekit")
|
||||
|
||||
streams.HandleFunc("homekit", streamHandler)
|
||||
|
||||
api.HandleFunc("api/homekit", apiHandler)
|
||||
|
||||
if cfg.Mod == nil {
|
||||
return
|
||||
}
|
||||
|
||||
servers = map[string]*server{}
|
||||
var entries []*mdns.ServiceEntry
|
||||
|
||||
for id, conf := range cfg.Mod {
|
||||
stream := streams.Get(id)
|
||||
if stream == nil {
|
||||
log.Warn().Msgf("[homekit] missing stream: %s", id)
|
||||
continue
|
||||
}
|
||||
|
||||
if conf.Pin == "" {
|
||||
conf.Pin = "19550224" // default PIN
|
||||
}
|
||||
|
||||
pin, err := hap.SanitizePin(conf.Pin)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
continue
|
||||
}
|
||||
|
||||
deviceID := calcDeviceID(conf.DeviceID, id) // random MAC-address
|
||||
name := calcName(conf.Name, deviceID)
|
||||
|
||||
srv := &server{
|
||||
stream: id,
|
||||
srtp: srtp.Server,
|
||||
pairings: conf.Pairings,
|
||||
}
|
||||
|
||||
srv.hap = &hap.Server{
|
||||
Pin: pin,
|
||||
DeviceID: deviceID,
|
||||
DevicePrivate: calcDevicePrivate(conf.DevicePrivate, id),
|
||||
GetPair: srv.GetPair,
|
||||
AddPair: srv.AddPair,
|
||||
Handler: homekit.ServerHandler(srv),
|
||||
}
|
||||
|
||||
if url := findHomeKitURL(stream); url != "" {
|
||||
// 1. Act as transparent proxy for HomeKit camera
|
||||
dial := func() (net.Conn, error) {
|
||||
client, err := homekit.Dial(url, srtp.Server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.Conn(), nil
|
||||
}
|
||||
srv.hap.Handler = homekit.ProxyHandler(srv, dial)
|
||||
} else {
|
||||
// 2. Act as basic HomeKit camera
|
||||
srv.accessory = camera.NewAccessory("AlexxIT", "go2rtc", name, "-", app.Version)
|
||||
srv.hap.Handler = homekit.ServerHandler(srv)
|
||||
}
|
||||
|
||||
srv.mdns = &mdns.ServiceEntry{
|
||||
Name: name,
|
||||
Port: uint16(api.Port()),
|
||||
Info: map[string]string{
|
||||
hap.TXTConfigNumber: "1",
|
||||
hap.TXTFeatureFlags: "0",
|
||||
hap.TXTDeviceID: deviceID,
|
||||
hap.TXTModel: app.UserAgent,
|
||||
hap.TXTProtoVersion: "1.1",
|
||||
hap.TXTStateNumber: "1",
|
||||
hap.TXTStatusFlags: hap.StatusNotPaired,
|
||||
hap.TXTCategory: hap.CategoryCamera,
|
||||
hap.TXTSetupHash: srv.hap.SetupHash(),
|
||||
},
|
||||
}
|
||||
entries = append(entries, srv.mdns)
|
||||
|
||||
srv.UpdateStatus()
|
||||
|
||||
host := srv.mdns.Host(mdns.ServiceHAP)
|
||||
servers[host] = srv
|
||||
}
|
||||
|
||||
api.HandleFunc(hap.PathPairSetup, hapPairSetup)
|
||||
api.HandleFunc(hap.PathPairVerify, hapPairVerify)
|
||||
|
||||
log.Trace().Msgf("[homekit] mnds: %s", entries)
|
||||
|
||||
go func() {
|
||||
if err := mdns.Serve(mdns.ServiceHAP, entries); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
var servers map[string]*server
|
||||
|
||||
func streamHandler(url string) (core.Producer, error) {
|
||||
conn, err := homekit.NewClient(url, srtp.Server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = conn.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
return homekit.Dial(url, srtp.Server)
|
||||
}
|
||||
|
||||
func hapPairSetup(w http.ResponseWriter, r *http.Request) {
|
||||
srv, ok := servers[r.Host]
|
||||
if !ok {
|
||||
log.Error().Msg("[homekit] unknown host: " + r.Host)
|
||||
return
|
||||
}
|
||||
|
||||
conn, rw, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
if err = srv.hap.PairSetup(r, rw, conn); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
|
||||
func hapPairVerify(w http.ResponseWriter, r *http.Request) {
|
||||
srv, ok := servers[r.Host]
|
||||
if !ok {
|
||||
log.Error().Msg("[homekit] unknown host: " + r.Host)
|
||||
return
|
||||
}
|
||||
|
||||
conn, rw, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
if err = srv.hap.PairVerify(r, rw, conn); err != nil && err != io.EOF {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
|
||||
func findHomeKitURL(stream *streams.Stream) string {
|
||||
sources := stream.Sources()
|
||||
if len(sources) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
url := sources[0]
|
||||
if strings.HasPrefix(url, "homekit") {
|
||||
return url
|
||||
}
|
||||
|
||||
if strings.HasPrefix(url, "hass") {
|
||||
location, _ := streams.Location(url)
|
||||
if strings.HasPrefix(location, "homekit") {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/ffmpeg"
|
||||
srtp2 "github.com/AlexxIT/go2rtc/internal/srtp"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/camera"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
||||
"github.com/AlexxIT/go2rtc/pkg/homekit"
|
||||
"github.com/AlexxIT/go2rtc/pkg/magic"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
||||
"github.com/AlexxIT/go2rtc/pkg/srtp"
|
||||
)
|
||||
|
||||
type server struct {
|
||||
stream string // stream name from YAML
|
||||
hap *hap.Server // server for HAP connection and encryption
|
||||
mdns *mdns.ServiceEntry
|
||||
srtp *srtp.Server
|
||||
accessory *hap.Accessory // HAP accessory
|
||||
pairings []string // pairings list
|
||||
|
||||
streams map[string]*homekit.Consumer
|
||||
consumer *homekit.Consumer
|
||||
}
|
||||
|
||||
func (s *server) UpdateStatus() {
|
||||
// true status is important, or device may be offline in Apple Home
|
||||
if len(s.pairings) == 0 {
|
||||
s.mdns.Info[hap.TXTStatusFlags] = hap.StatusNotPaired
|
||||
} else {
|
||||
s.mdns.Info[hap.TXTStatusFlags] = hap.StatusPaired
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) GetAccessories(_ net.Conn) []*hap.Accessory {
|
||||
return []*hap.Accessory{s.accessory}
|
||||
}
|
||||
|
||||
func (s *server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any {
|
||||
log.Trace().Msgf("[homekit] %s: get char aid=%d iid=0x%x", conn.RemoteAddr(), aid, iid)
|
||||
|
||||
char := s.accessory.GetCharacterByID(iid)
|
||||
if char == nil {
|
||||
log.Warn().Msgf("[homekit] get unknown characteristic: %d", iid)
|
||||
return nil
|
||||
}
|
||||
|
||||
switch char.Type {
|
||||
case camera.TypeSetupEndpoints:
|
||||
if s.consumer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
answer := s.consumer.GetAnswer()
|
||||
v, err := tlv8.MarshalBase64(answer)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
return char.Value
|
||||
}
|
||||
|
||||
func (s *server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value any) {
|
||||
log.Trace().Msgf("[homekit] %s: set char aid=%d iid=0x%x value=%v", conn.RemoteAddr(), aid, iid, value)
|
||||
|
||||
char := s.accessory.GetCharacterByID(iid)
|
||||
if char == nil {
|
||||
log.Warn().Msgf("[homekit] set unknown characteristic: %d", iid)
|
||||
return
|
||||
}
|
||||
|
||||
switch char.Type {
|
||||
case camera.TypeSetupEndpoints:
|
||||
var offer camera.SetupEndpoints
|
||||
if err := tlv8.UnmarshalBase64(value.(string), &offer); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.consumer = homekit.NewConsumer(conn, srtp2.Server)
|
||||
s.consumer.SetOffer(&offer)
|
||||
|
||||
case camera.TypeSelectedStreamConfiguration:
|
||||
var conf camera.SelectedStreamConfig
|
||||
if err := tlv8.UnmarshalBase64(value.(string), &conf); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Trace().Msgf("[homekit] %s stream id=%x cmd=%d", conn.RemoteAddr(), conf.Control.SessionID, conf.Control.Command)
|
||||
|
||||
switch conf.Control.Command {
|
||||
case camera.SessionCommandEnd:
|
||||
if consumer := s.streams[conf.Control.SessionID]; consumer != nil {
|
||||
_ = consumer.Stop()
|
||||
}
|
||||
|
||||
case camera.SessionCommandStart:
|
||||
if s.consumer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !s.consumer.SetConfig(&conf) {
|
||||
log.Warn().Msgf("[homekit] wrong config")
|
||||
return
|
||||
}
|
||||
|
||||
if s.streams == nil {
|
||||
s.streams = map[string]*homekit.Consumer{}
|
||||
}
|
||||
|
||||
s.streams[conf.Control.SessionID] = s.consumer
|
||||
|
||||
stream := streams.Get(s.stream)
|
||||
if err := stream.AddConsumer(s.consumer); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
_, _ = s.consumer.WriteTo(nil)
|
||||
stream.RemoveConsumer(s.consumer)
|
||||
|
||||
delete(s.streams, conf.Control.SessionID)
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) GetImage(conn net.Conn, width, height int) []byte {
|
||||
log.Trace().Msgf("[homekit] %s: get image width=%d height=%d", conn.RemoteAddr(), width, height)
|
||||
|
||||
stream := streams.Get(s.stream)
|
||||
cons := magic.NewKeyframe()
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
once := &core.OnceBuffer{} // init and first frame
|
||||
_, _ = cons.WriteTo(once)
|
||||
b := once.Buffer()
|
||||
|
||||
stream.RemoveConsumer(cons)
|
||||
|
||||
switch cons.CodecName() {
|
||||
case core.CodecH264, core.CodecH265:
|
||||
var err error
|
||||
if b, err = ffmpeg.JPEGWithScale(b, width, height); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
func (s *server) GetPair(conn net.Conn, id string) []byte {
|
||||
log.Trace().Msgf("[homekit] %s: get pair id=%s", conn.RemoteAddr(), id)
|
||||
|
||||
for _, pairing := range s.pairings {
|
||||
if !strings.Contains(pairing, id) {
|
||||
continue
|
||||
}
|
||||
|
||||
query, err := url.ParseQuery(pairing)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if query.Get("client_id") != id {
|
||||
continue
|
||||
}
|
||||
|
||||
s := query.Get("client_public")
|
||||
b, _ := hex.DecodeString(s)
|
||||
return b
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *server) AddPair(conn net.Conn, id string, public []byte, permissions byte) {
|
||||
log.Trace().Msgf("[homekit] %s: add pair id=%s public=%x perm=%d", conn.RemoteAddr(), id, public, permissions)
|
||||
|
||||
query := url.Values{
|
||||
"client_id": []string{id},
|
||||
"client_public": []string{hex.EncodeToString(public)},
|
||||
"permissions": []string{string('0' + permissions)},
|
||||
}
|
||||
if s.GetPair(conn, id) == nil {
|
||||
s.pairings = append(s.pairings, query.Encode())
|
||||
s.UpdateStatus()
|
||||
s.PatchConfig()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) DelPair(conn net.Conn, id string) {
|
||||
log.Trace().Msgf("[homekit] %s: del pair id=%s", conn.RemoteAddr(), id)
|
||||
|
||||
id = "client_id=" + id
|
||||
for i, pairing := range s.pairings {
|
||||
if !strings.Contains(pairing, id) {
|
||||
continue
|
||||
}
|
||||
|
||||
s.pairings = append(s.pairings[:i], s.pairings[i+1:]...)
|
||||
s.UpdateStatus()
|
||||
s.PatchConfig()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) PatchConfig() {
|
||||
if err := app.PatchConfig("pairings", s.pairings, "homekit", s.stream); err != nil {
|
||||
log.Error().Err(err).Msgf(
|
||||
"[homekit] can't save %s pairings=%v", s.stream, s.pairings,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func calcName(name, seed string) string {
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
b := sha512.Sum512([]byte(seed))
|
||||
return fmt.Sprintf("go2rtc-%02X%02X", b[0], b[2])
|
||||
}
|
||||
|
||||
func calcDeviceID(deviceID, seed string) string {
|
||||
if deviceID != "" {
|
||||
if len(deviceID) >= 17 {
|
||||
// 1. Returd device_id as is (ex. AA:BB:CC:DD:EE:FF)
|
||||
return deviceID
|
||||
}
|
||||
// 2. Use device_id as seed if not zero
|
||||
seed = deviceID
|
||||
}
|
||||
b := sha512.Sum512([]byte(seed))
|
||||
return fmt.Sprintf("%02X:%02X:%02X:%02X:%02X:%02X", b[32], b[34], b[36], b[38], b[40], b[42])
|
||||
}
|
||||
|
||||
func calcDevicePrivate(private, seed string) []byte {
|
||||
if private != "" {
|
||||
// 1. Decode private from HEX string
|
||||
if b, _ := hex.DecodeString(private); len(b) == ed25519.PrivateKeySize {
|
||||
// 2. Return if OK
|
||||
return b
|
||||
}
|
||||
// 3. Use private as seed if not zero
|
||||
seed = private
|
||||
}
|
||||
b := sha512.Sum512([]byte(seed))
|
||||
return ed25519.NewKeyFromSeed(b[:ed25519.SeedSize])
|
||||
}
|
||||
+36
-40
@@ -2,17 +2,18 @@ package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/magic"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mjpeg"
|
||||
"github.com/AlexxIT/go2rtc/pkg/rtmp"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hls"
|
||||
"github.com/AlexxIT/go2rtc/pkg/magic"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mjpeg"
|
||||
"github.com/AlexxIT/go2rtc/pkg/multipart"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
@@ -23,13 +24,24 @@ func Init() {
|
||||
streams.HandleFunc("tcp", handleTCP)
|
||||
}
|
||||
|
||||
func handleHTTP(url string) (core.Producer, error) {
|
||||
func handleHTTP(rawURL string) (core.Producer, error) {
|
||||
rawURL, rawQuery, _ := strings.Cut(rawURL, "#")
|
||||
|
||||
// first we get the Content-Type to define supported producer
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
req, err := http.NewRequest("GET", rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if rawQuery != "" {
|
||||
query := streams.ParseQuery(rawQuery)
|
||||
|
||||
for _, header := range query["header"] {
|
||||
key, value, _ := strings.Cut(header, ":")
|
||||
req.Header.Add(key, strings.TrimSpace(value))
|
||||
}
|
||||
}
|
||||
|
||||
res, err := tcp.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -39,37 +51,29 @@ func handleHTTP(url string) (core.Producer, error) {
|
||||
return nil, errors.New(res.Status)
|
||||
}
|
||||
|
||||
// 1. Guess format from content type
|
||||
ct := res.Header.Get("Content-Type")
|
||||
if i := strings.IndexByte(ct, ';'); i > 0 {
|
||||
ct = ct[:i]
|
||||
}
|
||||
|
||||
switch ct {
|
||||
case "image/jpeg", "multipart/x-mixed-replace":
|
||||
var ext string
|
||||
if i := strings.LastIndexByte(req.URL.Path, '.'); i > 0 {
|
||||
ext = req.URL.Path[i+1:]
|
||||
}
|
||||
|
||||
switch {
|
||||
case ct == "image/jpeg":
|
||||
return mjpeg.NewClient(res), nil
|
||||
|
||||
case "video/x-flv":
|
||||
var conn *rtmp.Client
|
||||
if conn, err = rtmp.Accept(res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = conn.Describe(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
case ct == "multipart/x-mixed-replace":
|
||||
return multipart.Open(res.Body)
|
||||
|
||||
default: // "video/mpeg":
|
||||
case ct == "application/vnd.apple.mpegurl" || ext == "m3u8":
|
||||
return hls.OpenURL(req.URL, res.Body)
|
||||
}
|
||||
|
||||
client := magic.NewClient(res.Body)
|
||||
if err = client.Probe(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client.Desc = "HTTP active producer"
|
||||
client.URL = url
|
||||
|
||||
return client, nil
|
||||
return magic.Open(res.Body)
|
||||
}
|
||||
|
||||
func handleTCP(rawURL string) (core.Producer, error) {
|
||||
@@ -78,18 +82,10 @@ func handleTCP(rawURL string) (core.Producer, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", u.Host, time.Second*3)
|
||||
conn, err := net.DialTimeout("tcp", u.Host, core.ConnDialTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := magic.NewClient(conn)
|
||||
if err = client.Probe(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client.Desc = "TCP active producer"
|
||||
client.URL = rawURL
|
||||
|
||||
return client, nil
|
||||
return magic.Open(conn)
|
||||
}
|
||||
|
||||
+53
-62
@@ -2,7 +2,13 @@ package mjpeg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
||||
"github.com/AlexxIT/go2rtc/internal/ffmpeg"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
@@ -10,48 +16,35 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/mjpeg"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/rs/zerolog/log"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
api.HandleFunc("api/frame.jpeg", handlerKeyframe)
|
||||
api.HandleFunc("api/stream.mjpeg", handlerStream)
|
||||
|
||||
api.HandleWS("mjpeg", handlerWS)
|
||||
ws.HandleFunc("mjpeg", handlerWS)
|
||||
}
|
||||
|
||||
func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
|
||||
src := r.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
exit := make(chan []byte)
|
||||
|
||||
cons := &magic.Keyframe{
|
||||
RemoteAddr: tcp.RemoteAddr(r),
|
||||
UserAgent: r.UserAgent(),
|
||||
}
|
||||
cons.Listen(func(msg any) {
|
||||
if b, ok := msg.([]byte); ok {
|
||||
select {
|
||||
case exit <- b:
|
||||
default:
|
||||
}
|
||||
}
|
||||
})
|
||||
cons := magic.NewKeyframe()
|
||||
cons.RemoteAddr = tcp.RemoteAddr(r)
|
||||
cons.UserAgent = r.UserAgent()
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
data := <-exit
|
||||
once := &core.OnceBuffer{} // init and first frame
|
||||
_, _ = cons.WriteTo(once)
|
||||
b := once.Buffer()
|
||||
|
||||
stream.RemoveConsumer(cons)
|
||||
|
||||
@@ -59,7 +52,7 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
|
||||
case core.CodecH264, core.CodecH265:
|
||||
ts := time.Now()
|
||||
var err error
|
||||
if data, err = ffmpeg.TranscodeToJPEG(data); err != nil {
|
||||
if b, err = ffmpeg.JPEGWithQuery(b, r.URL.Query()); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -68,18 +61,16 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "image/jpeg")
|
||||
h.Set("Content-Length", strconv.Itoa(len(data)))
|
||||
h.Set("Content-Length", strconv.Itoa(len(b)))
|
||||
h.Set("Cache-Control", "no-cache")
|
||||
h.Set("Connection", "close")
|
||||
h.Set("Pragma", "no-cache")
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
if _, err := w.Write(b); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
|
||||
const header = "--frame\r\nContent-Type: image/jpeg\r\nContent-Length: "
|
||||
|
||||
func handlerStream(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
outputMjpeg(w, r)
|
||||
@@ -90,32 +81,15 @@ func handlerStream(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func outputMjpeg(w http.ResponseWriter, r *http.Request) {
|
||||
src := r.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
flusher := w.(http.Flusher)
|
||||
|
||||
cons := &mjpeg.Consumer{
|
||||
RemoteAddr: tcp.RemoteAddr(r),
|
||||
UserAgent: r.UserAgent(),
|
||||
}
|
||||
cons.Listen(func(msg any) {
|
||||
switch msg := msg.(type) {
|
||||
case []byte:
|
||||
data := []byte(header + strconv.Itoa(len(msg)))
|
||||
data = append(data, '\r', '\n', '\r', '\n')
|
||||
data = append(data, msg...)
|
||||
data = append(data, '\r', '\n')
|
||||
|
||||
// Chrome bug: mjpeg image always shows the second to last image
|
||||
// https://bugs.chromium.org/p/chromium/issues/detail?id=527446
|
||||
_, _ = w.Write(data)
|
||||
flusher.Flush()
|
||||
}
|
||||
})
|
||||
cons := mjpeg.NewConsumer()
|
||||
cons.RemoteAddr = tcp.RemoteAddr(r)
|
||||
cons.UserAgent = r.UserAgent()
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Msg("[api.mjpeg] add consumer")
|
||||
@@ -128,11 +102,33 @@ func outputMjpeg(w http.ResponseWriter, r *http.Request) {
|
||||
h.Set("Connection", "close")
|
||||
h.Set("Pragma", "no-cache")
|
||||
|
||||
<-r.Context().Done()
|
||||
wr := &writer{wr: w, buf: []byte(header)}
|
||||
_, _ = cons.WriteTo(wr)
|
||||
|
||||
stream.RemoveConsumer(cons)
|
||||
}
|
||||
|
||||
//log.Trace().Msg("[api.mjpeg] close")
|
||||
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()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func inputMjpeg(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -156,29 +152,24 @@ func inputMjpeg(w http.ResponseWriter, r *http.Request) {
|
||||
stream.RemoveProducer(client)
|
||||
}
|
||||
|
||||
func handlerWS(tr *api.Transport, _ *api.Message) error {
|
||||
src := tr.Request.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
func handlerWS(tr *ws.Transport, _ *ws.Message) error {
|
||||
stream := streams.GetOrPatch(tr.Request.URL.Query())
|
||||
if stream == nil {
|
||||
return errors.New(api.StreamNotFound)
|
||||
}
|
||||
|
||||
cons := &mjpeg.Consumer{
|
||||
RemoteAddr: tcp.RemoteAddr(tr.Request),
|
||||
UserAgent: tr.Request.UserAgent(),
|
||||
}
|
||||
cons.Listen(func(msg any) {
|
||||
if data, ok := msg.([]byte); ok {
|
||||
tr.Write(data)
|
||||
}
|
||||
})
|
||||
cons := mjpeg.NewConsumer()
|
||||
cons.RemoteAddr = tcp.RemoteAddr(tr.Request)
|
||||
cons.UserAgent = tr.Request.UserAgent()
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return err
|
||||
}
|
||||
|
||||
tr.Write(&api.Message{Type: "mjpeg"})
|
||||
tr.Write(&ws.Message{Type: "mjpeg"})
|
||||
|
||||
go cons.WriteTo(tr.Writer())
|
||||
|
||||
tr.OnClose(func() {
|
||||
stream.RemoveConsumer(cons)
|
||||
|
||||
+40
-67
@@ -7,8 +7,10 @@ import (
|
||||
"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/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mp4"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/rs/zerolog"
|
||||
@@ -17,8 +19,8 @@ import (
|
||||
func Init() {
|
||||
log = app.GetLogger("mp4")
|
||||
|
||||
api.HandleWS("mse", handlerWSMSE)
|
||||
api.HandleWS("mp4", handlerWSMP4)
|
||||
ws.HandleFunc("mse", handlerWSMSE)
|
||||
ws.HandleFunc("mp4", handlerWSMP4)
|
||||
|
||||
api.HandleFunc("api/frame.mp4", handlerKeyframe)
|
||||
api.HandleFunc("api/stream.mp4", handlerMP4)
|
||||
@@ -37,25 +39,15 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
src := r.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
query := r.URL.Query()
|
||||
src := query.Get("src")
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
exit := make(chan []byte, 1)
|
||||
|
||||
cons := &mp4.Segment{OnlyKeyframe: true}
|
||||
cons.Listen(func(msg any) {
|
||||
if data, ok := msg.([]byte); ok && exit != nil {
|
||||
select {
|
||||
case exit <- data:
|
||||
default:
|
||||
}
|
||||
exit = nil
|
||||
}
|
||||
})
|
||||
cons := mp4.NewKeyframe(nil)
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
@@ -63,15 +55,21 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
data := <-exit
|
||||
once := &core.OnceBuffer{} // init and first frame
|
||||
_, _ = cons.WriteTo(once)
|
||||
|
||||
stream.RemoveConsumer(cons)
|
||||
|
||||
// Apple Safari won't show frame without length
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
w.Header().Set("Content-Type", cons.MimeType)
|
||||
header := w.Header()
|
||||
header.Set("Content-Length", strconv.Itoa(once.Len()))
|
||||
header.Set("Content-Type", mp4.ContentType(cons.Codecs()))
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
if filename := query.Get("filename"); filename != "" {
|
||||
header.Set("Content-Disposition", `attachment; filename="`+filename+`"`)
|
||||
}
|
||||
|
||||
if _, err := once.WriteTo(w); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
@@ -94,34 +92,17 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
src := query.Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
exit := make(chan error, 1) // Add buffer to prevent blocking
|
||||
|
||||
cons := &mp4.Consumer{
|
||||
RemoteAddr: tcp.RemoteAddr(r),
|
||||
UserAgent: r.UserAgent(),
|
||||
Medias: mp4.ParseQuery(r.URL.Query()),
|
||||
}
|
||||
|
||||
cons.Listen(func(msg any) {
|
||||
if exit == nil {
|
||||
return
|
||||
}
|
||||
if data, ok := msg.([]byte); ok {
|
||||
if _, err := w.Write(data); err != nil {
|
||||
select {
|
||||
case exit <- err:
|
||||
default:
|
||||
}
|
||||
exit = nil
|
||||
}
|
||||
}
|
||||
})
|
||||
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()
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
@@ -129,44 +110,36 @@ func handlerMP4(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
defer stream.RemoveConsumer(cons)
|
||||
|
||||
w.Header().Set("Content-Type", cons.MimeType())
|
||||
|
||||
data, err := cons.Init()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
if rotate := query.Get("rotate"); rotate != "" {
|
||||
cons.Rotate = core.Atoi(rotate)
|
||||
}
|
||||
|
||||
if _, err = w.Write(data); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
if scale := query.Get("scale"); scale != "" {
|
||||
if sx, sy, ok := strings.Cut(scale, ":"); ok {
|
||||
cons.ScaleX = core.Atoi(sx)
|
||||
cons.ScaleY = core.Atoi(sy)
|
||||
}
|
||||
}
|
||||
|
||||
cons.Start()
|
||||
header := w.Header()
|
||||
header.Set("Content-Type", mp4.ContentType(cons.Codecs()))
|
||||
|
||||
if filename := query.Get("filename"); filename != "" {
|
||||
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() {
|
||||
if exit != nil {
|
||||
select {
|
||||
case exit <- nil:
|
||||
default:
|
||||
}
|
||||
exit = nil
|
||||
}
|
||||
_ = cons.Stop()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
err = <-exit
|
||||
exit = nil
|
||||
_, _ = cons.WriteTo(w)
|
||||
|
||||
log.Trace().Err(err).Caller().Send()
|
||||
stream.RemoveConsumer(cons)
|
||||
|
||||
if duration != nil {
|
||||
duration.Stop()
|
||||
|
||||
+25
-91
@@ -2,91 +2,73 @@ package mp4
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mp4"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func handlerWSMSE(tr *api.Transport, msg *api.Message) error {
|
||||
src := tr.Request.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error {
|
||||
stream := streams.GetOrPatch(tr.Request.URL.Query())
|
||||
if stream == nil {
|
||||
return errors.New(api.StreamNotFound)
|
||||
}
|
||||
|
||||
cons := &mp4.Consumer{
|
||||
RemoteAddr: tcp.RemoteAddr(tr.Request),
|
||||
UserAgent: tr.Request.UserAgent(),
|
||||
}
|
||||
|
||||
var medias []*core.Media
|
||||
if codecs := msg.String(); codecs != "" {
|
||||
log.Trace().Str("codecs", codecs).Msgf("[mp4] new WS/MSE consumer")
|
||||
cons.Medias = parseMedias(codecs, true)
|
||||
medias = mp4.ParseCodecs(codecs, true)
|
||||
}
|
||||
|
||||
cons.Listen(func(msg any) {
|
||||
if data, ok := msg.([]byte); ok {
|
||||
tr.Write(data)
|
||||
}
|
||||
})
|
||||
cons := mp4.NewConsumer(medias)
|
||||
cons.Type = "MSE/WebSocket active consumer"
|
||||
cons.RemoteAddr = tcp.RemoteAddr(tr.Request)
|
||||
cons.UserAgent = tr.Request.UserAgent()
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Debug().Err(err).Msg("[mp4] add consumer")
|
||||
return err
|
||||
}
|
||||
|
||||
tr.Write(&ws.Message{Type: "mse", Value: mp4.ContentType(cons.Codecs())})
|
||||
|
||||
go cons.WriteTo(tr.Writer())
|
||||
|
||||
tr.OnClose(func() {
|
||||
stream.RemoveConsumer(cons)
|
||||
})
|
||||
|
||||
tr.Write(&api.Message{Type: "mse", Value: cons.MimeType()})
|
||||
|
||||
data, err := cons.Init()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
return err
|
||||
}
|
||||
|
||||
tr.Write(data)
|
||||
|
||||
cons.Start()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func handlerWSMP4(tr *api.Transport, msg *api.Message) error {
|
||||
src := tr.Request.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
func handlerWSMP4(tr *ws.Transport, msg *ws.Message) error {
|
||||
stream := streams.GetOrPatch(tr.Request.URL.Query())
|
||||
if stream == nil {
|
||||
return errors.New(api.StreamNotFound)
|
||||
}
|
||||
|
||||
cons := &mp4.Segment{
|
||||
RemoteAddr: tcp.RemoteAddr(tr.Request),
|
||||
UserAgent: tr.Request.UserAgent(),
|
||||
OnlyKeyframe: true,
|
||||
}
|
||||
|
||||
var medias []*core.Media
|
||||
if codecs := msg.String(); codecs != "" {
|
||||
log.Trace().Str("codecs", codecs).Msgf("[mp4] new WS/MP4 consumer")
|
||||
cons.Medias = parseMedias(codecs, false)
|
||||
medias = mp4.ParseCodecs(codecs, false)
|
||||
}
|
||||
|
||||
cons.Listen(func(msg any) {
|
||||
if data, ok := msg.([]byte); ok {
|
||||
tr.Write(data)
|
||||
}
|
||||
})
|
||||
cons := mp4.NewKeyframe(medias)
|
||||
cons.Type = "MP4/WebSocket active consumer"
|
||||
cons.RemoteAddr = tcp.RemoteAddr(tr.Request)
|
||||
cons.UserAgent = tr.Request.UserAgent()
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return err
|
||||
}
|
||||
|
||||
tr.Write(&api.Message{Type: "mp4", Value: cons.MimeType})
|
||||
tr.Write(&ws.Message{Type: "mse", Value: mp4.ContentType(cons.Codecs())})
|
||||
|
||||
go cons.WriteTo(tr.Writer())
|
||||
|
||||
tr.OnClose(func() {
|
||||
stream.RemoveConsumer(cons)
|
||||
@@ -94,51 +76,3 @@ func handlerWSMP4(tr *api.Transport, msg *api.Message) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseMedias(codecs string, parseAudio bool) (medias []*core.Media) {
|
||||
var videos []*core.Codec
|
||||
var audios []*core.Codec
|
||||
|
||||
for _, name := range strings.Split(codecs, ",") {
|
||||
switch name {
|
||||
case mp4.MimeH264:
|
||||
codec := &core.Codec{Name: core.CodecH264}
|
||||
videos = append(videos, codec)
|
||||
case mp4.MimeH265:
|
||||
codec := &core.Codec{Name: core.CodecH265}
|
||||
videos = append(videos, codec)
|
||||
case mp4.MimeAAC:
|
||||
codec := &core.Codec{Name: core.CodecAAC}
|
||||
audios = append(audios, codec)
|
||||
case mp4.MimeFlac:
|
||||
audios = append(audios,
|
||||
&core.Codec{Name: core.CodecPCMA},
|
||||
&core.Codec{Name: core.CodecPCMU},
|
||||
&core.Codec{Name: core.CodecPCM},
|
||||
)
|
||||
case mp4.MimeOpus:
|
||||
codec := &core.Codec{Name: core.CodecOpus}
|
||||
audios = append(audios, codec)
|
||||
}
|
||||
}
|
||||
|
||||
if videos != nil {
|
||||
media := &core.Media{
|
||||
Kind: core.KindVideo,
|
||||
Direction: core.DirectionSendonly,
|
||||
Codecs: videos,
|
||||
}
|
||||
medias = append(medias, media)
|
||||
}
|
||||
|
||||
if audios != nil && parseAudio {
|
||||
media := &core.Media{
|
||||
Kind: core.KindAudio,
|
||||
Direction: core.DirectionSendonly,
|
||||
Codecs: audios,
|
||||
}
|
||||
medias = append(medias, media)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package mpegts
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"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) {
|
||||
src := r.URL.Query().Get("src")
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
cons := aac.NewConsumer()
|
||||
cons.RemoteAddr = tcp.RemoteAddr(r)
|
||||
cons.UserAgent = r.UserAgent()
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "audio/aac")
|
||||
|
||||
_, _ = cons.WriteTo(w)
|
||||
|
||||
stream.RemoveConsumer(cons)
|
||||
}
|
||||
@@ -1,22 +1,54 @@
|
||||
package mpegts
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mpegts"
|
||||
"net/http"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
api.HandleFunc("api/stream.ts", apiHandle)
|
||||
api.HandleFunc("api/stream.aac", apiStreamAAC)
|
||||
}
|
||||
|
||||
func apiHandle(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||
outputMpegTS(w, r)
|
||||
} else {
|
||||
inputMpegTS(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func outputMpegTS(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 := mpegts.NewConsumer()
|
||||
cons.RemoteAddr = tcp.RemoteAddr(r)
|
||||
cons.UserAgent = r.UserAgent()
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "video/mp2t")
|
||||
|
||||
_, _ = cons.WriteTo(w)
|
||||
|
||||
stream.RemoveConsumer(cons)
|
||||
}
|
||||
|
||||
func inputMpegTS(w http.ResponseWriter, r *http.Request) {
|
||||
dst := r.URL.Query().Get("dst")
|
||||
stream := streams.Get(dst)
|
||||
if stream == nil {
|
||||
@@ -25,16 +57,15 @@ func apiHandle(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
res := &http.Response{Body: r.Body, Request: r}
|
||||
client := mpegts.NewClient(res)
|
||||
|
||||
if err := client.Handle(); err != nil {
|
||||
client, err := mpegts.Open(res.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
stream.AddProducer(client)
|
||||
|
||||
if err := client.Handle(); err != nil {
|
||||
if err = client.Start(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package nest
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/nest"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("nest", streamNest)
|
||||
|
||||
api.HandleFunc("api/nest", apiNest)
|
||||
}
|
||||
|
||||
func streamNest(url string) (core.Producer, error) {
|
||||
client, err := nest.NewClient(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func apiNest(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
cliendID := query.Get("client_id")
|
||||
cliendSecret := query.Get("client_secret")
|
||||
refreshToken := query.Get("refresh_token")
|
||||
projectID := query.Get("project_id")
|
||||
|
||||
nestAPI, err := nest.NewAPI(cliendID, cliendSecret, refreshToken)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
devices, err := nestAPI.GetDevices(projectID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var items []*api.Source
|
||||
|
||||
for name, deviceID := range devices {
|
||||
query.Set("device_id", deviceID)
|
||||
|
||||
items = append(items, &api.Source{
|
||||
Name: name, URL: "nest:?" + query.Encode(),
|
||||
})
|
||||
}
|
||||
|
||||
api.ResponseSources(w, items)
|
||||
}
|
||||
+13
-12
@@ -1,13 +1,6 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/onvif"
|
||||
"github.com/rs/zerolog"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -15,6 +8,14 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/onvif"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
@@ -121,7 +122,7 @@ func onvifDeviceService(w http.ResponseWriter, r *http.Request) {
|
||||
func apiOnvif(w http.ResponseWriter, r *http.Request) {
|
||||
src := r.URL.Query().Get("src")
|
||||
|
||||
var items []api.Stream
|
||||
var items []*api.Source
|
||||
|
||||
if src == "" {
|
||||
urls, err := onvif.DiscoveryStreamingURLs()
|
||||
@@ -149,7 +150,7 @@ func apiOnvif(w http.ResponseWriter, r *http.Request) {
|
||||
u.Path = ""
|
||||
}
|
||||
|
||||
items = append(items, api.Stream{Name: u.Host, URL: u.String()})
|
||||
items = append(items, &api.Source{Name: u.Host, URL: u.String()})
|
||||
}
|
||||
} else {
|
||||
client, err := onvif.NewClient(src)
|
||||
@@ -176,19 +177,19 @@ func apiOnvif(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
for i, token := range tokens {
|
||||
items = append(items, api.Stream{
|
||||
items = append(items, &api.Source{
|
||||
Name: name + " stream" + strconv.Itoa(i),
|
||||
URL: src + "?subtype=" + token,
|
||||
})
|
||||
}
|
||||
|
||||
if len(tokens) > 0 && client.HasSnapshots() {
|
||||
items = append(items, api.Stream{
|
||||
items = append(items, &api.Source{
|
||||
Name: name + " snapshot",
|
||||
URL: src + "?subtype=" + tokens[0] + "&snapshot",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
api.ResponseStreams(w, items)
|
||||
api.ResponseSources(w, items)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ package roborock
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/roborock"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
@@ -84,7 +85,7 @@ func apiHandle(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var items []api.Stream
|
||||
var items []*api.Source
|
||||
|
||||
for _, device := range devices {
|
||||
source := fmt.Sprintf(
|
||||
@@ -93,8 +94,8 @@ func apiHandle(w http.ResponseWriter, r *http.Request) {
|
||||
Auth.UserData.IoT.User, Auth.UserData.IoT.Pass, Auth.UserData.IoT.Domain,
|
||||
device.DID, device.Key,
|
||||
)
|
||||
items = append(items, api.Stream{Name: device.Name, URL: source})
|
||||
items = append(items, &api.Source{Name: device.Name, URL: source})
|
||||
}
|
||||
|
||||
api.ResponseStreams(w, items)
|
||||
api.ResponseSources(w, items)
|
||||
}
|
||||
|
||||
+160
-16
@@ -1,38 +1,188 @@
|
||||
package rtmp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/flv"
|
||||
"github.com/AlexxIT/go2rtc/pkg/rtmp"
|
||||
"github.com/rs/zerolog/log"
|
||||
"io"
|
||||
"net/http"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var conf struct {
|
||||
Mod struct {
|
||||
Listen string `yaml:"listen" json:"listen"`
|
||||
} `yaml:"rtmp"`
|
||||
}
|
||||
|
||||
app.LoadConfig(&conf)
|
||||
|
||||
log = app.GetLogger("rtmp")
|
||||
|
||||
streams.HandleFunc("rtmp", streamsHandle)
|
||||
streams.HandleFunc("rtmps", streamsHandle)
|
||||
streams.HandleFunc("rtmpx", streamsHandle)
|
||||
|
||||
api.HandleFunc("api/stream.flv", apiHandle)
|
||||
|
||||
streams.HandleConsumerFunc("rtmp", streamsConsumerHandle)
|
||||
streams.HandleConsumerFunc("rtmps", streamsConsumerHandle)
|
||||
streams.HandleConsumerFunc("rtmpx", streamsConsumerHandle)
|
||||
|
||||
address := conf.Mod.Listen
|
||||
if address == "" {
|
||||
return
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", address)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().Str("addr", address).Msg("[rtmp] listen")
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err = tcpHandle(conn); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func tcpHandle(netConn net.Conn) error {
|
||||
rtmpConn, err := rtmp.NewServer(netConn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = rtmpConn.ReadCommands(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch rtmpConn.Intent {
|
||||
case rtmp.CommandPlay:
|
||||
stream := streams.Get(rtmpConn.App)
|
||||
if stream == nil {
|
||||
return errors.New("stream not found: " + rtmpConn.App)
|
||||
}
|
||||
|
||||
cons := flv.NewConsumer()
|
||||
if err = stream.AddConsumer(cons); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer stream.RemoveConsumer(cons)
|
||||
|
||||
if err = rtmpConn.WriteStart(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = cons.WriteTo(rtmpConn)
|
||||
|
||||
return nil
|
||||
|
||||
case rtmp.CommandPublish:
|
||||
stream := streams.Get(rtmpConn.App)
|
||||
if stream == nil {
|
||||
return errors.New("stream not found: " + rtmpConn.App)
|
||||
}
|
||||
|
||||
if err = rtmpConn.WriteStart(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prod, err := rtmpConn.Producer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stream.AddProducer(prod)
|
||||
|
||||
defer stream.RemoveProducer(prod)
|
||||
|
||||
_ = prod.Start()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New("rtmp: unknown command: " + rtmpConn.Intent)
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
|
||||
func streamsHandle(url string) (core.Producer, error) {
|
||||
conn := rtmp.NewClient(url)
|
||||
if err := conn.Dial(); err != nil {
|
||||
client, err := rtmp.DialPlay(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := conn.Describe(); err != nil {
|
||||
return nil, err
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func streamsConsumerHandle(url string) (core.Consumer, func(), error) {
|
||||
cons := flv.NewConsumer()
|
||||
run := func() {
|
||||
wr, err := rtmp.DialPublish(url)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, err = cons.WriteTo(wr)
|
||||
}
|
||||
return conn, nil
|
||||
|
||||
return cons, run, nil
|
||||
}
|
||||
|
||||
func apiHandle(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||
outputFLV(w, r)
|
||||
} else {
|
||||
inputFLV(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func outputFLV(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 := flv.NewConsumer()
|
||||
cons.Type = "HTTP-FLV consumer"
|
||||
cons.RemoteAddr = tcp.RemoteAddr(r)
|
||||
cons.UserAgent = r.UserAgent()
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "video/x-flv")
|
||||
|
||||
_, _ = cons.WriteTo(w)
|
||||
|
||||
stream.RemoveConsumer(cons)
|
||||
}
|
||||
|
||||
func inputFLV(w http.ResponseWriter, r *http.Request) {
|
||||
dst := r.URL.Query().Get("dst")
|
||||
stream := streams.Get(dst)
|
||||
if stream == nil {
|
||||
@@ -40,18 +190,12 @@ func apiHandle(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
res := &http.Response{Body: r.Body, Request: r}
|
||||
client, err := rtmp.Accept(res)
|
||||
client, err := flv.Open(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err = client.Describe(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
stream.AddProducer(client)
|
||||
|
||||
if err = client.Start(); err != nil && err != io.EOF {
|
||||
|
||||
+15
-14
@@ -26,7 +26,7 @@ func Init() {
|
||||
}
|
||||
|
||||
// default config
|
||||
conf.Mod.Listen = ":8554"
|
||||
conf.Mod.Listen = "0.0.0.0:8554"
|
||||
conf.Mod.DefaultQuery = "video&audio"
|
||||
|
||||
app.LoadConfig(&conf)
|
||||
@@ -91,19 +91,21 @@ var log zerolog.Logger
|
||||
var handlers []Handler
|
||||
var defaultMedias []*core.Media
|
||||
|
||||
func rtspHandler(url string) (core.Producer, error) {
|
||||
backchannel := true
|
||||
func rtspHandler(rawURL string) (core.Producer, error) {
|
||||
rawURL, rawQuery, _ := strings.Cut(rawURL, "#")
|
||||
|
||||
if i := strings.IndexByte(url, '#'); i > 0 {
|
||||
if url[i+1:] == "backchannel=0" {
|
||||
backchannel = false
|
||||
}
|
||||
url = url[:i]
|
||||
}
|
||||
|
||||
conn := rtsp.NewClient(url)
|
||||
conn := rtsp.NewClient(rawURL)
|
||||
conn.Backchannel = true
|
||||
conn.UserAgent = app.UserAgent
|
||||
|
||||
if rawQuery != "" {
|
||||
query := streams.ParseQuery(rawQuery)
|
||||
conn.Backchannel = query.Get("backchannel") == "1"
|
||||
conn.Media = query.Get("media")
|
||||
conn.Timeout = core.Atoi(query.Get("timeout"))
|
||||
conn.Transport = query.Get("transport")
|
||||
}
|
||||
|
||||
if log.Trace().Enabled() {
|
||||
conn.Listen(func(msg any) {
|
||||
switch msg := msg.(type) {
|
||||
@@ -121,12 +123,11 @@ func rtspHandler(url string) (core.Producer, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn.Backchannel = backchannel
|
||||
if err := conn.Describe(); err != nil {
|
||||
if !backchannel {
|
||||
if !conn.Backchannel {
|
||||
return nil, err
|
||||
}
|
||||
log.Trace().Msgf("[rtsp] describe (backchannel=%t) err: %v", backchannel, err)
|
||||
log.Trace().Msgf("[rtsp] describe (backchannel=%t) err: %v", conn.Backchannel, err)
|
||||
|
||||
// second try without backchannel, we need to reconnect
|
||||
conn.Backchannel = false
|
||||
|
||||
+3
-19
@@ -3,7 +3,6 @@ package srtp
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/pkg/srtp"
|
||||
"net"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
@@ -14,7 +13,7 @@ func Init() {
|
||||
}
|
||||
|
||||
// default config
|
||||
cfg.Mod.Listen = ":8443"
|
||||
cfg.Mod.Listen = "0.0.0.0:8443"
|
||||
|
||||
// load config from YAML
|
||||
app.LoadConfig(&cfg)
|
||||
@@ -23,23 +22,8 @@ func Init() {
|
||||
return
|
||||
}
|
||||
|
||||
log := app.GetLogger("srtp")
|
||||
|
||||
// create SRTP server (endpoint) for receiving video from HomeKit camera
|
||||
conn, err := net.ListenPacket("udp", cfg.Mod.Listen)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
|
||||
log.Info().Str("addr", cfg.Mod.Listen).Msg("[srtp] listen")
|
||||
|
||||
// run server
|
||||
go func() {
|
||||
Server = &srtp.Server{}
|
||||
if err = Server.Serve(conn); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
}()
|
||||
// create SRTP server (endpoint) for receiving video from HomeKit cameras
|
||||
Server = srtp.NewServer(cfg.Mod.Listen)
|
||||
}
|
||||
|
||||
var Server *srtp.Server
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
func (s *Stream) AddConsumer(cons core.Consumer) (err error) {
|
||||
// support for multiple simultaneous requests from different consumers
|
||||
consN := atomic.AddInt32(&s.requests, 1) - 1
|
||||
|
||||
var prodErrors []error
|
||||
var prodMedias []*core.Media
|
||||
var prods []*Producer // matched producers for consumer
|
||||
|
||||
// Step 1. Get consumer medias
|
||||
consMedias := cons.GetMedias()
|
||||
for _, consMedia := range consMedias {
|
||||
log.Trace().Msgf("[streams] check cons=%d media=%s", consN, consMedia)
|
||||
|
||||
producers:
|
||||
for prodN, prod := range s.producers {
|
||||
if err = prod.Dial(); err != nil {
|
||||
log.Trace().Err(err).Msgf("[streams] skip prod=%s", prod.url)
|
||||
prodErrors = append(prodErrors, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Step 2. Get producer medias (not tracks yet)
|
||||
for _, prodMedia := range prod.GetMedias() {
|
||||
log.Trace().Msgf("[streams] check prod=%d media=%s", prodN, prodMedia)
|
||||
prodMedias = append(prodMedias, prodMedia)
|
||||
|
||||
// Step 3. Match consumer/producer codecs list
|
||||
prodCodec, consCodec := prodMedia.MatchMedia(consMedia)
|
||||
if prodCodec == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var track *core.Receiver
|
||||
|
||||
switch prodMedia.Direction {
|
||||
case core.DirectionRecvonly:
|
||||
log.Trace().Msgf("[streams] match prod=%d => cons=%d", prodN, consN)
|
||||
|
||||
// Step 4. Get recvonly track from producer
|
||||
if track, err = prod.GetTrack(prodMedia, prodCodec); err != nil {
|
||||
log.Info().Err(err).Msg("[streams] can't get track")
|
||||
continue
|
||||
}
|
||||
// Step 5. Add track to consumer
|
||||
if err = cons.AddTrack(consMedia, consCodec, track); err != nil {
|
||||
log.Info().Err(err).Msg("[streams] can't add track")
|
||||
continue
|
||||
}
|
||||
|
||||
case core.DirectionSendonly:
|
||||
log.Trace().Msgf("[streams] match cons=%d => prod=%d", consN, prodN)
|
||||
|
||||
// Step 4. Get recvonly track from consumer (backchannel)
|
||||
if track, err = cons.(core.Producer).GetTrack(consMedia, consCodec); err != nil {
|
||||
log.Info().Err(err).Msg("[streams] can't get track")
|
||||
continue
|
||||
}
|
||||
// Step 5. Add track to producer
|
||||
if err = prod.AddTrack(prodMedia, prodCodec, track); err != nil {
|
||||
log.Info().Err(err).Msg("[streams] can't add track")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
prods = append(prods, prod)
|
||||
|
||||
if !consMedia.MatchAll() {
|
||||
break producers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stop producers if they don't have readers
|
||||
if atomic.AddInt32(&s.requests, -1) == 0 {
|
||||
s.stopProducers()
|
||||
}
|
||||
|
||||
if len(prods) == 0 {
|
||||
return formatError(consMedias, prodMedias, prodErrors)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.consumers = append(s.consumers, cons)
|
||||
s.mu.Unlock()
|
||||
|
||||
// there may be duplicates, but that's not a problem
|
||||
for _, prod := range prods {
|
||||
prod.start()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatError(consMedias, prodMedias []*core.Media, prodErrors []error) error {
|
||||
if prodMedias != nil {
|
||||
var prod, cons string
|
||||
|
||||
for _, media := range prodMedias {
|
||||
if media.Direction == core.DirectionRecvonly {
|
||||
for _, codec := range media.Codecs {
|
||||
prod = appendString(prod, codec.PrintName())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, media := range consMedias {
|
||||
if media.Direction == core.DirectionSendonly {
|
||||
for _, codec := range media.Codecs {
|
||||
cons = appendString(cons, codec.PrintName())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors.New("streams: codecs not matched: " + prod + " => " + cons)
|
||||
}
|
||||
|
||||
if prodErrors != nil {
|
||||
var text string
|
||||
|
||||
for _, err := range prodErrors {
|
||||
text = appendString(text, err.Error())
|
||||
}
|
||||
|
||||
return errors.New("streams: " + text)
|
||||
}
|
||||
|
||||
return errors.New("streams: unknown error")
|
||||
}
|
||||
|
||||
func appendString(s, elem string) string {
|
||||
if strings.Contains(s, elem) {
|
||||
return s
|
||||
}
|
||||
if len(s) == 0 {
|
||||
return elem
|
||||
}
|
||||
return s + ", " + elem
|
||||
}
|
||||
@@ -1,41 +1,97 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
type Handler func(url string) (core.Producer, error)
|
||||
|
||||
var handlers = map[string]Handler{}
|
||||
var handlersMu sync.Mutex
|
||||
|
||||
func HandleFunc(scheme string, handler Handler) {
|
||||
handlersMu.Lock()
|
||||
handlers[scheme] = handler
|
||||
handlersMu.Unlock()
|
||||
}
|
||||
|
||||
func getHandler(url string) Handler {
|
||||
i := strings.IndexByte(url, ':')
|
||||
if i <= 0 { // TODO: i < 4 ?
|
||||
return nil
|
||||
}
|
||||
handlersMu.Lock()
|
||||
defer handlersMu.Unlock()
|
||||
return handlers[url[:i]]
|
||||
}
|
||||
|
||||
func HasProducer(url string) bool {
|
||||
return getHandler(url) != nil
|
||||
if i := strings.IndexByte(url, ':'); i > 0 {
|
||||
scheme := url[:i]
|
||||
|
||||
if _, ok := handlers[scheme]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
if _, ok := redirects[scheme]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func GetProducer(url string) (core.Producer, error) {
|
||||
handler := getHandler(url)
|
||||
if handler == nil {
|
||||
return nil, fmt.Errorf("unsupported scheme: %s", url)
|
||||
if i := strings.IndexByte(url, ':'); i > 0 {
|
||||
scheme := url[:i]
|
||||
|
||||
if redirect, ok := redirects[scheme]; ok {
|
||||
location, err := redirect(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if location != "" {
|
||||
return GetProducer(location)
|
||||
}
|
||||
}
|
||||
|
||||
if handler, ok := handlers[scheme]; ok {
|
||||
return handler(url)
|
||||
}
|
||||
}
|
||||
return handler(url)
|
||||
|
||||
return nil, errors.New("streams: unsupported scheme: " + url)
|
||||
}
|
||||
|
||||
// Redirect can return: location URL or error or empty URL and error
|
||||
type Redirect func(url string) (string, error)
|
||||
|
||||
var redirects = map[string]Redirect{}
|
||||
|
||||
func RedirectFunc(scheme string, redirect Redirect) {
|
||||
redirects[scheme] = redirect
|
||||
}
|
||||
|
||||
func Location(url string) (string, error) {
|
||||
if i := strings.IndexByte(url, ':'); i > 0 {
|
||||
scheme := url[:i]
|
||||
|
||||
if redirect, ok := redirects[scheme]; ok {
|
||||
return redirect(url)
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// TODO: rework
|
||||
|
||||
type ConsumerHandler func(url string) (core.Consumer, func(), error)
|
||||
|
||||
var consumerHandlers = map[string]ConsumerHandler{}
|
||||
|
||||
func HandleConsumerFunc(scheme string, handler ConsumerHandler) {
|
||||
consumerHandlers[scheme] = handler
|
||||
}
|
||||
|
||||
func GetConsumer(url string) (core.Consumer, func(), error) {
|
||||
if i := strings.IndexByte(url, ':'); i > 0 {
|
||||
scheme := url[:i]
|
||||
|
||||
if handler, ok := consumerHandlers[scheme]; ok {
|
||||
return handler(url)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil, errors.New("streams: unsupported scheme: " + url)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ParseQuery(s string) url.Values {
|
||||
params := url.Values{}
|
||||
for _, key := range strings.Split(s, "#") {
|
||||
var value string
|
||||
i := strings.IndexByte(key, '=')
|
||||
if i > 0 {
|
||||
key, value = key[:i], key[i+1:]
|
||||
}
|
||||
params[key] = append(params[key], value)
|
||||
}
|
||||
return params
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/app/store"
|
||||
"github.com/rs/zerolog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var cfg struct {
|
||||
Mod map[string]any `yaml:"streams"`
|
||||
}
|
||||
|
||||
app.LoadConfig(&cfg)
|
||||
|
||||
log = app.GetLogger("streams")
|
||||
|
||||
for name, item := range cfg.Mod {
|
||||
streams[name] = NewStream(item)
|
||||
}
|
||||
|
||||
for name, item := range store.GetDict("streams") {
|
||||
streams[name] = NewStream(item)
|
||||
}
|
||||
|
||||
api.HandleFunc("api/streams", streamsHandler)
|
||||
}
|
||||
|
||||
func Get(name string) *Stream {
|
||||
return streams[name]
|
||||
}
|
||||
|
||||
func New(name string, source any) *Stream {
|
||||
stream := NewStream(source)
|
||||
streams[name] = stream
|
||||
return stream
|
||||
}
|
||||
|
||||
func NewTemplate(name string, source any) *Stream {
|
||||
// check if source links to some stream name from go2rtc
|
||||
if rawURL, ok := source.(string); ok {
|
||||
if u, err := url.Parse(rawURL); err == nil && u.Scheme == "rtsp" {
|
||||
if stream, ok := streams[u.Path[1:]]; ok {
|
||||
streams[name] = stream
|
||||
return stream
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return New(name, "{input}")
|
||||
}
|
||||
|
||||
func GetOrNew(src string) *Stream {
|
||||
if stream, ok := streams[src]; ok {
|
||||
return stream
|
||||
}
|
||||
|
||||
if !HasProducer(src) {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Info().Str("url", src).Msg("[streams] create new stream")
|
||||
|
||||
return New(src, src)
|
||||
}
|
||||
|
||||
func GetAll() (names []string) {
|
||||
for name := range streams {
|
||||
names = append(names, name)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
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" {
|
||||
_ = json.NewEncoder(w).Encode(streams)
|
||||
return
|
||||
}
|
||||
|
||||
// Not sure about all this API. Should be rewrited...
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
e := json.NewEncoder(w)
|
||||
e.SetIndent("", " ")
|
||||
_ = e.Encode(streams[src])
|
||||
|
||||
case "PUT":
|
||||
name := query.Get("name")
|
||||
if name == "" {
|
||||
name = src
|
||||
}
|
||||
|
||||
New(name, src)
|
||||
|
||||
case "PATCH":
|
||||
name := query.Get("name")
|
||||
if name == "" {
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// support {input} templates: https://github.com/AlexxIT/go2rtc#module-hass
|
||||
stream := Get(name)
|
||||
if stream == nil {
|
||||
stream = NewTemplate(name, src)
|
||||
}
|
||||
stream.SetSource(src)
|
||||
|
||||
case "POST":
|
||||
// with dst - redirect source to dst
|
||||
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 {
|
||||
_ = json.NewEncoder(w).Encode(stream)
|
||||
}
|
||||
} else {
|
||||
http.Error(w, "", http.StatusNotFound)
|
||||
}
|
||||
} else {
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
case "DELETE":
|
||||
delete(streams, src)
|
||||
}
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
var streams = map[string]*Stream{}
|
||||
@@ -3,10 +3,11 @@ package streams
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
type state byte
|
||||
@@ -35,6 +36,24 @@ type Producer struct {
|
||||
workerID int
|
||||
}
|
||||
|
||||
const SourceTemplate = "{input}"
|
||||
|
||||
func NewProducer(source string) *Producer {
|
||||
if strings.Contains(source, SourceTemplate) {
|
||||
return &Producer{template: source}
|
||||
}
|
||||
|
||||
return &Producer{url: source}
|
||||
}
|
||||
|
||||
func (p *Producer) SetSource(s string) {
|
||||
if p.template == "" {
|
||||
p.url = s
|
||||
} else {
|
||||
p.url = strings.Replace(p.template, SourceTemplate, s, 1)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Producer) Dial() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
@@ -112,13 +131,6 @@ func (p *Producer) AddTrack(media *core.Media, codec *core.Codec, track *core.Re
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Producer) SetSource(s string) {
|
||||
if p.template == "" {
|
||||
p.template = p.url
|
||||
}
|
||||
p.url = strings.Replace(p.template, "{input}", s, 1)
|
||||
}
|
||||
|
||||
func (p *Producer) MarshalJSON() ([]byte, error) {
|
||||
if p.conn != nil {
|
||||
return json.Marshal(p.conn)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package streams
|
||||
|
||||
import "time"
|
||||
|
||||
func (s *Stream) Publish(url string) error {
|
||||
cons, run, err := GetConsumer(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = s.AddConsumer(cons); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
run()
|
||||
s.RemoveConsumer(cons)
|
||||
|
||||
// TODO: more smart retry
|
||||
time.Sleep(5 * time.Second)
|
||||
_ = s.Publish(url)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Publish(stream *Stream, destination any) {
|
||||
switch v := destination.(type) {
|
||||
case string:
|
||||
if err := stream.Publish(v); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
case []any:
|
||||
for _, v := range v {
|
||||
Publish(stream, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-148
@@ -2,11 +2,9 @@ package streams
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
type Stream struct {
|
||||
@@ -19,15 +17,13 @@ type Stream struct {
|
||||
func NewStream(source any) *Stream {
|
||||
switch source := source.(type) {
|
||||
case string:
|
||||
s := new(Stream)
|
||||
prod := &Producer{url: source}
|
||||
s.producers = append(s.producers, prod)
|
||||
return s
|
||||
return &Stream{
|
||||
producers: []*Producer{NewProducer(source)},
|
||||
}
|
||||
case []any:
|
||||
s := new(Stream)
|
||||
for _, source := range source {
|
||||
prod := &Producer{url: source.(string)}
|
||||
s.producers = append(s.producers, prod)
|
||||
s.producers = append(s.producers, NewProducer(source.(string)))
|
||||
}
|
||||
return s
|
||||
case map[string]any:
|
||||
@@ -39,105 +35,19 @@ func NewStream(source any) *Stream {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Stream) Sources() (sources []string) {
|
||||
for _, prod := range s.producers {
|
||||
sources = append(sources, prod.url)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Stream) SetSource(source string) {
|
||||
for _, prod := range s.producers {
|
||||
prod.SetSource(source)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Stream) AddConsumer(cons core.Consumer) (err error) {
|
||||
// support for multiple simultaneous requests from different consumers
|
||||
consN := atomic.AddInt32(&s.requests, 1) - 1
|
||||
|
||||
var statErrors []error
|
||||
var statMedias []*core.Media
|
||||
var statProds []*Producer // matched producers for consumer
|
||||
|
||||
// Step 1. Get consumer medias
|
||||
for _, consMedia := range cons.GetMedias() {
|
||||
log.Trace().Msgf("[streams] check cons=%d media=%s", consN, consMedia)
|
||||
|
||||
producers:
|
||||
for prodN, prod := range s.producers {
|
||||
if err = prod.Dial(); err != nil {
|
||||
log.Trace().Err(err).Msgf("[streams] skip prod=%s", prod.url)
|
||||
statErrors = append(statErrors, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Step 2. Get producer medias (not tracks yet)
|
||||
for _, prodMedia := range prod.GetMedias() {
|
||||
log.Trace().Msgf("[streams] check prod=%d media=%s", prodN, prodMedia)
|
||||
statMedias = append(statMedias, prodMedia)
|
||||
|
||||
// Step 3. Match consumer/producer codecs list
|
||||
prodCodec, consCodec := prodMedia.MatchMedia(consMedia)
|
||||
if prodCodec == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var track *core.Receiver
|
||||
|
||||
switch prodMedia.Direction {
|
||||
case core.DirectionRecvonly:
|
||||
log.Trace().Msgf("[streams] match prod=%d => cons=%d", prodN, consN)
|
||||
|
||||
// Step 4. Get recvonly track from producer
|
||||
if track, err = prod.GetTrack(prodMedia, prodCodec); err != nil {
|
||||
log.Info().Err(err).Msg("[streams] can't get track")
|
||||
continue
|
||||
}
|
||||
// Step 5. Add track to consumer
|
||||
if err = cons.AddTrack(consMedia, consCodec, track); err != nil {
|
||||
log.Info().Err(err).Msg("[streams] can't add track")
|
||||
continue
|
||||
}
|
||||
|
||||
case core.DirectionSendonly:
|
||||
log.Trace().Msgf("[streams] match cons=%d => prod=%d", consN, prodN)
|
||||
|
||||
// Step 4. Get recvonly track from consumer (backchannel)
|
||||
if track, err = cons.(core.Producer).GetTrack(consMedia, consCodec); err != nil {
|
||||
log.Info().Err(err).Msg("[streams] can't get track")
|
||||
continue
|
||||
}
|
||||
// Step 5. Add track to producer
|
||||
if err = prod.AddTrack(prodMedia, prodCodec, track); err != nil {
|
||||
log.Info().Err(err).Msg("[streams] can't add track")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
statProds = append(statProds, prod)
|
||||
|
||||
if !consMedia.MatchAll() {
|
||||
break producers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stop producers if they don't have readers
|
||||
if atomic.AddInt32(&s.requests, -1) == 0 {
|
||||
s.stopProducers()
|
||||
}
|
||||
|
||||
if len(statProds) == 0 {
|
||||
return formatError(statMedias, statErrors)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.consumers = append(s.consumers, cons)
|
||||
s.mu.Unlock()
|
||||
|
||||
// there may be duplicates, but that's not a problem
|
||||
for _, prod := range statProds {
|
||||
prod.start()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Stream) RemoveConsumer(cons core.Consumer) {
|
||||
_ = cons.Stop()
|
||||
|
||||
@@ -207,48 +117,3 @@ func (s *Stream) MarshalJSON() ([]byte, error) {
|
||||
|
||||
return json.Marshal(info)
|
||||
}
|
||||
|
||||
func formatError(statMedias []*core.Media, statErrors []error) error {
|
||||
var text string
|
||||
|
||||
for _, media := range statMedias {
|
||||
if media.Direction == core.DirectionRecvonly {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, codec := range media.Codecs {
|
||||
name := codec.Name
|
||||
if name == core.CodecAAC {
|
||||
name = "AAC"
|
||||
}
|
||||
if strings.Contains(text, name) {
|
||||
continue
|
||||
}
|
||||
if len(text) > 0 {
|
||||
text += ","
|
||||
}
|
||||
text += name
|
||||
}
|
||||
}
|
||||
|
||||
if text != "" {
|
||||
return errors.New(text)
|
||||
}
|
||||
|
||||
for _, err := range statErrors {
|
||||
s := err.Error()
|
||||
if strings.Contains(text, s) {
|
||||
continue
|
||||
}
|
||||
if len(text) > 0 {
|
||||
text += ","
|
||||
}
|
||||
text += s
|
||||
}
|
||||
|
||||
if text != "" {
|
||||
return errors.New(text)
|
||||
}
|
||||
|
||||
return errors.New("unknown error")
|
||||
}
|
||||
|
||||
@@ -1,19 +1,38 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTemplate(t *testing.T) {
|
||||
source1 := "does not matter"
|
||||
|
||||
stream1 := New("from_yaml", source1)
|
||||
func TestRecursion(t *testing.T) {
|
||||
// create stream with some source
|
||||
stream1 := New("from_yaml", "does not matter")
|
||||
require.Len(t, streams, 1)
|
||||
|
||||
stream2 := NewTemplate("camera.from_hass", "rtsp://localhost:8554/from_yaml?video")
|
||||
// ask another unnamed stream that links go2rtc
|
||||
query, err := url.ParseQuery("src=rtsp://localhost:8554/from_yaml?video")
|
||||
require.Nil(t, err)
|
||||
stream2 := GetOrPatch(query)
|
||||
|
||||
// check stream is same
|
||||
require.Equal(t, stream1, stream2)
|
||||
require.Equal(t, stream2.producers[0].url, source1)
|
||||
// check stream urls is same
|
||||
require.Equal(t, stream1.producers[0].url, stream2.producers[0].url)
|
||||
require.Len(t, streams, 2)
|
||||
}
|
||||
|
||||
func TestTempate(t *testing.T) {
|
||||
HandleFunc("rtsp", func(url string) (core.Producer, error) { return nil, nil }) // bypass HasProducer
|
||||
|
||||
// config from yaml
|
||||
stream1 := New("camera.from_hass", "ffmpeg:{input}#video=copy")
|
||||
// request from hass
|
||||
stream2 := Patch("camera.from_hass", "rtsp://example.com")
|
||||
|
||||
require.Equal(t, stream1, stream2)
|
||||
require.Equal(t, "ffmpeg:rtsp://example.com#video=copy", stream1.producers[0].url)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var cfg struct {
|
||||
Streams map[string]any `yaml:"streams"`
|
||||
Publish map[string]any `yaml:"publish"`
|
||||
}
|
||||
|
||||
app.LoadConfig(&cfg)
|
||||
|
||||
log = app.GetLogger("streams")
|
||||
|
||||
for name, item := range cfg.Streams {
|
||||
streams[name] = NewStream(item)
|
||||
}
|
||||
|
||||
api.HandleFunc("api/streams", streamsHandler)
|
||||
|
||||
if cfg.Publish == nil {
|
||||
return
|
||||
}
|
||||
|
||||
time.AfterFunc(time.Second, func() {
|
||||
for name, dst := range cfg.Publish {
|
||||
if stream := Get(name); stream != nil {
|
||||
Publish(stream, dst)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func Get(name string) *Stream {
|
||||
return streams[name]
|
||||
}
|
||||
|
||||
var sanitize = regexp.MustCompile(`\s`)
|
||||
|
||||
func New(name string, source string) *Stream {
|
||||
// not allow creating dynamic streams with spaces in the source
|
||||
if sanitize.MatchString(source) {
|
||||
return nil
|
||||
}
|
||||
|
||||
stream := NewStream(source)
|
||||
streams[name] = stream
|
||||
return stream
|
||||
}
|
||||
|
||||
func Patch(name string, source string) *Stream {
|
||||
streamsMu.Lock()
|
||||
defer streamsMu.Unlock()
|
||||
|
||||
// check if source links to some stream name from go2rtc
|
||||
if u, err := url.Parse(source); err == nil && u.Scheme == "rtsp" && len(u.Path) > 1 {
|
||||
rtspName := u.Path[1:]
|
||||
if stream, ok := streams[rtspName]; ok {
|
||||
if streams[name] != stream {
|
||||
// link (alias) streams[name] to streams[rtspName]
|
||||
streams[name] = stream
|
||||
}
|
||||
return stream
|
||||
}
|
||||
}
|
||||
|
||||
if stream, ok := streams[source]; ok {
|
||||
if name != source {
|
||||
// link (alias) streams[name] to streams[source]
|
||||
streams[name] = stream
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
// check if src has supported scheme
|
||||
if !HasProducer(source) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// check an existing stream with this name
|
||||
if stream, ok := streams[name]; ok {
|
||||
stream.SetSource(source)
|
||||
return stream
|
||||
}
|
||||
|
||||
// create new stream with this name
|
||||
return New(name, source)
|
||||
}
|
||||
|
||||
func GetOrPatch(query url.Values) *Stream {
|
||||
// check if src param exists
|
||||
source := query.Get("src")
|
||||
if source == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// check if src is stream name
|
||||
if stream, ok := streams[source]; ok {
|
||||
return stream
|
||||
}
|
||||
|
||||
// check if name param provided
|
||||
if name := query.Get("name"); name != "" {
|
||||
log.Info().Msgf("[streams] create new stream url=%s", source)
|
||||
|
||||
return Patch(name, source)
|
||||
}
|
||||
|
||||
// return new stream with src as name
|
||||
return Patch(source, source)
|
||||
}
|
||||
|
||||
func GetAll() (names []string) {
|
||||
for name := range streams {
|
||||
names = append(names, name)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func Streams() map[string]*Stream {
|
||||
return streams
|
||||
}
|
||||
|
||||
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
|
||||
@@ -3,17 +3,16 @@ package tapo
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/kasa"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tapo"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("tapo", handle)
|
||||
}
|
||||
streams.HandleFunc("kasa", func(url string) (core.Producer, error) {
|
||||
return kasa.Dial(url)
|
||||
})
|
||||
|
||||
func handle(url string) (core.Producer, error) {
|
||||
conn := tapo.NewClient(url)
|
||||
if err := conn.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
streams.HandleFunc("tapo", func(url string) (core.Producer, error) {
|
||||
return tapo.Dial(url)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
"github.com/pion/sdp/v3"
|
||||
"strconv"
|
||||
@@ -56,7 +56,7 @@ func GetCandidates() (candidates []string) {
|
||||
return
|
||||
}
|
||||
|
||||
func asyncCandidates(tr *api.Transport, cons *webrtc.Conn) {
|
||||
func asyncCandidates(tr *ws.Transport, cons *webrtc.Conn) {
|
||||
tr.WithContext(func(ctx map[any]any) {
|
||||
if candidates, ok := ctx["candidate"].([]string); ok {
|
||||
// process candidates that receive before this moment
|
||||
@@ -74,7 +74,7 @@ func asyncCandidates(tr *api.Transport, cons *webrtc.Conn) {
|
||||
|
||||
for _, candidate := range GetCandidates() {
|
||||
log.Trace().Str("candidate", candidate).Msg("[webrtc] config")
|
||||
tr.Write(&api.Message{Type: "webrtc/candidate", Value: candidate})
|
||||
tr.Write(&ws.Message{Type: "webrtc/candidate", Value: candidate})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ func syncCanditates(answer string) (string, error) {
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func candidateHandler(tr *api.Transport, msg *api.Message) error {
|
||||
func candidateHandler(tr *ws.Transport, msg *ws.Message) error {
|
||||
// process incoming candidate in sync function
|
||||
tr.WithContext(func(ctx map[any]any) {
|
||||
candidate := msg.String()
|
||||
|
||||
+101
-40
@@ -1,44 +1,75 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
"github.com/gorilla/websocket"
|
||||
pion "github.com/pion/webrtc/v3"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func streamsHandler(url string) (core.Producer, error) {
|
||||
url = url[7:]
|
||||
if i := strings.Index(url, "://"); i > 0 {
|
||||
switch url[:i] {
|
||||
// streamsHandler supports:
|
||||
// 1. WHEP: webrtc:http://192.168.1.123:1984/api/webrtc?src=camera1
|
||||
// 2. go2rtc: webrtc:ws://192.168.1.123:1984/api/ws?src=camera1
|
||||
// 3. Wyze: webrtc:http://192.168.1.123:5000/signaling/camera1?kvs#format=wyze
|
||||
// 4. Kinesis: webrtc:wss://...amazonaws.com/?...#format=kinesis#client_id=...#ice_servers=[{...},{...}]
|
||||
func streamsHandler(rawURL string) (core.Producer, error) {
|
||||
var query url.Values
|
||||
if i := strings.IndexByte(rawURL, '#'); i > 0 {
|
||||
query = streams.ParseQuery(rawURL[i+1:])
|
||||
rawURL = rawURL[:i]
|
||||
}
|
||||
|
||||
rawURL = rawURL[7:] // remove webrtc:
|
||||
if i := strings.IndexByte(rawURL, ':'); i > 0 {
|
||||
scheme := rawURL[:i]
|
||||
format := query.Get("format")
|
||||
|
||||
switch scheme {
|
||||
case "ws", "wss":
|
||||
return asyncClient(url)
|
||||
if format == "kinesis" {
|
||||
// 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")
|
||||
} else if format == "openipc" {
|
||||
return openIPCClient(rawURL, query)
|
||||
} else {
|
||||
return go2rtcClient(rawURL)
|
||||
}
|
||||
|
||||
case "http", "https":
|
||||
return syncClient(url)
|
||||
if format == "wyze" {
|
||||
// https://github.com/mrlt8/docker-wyze-bridge
|
||||
return wyzeClient(rawURL)
|
||||
} else {
|
||||
return whepClient(rawURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, errors.New("unsupported url: " + url)
|
||||
return nil, errors.New("unsupported url: " + rawURL)
|
||||
}
|
||||
|
||||
// asyncClient can connect only to go2rtc server
|
||||
// go2rtcClient can connect only to go2rtc server
|
||||
// ex: ws://localhost:1984/api/ws?src=camera1
|
||||
func asyncClient(url string) (core.Producer, error) {
|
||||
func go2rtcClient(url string) (core.Producer, error) {
|
||||
// 1. Connect to signalign server
|
||||
ws, _, err := websocket.DefaultDialer.Dial(url, nil)
|
||||
conn, _, err := Dial(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = ws.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// close websocket when we ready return Producer or connection error
|
||||
defer conn.Close()
|
||||
|
||||
// 2. Create PeerConnection
|
||||
pc, err := PeerConnection(true)
|
||||
@@ -47,22 +78,27 @@ func asyncClient(url string) (core.Producer, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var sendOffer core.Waiter
|
||||
// waiter will wait PC error or WS error or nil (connection OK)
|
||||
var connState core.Waiter
|
||||
|
||||
prod := webrtc.NewConn(pc)
|
||||
prod.Desc = "WebRTC/WebSocket async"
|
||||
prod.Mode = core.ModeActiveProducer
|
||||
prod.Listen(func(msg any) {
|
||||
switch msg := msg.(type) {
|
||||
case pion.PeerConnectionState:
|
||||
_ = ws.Close()
|
||||
|
||||
case *pion.ICECandidate:
|
||||
sendOffer.Wait()
|
||||
|
||||
s := msg.ToJSON().Candidate
|
||||
log.Trace().Str("candidate", s).Msg("[webrtc] local")
|
||||
_ = ws.WriteJSON(&api.Message{Type: "webrtc/candidate", Value: s})
|
||||
_ = conn.WriteJSON(&ws.Message{Type: "webrtc/candidate", Value: s})
|
||||
|
||||
case pion.PeerConnectionState:
|
||||
switch msg {
|
||||
case pion.PeerConnectionStateConnecting:
|
||||
case pion.PeerConnectionStateConnected:
|
||||
connState.Done(nil)
|
||||
default:
|
||||
connState.Done(errors.New("webrtc: " + msg.String()))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -79,15 +115,13 @@ func asyncClient(url string) (core.Producer, error) {
|
||||
}
|
||||
|
||||
// 4. Send offer
|
||||
msg := &api.Message{Type: "webrtc/offer", Value: offer}
|
||||
if err = ws.WriteJSON(msg); err != nil {
|
||||
msg := &ws.Message{Type: "webrtc/offer", Value: offer}
|
||||
if err = conn.WriteJSON(msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sendOffer.Done()
|
||||
|
||||
// 5. Get answer
|
||||
if err = ws.ReadJSON(msg); err != nil {
|
||||
if err = conn.ReadJSON(msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -102,13 +136,12 @@ func asyncClient(url string) (core.Producer, error) {
|
||||
|
||||
// 6. Continue to receiving candidates
|
||||
go func() {
|
||||
var err error
|
||||
|
||||
for {
|
||||
// receive data from remote
|
||||
msg := new(api.Message)
|
||||
if err = ws.ReadJSON(msg); err != nil {
|
||||
if cerr, ok := err.(*websocket.CloseError); ok {
|
||||
log.Trace().Err(err).Caller().Msgf("[webrtc] ws code=%d", cerr)
|
||||
}
|
||||
var msg ws.Message
|
||||
if err = conn.ReadJSON(&msg); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -120,15 +153,19 @@ func asyncClient(url string) (core.Producer, error) {
|
||||
}
|
||||
}
|
||||
|
||||
_ = ws.Close()
|
||||
connState.Done(err)
|
||||
}()
|
||||
|
||||
if err = connState.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return prod, nil
|
||||
}
|
||||
|
||||
// syncClient - support WebRTC-HTTP Egress Protocol (WHEP)
|
||||
// whepClient - support WebRTC-HTTP Egress Protocol (WHEP)
|
||||
// ex: http://localhost:1984/api/webrtc?src=camera1
|
||||
func syncClient(url string) (core.Producer, error) {
|
||||
func whepClient(url string) (core.Producer, error) {
|
||||
// 2. Create PeerConnection
|
||||
pc, err := PeerConnection(true)
|
||||
if err != nil {
|
||||
@@ -176,3 +213,27 @@ func syncClient(url string) (core.Producer, error) {
|
||||
|
||||
return prod, nil
|
||||
}
|
||||
|
||||
// Dial - websocket.Dial with Basic auth support
|
||||
func Dial(rawURL string) (*websocket.Conn, *http.Response, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if u.User == nil {
|
||||
return websocket.DefaultDialer.Dial(rawURL, nil)
|
||||
}
|
||||
|
||||
user := u.User.Username()
|
||||
pass, _ := u.User.Password()
|
||||
u.User = nil
|
||||
|
||||
header := http.Header{
|
||||
"Authorization": []string{
|
||||
"Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass)),
|
||||
},
|
||||
}
|
||||
|
||||
return websocket.DefaultDialer.Dial(u.String(), header)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
"github.com/gorilla/websocket"
|
||||
pion "github.com/pion/webrtc/v3"
|
||||
)
|
||||
|
||||
type kinesisRequest struct {
|
||||
Action string `json:"action"`
|
||||
ClientID string `json:"recipientClientId"`
|
||||
Payload []byte `json:"messagePayload"`
|
||||
}
|
||||
|
||||
func (k kinesisRequest) String() string {
|
||||
return fmt.Sprintf("action=%s, payload=%s", k.Action, k.Payload)
|
||||
}
|
||||
|
||||
type kinesisResponse struct {
|
||||
Payload []byte `json:"messagePayload"`
|
||||
Type string `json:"messageType"`
|
||||
}
|
||||
|
||||
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) {
|
||||
// 1. Connect to signalign server
|
||||
conn, _, err := websocket.DefaultDialer.Dial(rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. Load ICEServers from query param (base64 json)
|
||||
conf := pion.Configuration{}
|
||||
|
||||
if s := query.Get("ice_servers"); s != "" {
|
||||
conf.ICEServers, err = webrtc.UnmarshalICEServers([]byte(s))
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
|
||||
// close websocket when we ready return Producer or connection error
|
||||
defer conn.Close()
|
||||
|
||||
// 3. Create Peer Connection
|
||||
api, err := webrtc.NewAPI("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pc, err := api.NewPeerConnection(conf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// protect from sending ICE candidate before Offer
|
||||
var sendOffer core.Waiter
|
||||
|
||||
// protect from blocking on errors
|
||||
defer sendOffer.Done(nil)
|
||||
|
||||
// waiter will wait PC error or WS error or nil (connection OK)
|
||||
var connState core.Waiter
|
||||
|
||||
req := kinesisRequest{
|
||||
ClientID: query.Get("client_id"),
|
||||
}
|
||||
|
||||
prod := webrtc.NewConn(pc)
|
||||
prod.Desc = desc
|
||||
prod.Mode = core.ModeActiveProducer
|
||||
prod.Listen(func(msg any) {
|
||||
switch msg := msg.(type) {
|
||||
case *pion.ICECandidate:
|
||||
_ = sendOffer.Wait()
|
||||
|
||||
req.Action = "ICE_CANDIDATE"
|
||||
req.Payload, _ = json.Marshal(msg.ToJSON())
|
||||
if err = conn.WriteJSON(&req); err != nil {
|
||||
connState.Done(err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Trace().Msgf("[webrtc] kinesis send: %s", req)
|
||||
|
||||
case pion.PeerConnectionState:
|
||||
switch msg {
|
||||
case pion.PeerConnectionStateConnecting:
|
||||
case pion.PeerConnectionStateConnected:
|
||||
connState.Done(nil)
|
||||
default:
|
||||
connState.Done(errors.New("webrtc: " + msg.String()))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
medias := []*core.Media{
|
||||
{Kind: core.KindVideo, Direction: core.DirectionRecvonly},
|
||||
{Kind: core.KindAudio, Direction: core.DirectionRecvonly},
|
||||
}
|
||||
|
||||
// 4. Create offer
|
||||
offer, err := prod.CreateOffer(medias)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 5. Send offer
|
||||
req.Action = "SDP_OFFER"
|
||||
req.Payload, _ = json.Marshal(pion.SessionDescription{
|
||||
Type: pion.SDPTypeOffer,
|
||||
SDP: offer,
|
||||
})
|
||||
if err = conn.WriteJSON(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Trace().Msgf("[webrtc] kinesis send: %s", req)
|
||||
|
||||
sendOffer.Done(nil)
|
||||
|
||||
go func() {
|
||||
var err error
|
||||
|
||||
// will be closed when conn will be closed
|
||||
for {
|
||||
var res kinesisResponse
|
||||
if err = conn.ReadJSON(&res); err != nil {
|
||||
// some buggy messages from Amazon servers
|
||||
if errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
log.Trace().Msgf("[webrtc] kinesis recv: %s", res)
|
||||
|
||||
switch res.Type {
|
||||
case "SDP_ANSWER":
|
||||
// 6. Get answer
|
||||
var sd pion.SessionDescription
|
||||
if err = json.Unmarshal(res.Payload, &sd); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if err = prod.SetAnswer(sd.SDP); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
case "ICE_CANDIDATE":
|
||||
// 7. Continue to receiving candidates
|
||||
var ci pion.ICECandidateInit
|
||||
if err = json.Unmarshal(res.Payload, &ci); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if err = prod.AddCandidate(ci.Candidate); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connState.Done(err)
|
||||
}()
|
||||
|
||||
if err = connState.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return prod, nil
|
||||
}
|
||||
|
||||
type wyzeKVS struct {
|
||||
ClientId string `json:"ClientId"`
|
||||
Cam string `json:"cam"`
|
||||
Result string `json:"result"`
|
||||
Servers json.RawMessage `json:"servers"`
|
||||
URL string `json:"signalingUrl"`
|
||||
}
|
||||
|
||||
func wyzeClient(rawURL string) (core.Producer, error) {
|
||||
client := http.Client{Timeout: 5 * time.Second}
|
||||
res, err := client.Get(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var kvs wyzeKVS
|
||||
if err = json.Unmarshal(b, &kvs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if kvs.Result != "ok" {
|
||||
return nil, errors.New("wyse: wrong result: " + kvs.Result)
|
||||
}
|
||||
|
||||
query := url.Values{
|
||||
"client_id": []string{kvs.ClientId},
|
||||
"ice_servers": []string{string(kvs.Servers)},
|
||||
}
|
||||
|
||||
return kinesisClient(kvs.URL, query, "WebRTC/Wyze")
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/url"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
"github.com/gorilla/websocket"
|
||||
pion "github.com/pion/webrtc/v3"
|
||||
)
|
||||
|
||||
func openIPCClient(rawURL string, query url.Values) (core.Producer, error) {
|
||||
// 1. Connect to signalign server
|
||||
conn, _, err := websocket.DefaultDialer.Dial(rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. Load ICEServers from query param (base64 json)
|
||||
var conf pion.Configuration
|
||||
|
||||
if s := query.Get("ice_servers"); s != "" {
|
||||
conf.ICEServers, err = webrtc.UnmarshalICEServers([]byte(s))
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
|
||||
// close websocket when we ready return Producer or connection error
|
||||
defer conn.Close()
|
||||
|
||||
// 3. Create Peer Connection
|
||||
api, err := webrtc.NewAPI("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pc, err := api.NewPeerConnection(conf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// protect from sending ICE candidate before Offer
|
||||
var sendAnswer core.Waiter
|
||||
|
||||
// protect from blocking on errors
|
||||
defer sendAnswer.Done(nil)
|
||||
|
||||
// waiter will wait PC error or WS error or nil (connection OK)
|
||||
var connState core.Waiter
|
||||
|
||||
prod := webrtc.NewConn(pc)
|
||||
prod.Desc = "WebRTC/OpenIPC"
|
||||
prod.Mode = core.ModeActiveProducer
|
||||
prod.Listen(func(msg any) {
|
||||
switch msg := msg.(type) {
|
||||
case *pion.ICECandidate:
|
||||
_ = sendAnswer.Wait()
|
||||
|
||||
req := openIPCReq{
|
||||
Data: msg.ToJSON().Candidate,
|
||||
Req: "candidate",
|
||||
}
|
||||
if err = conn.WriteJSON(&req); err != nil {
|
||||
connState.Done(err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Trace().Msgf("[webrtc] openipc send: %s", req)
|
||||
|
||||
case pion.PeerConnectionState:
|
||||
switch msg {
|
||||
case pion.PeerConnectionStateConnecting:
|
||||
case pion.PeerConnectionStateConnected:
|
||||
connState.Done(nil)
|
||||
default:
|
||||
connState.Done(errors.New("webrtc: " + msg.String()))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
go func() {
|
||||
var err error
|
||||
|
||||
// will be closed when conn will be closed
|
||||
for err == nil {
|
||||
var rep openIPCReply
|
||||
if err = conn.ReadJSON(&rep); err != nil {
|
||||
// some buggy messages from Amazon servers
|
||||
if errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
log.Trace().Msgf("[webrtc] openipc recv: %s", rep)
|
||||
|
||||
switch rep.Reply {
|
||||
case "webrtc_answer":
|
||||
// 6. Get answer
|
||||
var sd pion.SessionDescription
|
||||
if err = json.Unmarshal(rep.Data, &sd); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if err = prod.SetOffer(sd.SDP); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
var answer string
|
||||
if answer, err = prod.GetAnswer(); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
req := openIPCReq{Data: answer, Req: "answer"}
|
||||
if err = conn.WriteJSON(req); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
log.Trace().Msgf("[webrtc] kinesis send: %s", req)
|
||||
|
||||
sendAnswer.Done(nil)
|
||||
|
||||
case "webrtc_candidate":
|
||||
// 7. Continue to receiving candidates
|
||||
var ci pion.ICECandidateInit
|
||||
if err = json.Unmarshal(rep.Data, &ci); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if err = prod.AddCandidate(ci.Candidate); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connState.Done(err)
|
||||
}()
|
||||
|
||||
if err = connState.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return prod, nil
|
||||
}
|
||||
|
||||
type openIPCReply struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
Reply string `json:"reply"`
|
||||
}
|
||||
|
||||
func (r openIPCReply) String() string {
|
||||
b, _ := json.Marshal(r)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
type openIPCReq struct {
|
||||
Data string `json:"data"`
|
||||
Req string `json:"req"`
|
||||
}
|
||||
|
||||
func (r openIPCReq) String() string {
|
||||
b, _ := json.Marshal(r)
|
||||
return string(b)
|
||||
}
|
||||
@@ -2,15 +2,17 @@ package webrtc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
pion "github.com/pion/webrtc/v3"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
pion "github.com/pion/webrtc/v3"
|
||||
)
|
||||
|
||||
const MimeSDP = "application/sdp"
|
||||
@@ -125,6 +127,8 @@ func outputWebRTC(w http.ResponseWriter, r *http.Request) {
|
||||
_, err = w.Write([]byte(answer))
|
||||
|
||||
default:
|
||||
w.Header().Set("Content-Type", mediaType)
|
||||
|
||||
_, err = w.Write([]byte(answer))
|
||||
}
|
||||
|
||||
@@ -138,7 +142,8 @@ func inputWebRTC(w http.ResponseWriter, r *http.Request) {
|
||||
dst := r.URL.Query().Get("dst")
|
||||
stream := streams.Get(dst)
|
||||
if stream == nil {
|
||||
stream = streams.New(dst, nil)
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Get offer
|
||||
|
||||
@@ -2,14 +2,16 @@ package webrtc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
pion "github.com/pion/webrtc/v3"
|
||||
"github.com/rs/zerolog"
|
||||
"net"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
@@ -21,7 +23,7 @@ func Init() {
|
||||
} `yaml:"webrtc"`
|
||||
}
|
||||
|
||||
cfg.Mod.Listen = ":8555/tcp"
|
||||
cfg.Mod.Listen = "0.0.0.0:8555/tcp"
|
||||
cfg.Mod.IceServers = []pion.ICEServer{
|
||||
{URLs: []string{"stun:stun.l.google.com:19302"}},
|
||||
}
|
||||
@@ -68,9 +70,9 @@ func Init() {
|
||||
}
|
||||
|
||||
// async WebRTC server (two API versions)
|
||||
api.HandleWS("webrtc", asyncHandler)
|
||||
api.HandleWS("webrtc/offer", asyncHandler)
|
||||
api.HandleWS("webrtc/candidate", candidateHandler)
|
||||
ws.HandleFunc("webrtc", asyncHandler)
|
||||
ws.HandleFunc("webrtc/offer", asyncHandler)
|
||||
ws.HandleFunc("webrtc/candidate", candidateHandler)
|
||||
|
||||
// sync WebRTC server (two API versions)
|
||||
api.HandleFunc("api/webrtc", syncHandler)
|
||||
@@ -84,13 +86,13 @@ var log zerolog.Logger
|
||||
|
||||
var PeerConnection func(active bool) (*pion.PeerConnection, error)
|
||||
|
||||
func asyncHandler(tr *api.Transport, msg *api.Message) error {
|
||||
func asyncHandler(tr *ws.Transport, msg *ws.Message) error {
|
||||
var stream *streams.Stream
|
||||
var mode core.Mode
|
||||
|
||||
query := tr.Request.URL.Query()
|
||||
if name := query.Get("src"); name != "" {
|
||||
stream = streams.GetOrNew(name)
|
||||
stream = streams.GetOrPatch(query)
|
||||
mode = core.ModePassiveConsumer
|
||||
log.Debug().Str("src", name).Msg("[webrtc] new consumer")
|
||||
} else if name = query.Get("dst"); name != "" {
|
||||
@@ -112,6 +114,9 @@ func asyncHandler(tr *api.Transport, msg *api.Message) error {
|
||||
|
||||
var sendAnswer core.Waiter
|
||||
|
||||
// protect from blocking on errors
|
||||
defer sendAnswer.Done(nil)
|
||||
|
||||
conn := webrtc.NewConn(pc)
|
||||
conn.Desc = "WebRTC/WebSocket async"
|
||||
conn.Mode = mode
|
||||
@@ -130,11 +135,11 @@ func asyncHandler(tr *api.Transport, msg *api.Message) error {
|
||||
}
|
||||
|
||||
case *pion.ICECandidate:
|
||||
sendAnswer.Wait()
|
||||
_ = sendAnswer.Wait()
|
||||
|
||||
s := msg.ToJSON().Candidate
|
||||
log.Trace().Str("candidate", s).Msg("[webrtc] local")
|
||||
tr.Write(&api.Message{Type: "webrtc/candidate", Value: s})
|
||||
tr.Write(&ws.Message{Type: "webrtc/candidate", Value: s})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -179,12 +184,12 @@ func asyncHandler(tr *api.Transport, msg *api.Message) error {
|
||||
|
||||
if apiV2 {
|
||||
desc := pion.SessionDescription{Type: pion.SDPTypeAnswer, SDP: answer}
|
||||
tr.Write(&api.Message{Type: "webrtc", Value: desc})
|
||||
tr.Write(&ws.Message{Type: "webrtc", Value: desc})
|
||||
} else {
|
||||
tr.Write(&api.Message{Type: "webrtc/answer", Value: answer})
|
||||
tr.Write(&ws.Message{Type: "webrtc/answer", Value: answer})
|
||||
}
|
||||
|
||||
sendAnswer.Done()
|
||||
sendAnswer.Done(nil)
|
||||
|
||||
asyncCandidates(tr, conn)
|
||||
|
||||
@@ -3,6 +3,9 @@ package webtorrent
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
@@ -10,8 +13,6 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webtorrent"
|
||||
"github.com/rs/zerolog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
@@ -110,13 +111,13 @@ func apiHandle(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
} else {
|
||||
// response all shares
|
||||
var items []api.Stream
|
||||
var items []*api.Source
|
||||
for src, share := range shares {
|
||||
pwd := srv.GetSharePwd(share)
|
||||
source := fmt.Sprintf("webtorrent:?share=%s&pwd=%s", share, pwd)
|
||||
items = append(items, api.Stream{Name: src, URL: source})
|
||||
items = append(items, &api.Source{ID: src, URL: source})
|
||||
}
|
||||
api.ResponseStreams(w, items)
|
||||
api.ResponseSources(w, items)
|
||||
}
|
||||
|
||||
case "POST":
|
||||
@@ -141,7 +142,7 @@ func apiHandle(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
data := fmt.Sprintf(`{"share":%q,"pwd":%q}`, share, pwd)
|
||||
_, _ = w.Write([]byte(data))
|
||||
api.Response(w, data, api.MimeJSON)
|
||||
|
||||
case "DELETE":
|
||||
if ok {
|
||||
|
||||
@@ -2,7 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/bubble"
|
||||
"github.com/AlexxIT/go2rtc/internal/debug"
|
||||
"github.com/AlexxIT/go2rtc/internal/dvrip"
|
||||
"github.com/AlexxIT/go2rtc/internal/echo"
|
||||
@@ -17,6 +19,7 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/internal/mjpeg"
|
||||
"github.com/AlexxIT/go2rtc/internal/mp4"
|
||||
"github.com/AlexxIT/go2rtc/internal/mpegts"
|
||||
"github.com/AlexxIT/go2rtc/internal/nest"
|
||||
"github.com/AlexxIT/go2rtc/internal/ngrok"
|
||||
"github.com/AlexxIT/go2rtc/internal/onvif"
|
||||
"github.com/AlexxIT/go2rtc/internal/roborock"
|
||||
@@ -27,46 +30,62 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/internal/tapo"
|
||||
"github.com/AlexxIT/go2rtc/internal/webrtc"
|
||||
"github.com/AlexxIT/go2rtc/internal/webtorrent"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app.Init() // init config and logs
|
||||
api.Init() // init HTTP API server
|
||||
streams.Init() // load streams list
|
||||
onvif.Init()
|
||||
app.Version = "1.8.0"
|
||||
|
||||
rtsp.Init() // add support RTSP client and RTSP server
|
||||
rtmp.Init() // add support RTMP client
|
||||
exec.Init() // add support exec scheme (depends on RTSP server)
|
||||
ffmpeg.Init() // add support ffmpeg scheme (depends on exec scheme)
|
||||
hass.Init() // add support hass scheme
|
||||
echo.Init()
|
||||
ivideon.Init()
|
||||
http.Init()
|
||||
dvrip.Init()
|
||||
tapo.Init()
|
||||
isapi.Init()
|
||||
mpegts.Init()
|
||||
roborock.Init()
|
||||
// 1. Core modules: app, api/ws, streams
|
||||
|
||||
srtp.Init()
|
||||
homekit.Init()
|
||||
app.Init() // init config and logs
|
||||
|
||||
webrtc.Init()
|
||||
mp4.Init()
|
||||
hls.Init()
|
||||
mjpeg.Init()
|
||||
api.Init() // init API before all others
|
||||
ws.Init() // init WS API endpoint
|
||||
|
||||
webtorrent.Init()
|
||||
ngrok.Init()
|
||||
debug.Init()
|
||||
streams.Init() // streams module
|
||||
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigs
|
||||
// 2. Main sources and servers
|
||||
|
||||
println("exit OK")
|
||||
rtsp.Init() // rtsp source, RTSP server
|
||||
webrtc.Init() // webrtc source, WebRTC server
|
||||
|
||||
// 3. Main API
|
||||
|
||||
mp4.Init() // MP4 API
|
||||
hls.Init() // HLS API
|
||||
mjpeg.Init() // MJPEG API
|
||||
|
||||
// 4. Other sources and servers
|
||||
|
||||
hass.Init() // hass source, Hass API server
|
||||
onvif.Init() // onvif source, ONVIF API server
|
||||
webtorrent.Init() // webtorrent source, WebTorrent module
|
||||
|
||||
// 5. Other sources
|
||||
|
||||
rtmp.Init() // rtmp source
|
||||
exec.Init() // exec source
|
||||
ffmpeg.Init() // ffmpeg source
|
||||
echo.Init() // echo source
|
||||
ivideon.Init() // ivideon source
|
||||
http.Init() // http/tcp source
|
||||
dvrip.Init() // dvrip source
|
||||
tapo.Init() // tapo source
|
||||
isapi.Init() // isapi source
|
||||
mpegts.Init() // mpegts passive source
|
||||
roborock.Init() // roborock source
|
||||
homekit.Init() // homekit source
|
||||
nest.Init() // nest source
|
||||
bubble.Init() // bubble source
|
||||
|
||||
// 6. Helper modules
|
||||
|
||||
ngrok.Init() // Ngrok module
|
||||
srtp.Init() // SRTP server
|
||||
debug.Init() // debug API
|
||||
|
||||
// 7. Go
|
||||
|
||||
shell.RunUntilSignal()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"eslint": "^8.44.0",
|
||||
"eslint-plugin-html": "^7.1.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2017,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"no-var": "error",
|
||||
"no-undef": "error",
|
||||
"no-unused-vars": "warn",
|
||||
"prefer-const": "error",
|
||||
"quotes": [
|
||||
"error",
|
||||
"single"
|
||||
],
|
||||
"semi": "error"
|
||||
},
|
||||
"plugins": [
|
||||
"html"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"*.html"
|
||||
],
|
||||
"parserOptions": {
|
||||
"sourceType": "script"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+12
-12
@@ -1,17 +1,17 @@
|
||||
## AAC-LD and AAC-ELD
|
||||
|
||||
Codec | Rate | QuickTime | ffmpeg | VLC
|
||||
------|------|-----------|--------|----
|
||||
AAC-LD | 8000 | yes | no | no
|
||||
AAC-LD | 16000 | yes | no | no
|
||||
AAC-LD | 22050 | yes | yes | no
|
||||
AAC-LD | 24000 | yes | yes | no
|
||||
AAC-LD | 32000 | yes | yes | no
|
||||
AAC-ELD | 8000 | yes | no | no
|
||||
AAC-ELD | 16000 | yes | no | no
|
||||
AAC-ELD | 22050 | yes | yes | yes
|
||||
AAC-ELD | 24000 | yes | yes | yes
|
||||
AAC-ELD | 32000 | yes | yes | yes
|
||||
| Codec | Rate | QuickTime | ffmpeg | VLC |
|
||||
|---------|-------|-----------|--------|-----|
|
||||
| AAC-LD | 8000 | yes | no | no |
|
||||
| AAC-LD | 16000 | yes | no | no |
|
||||
| AAC-LD | 22050 | yes | yes | no |
|
||||
| AAC-LD | 24000 | yes | yes | no |
|
||||
| AAC-LD | 32000 | yes | yes | no |
|
||||
| AAC-ELD | 8000 | yes | no | no |
|
||||
| AAC-ELD | 16000 | yes | no | no |
|
||||
| AAC-ELD | 22050 | yes | yes | yes |
|
||||
| AAC-ELD | 24000 | yes | yes | yes |
|
||||
| AAC-ELD | 32000 | yes | yes | yes |
|
||||
|
||||
## Useful links
|
||||
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package aac
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/bits"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
const (
|
||||
TypeAACMain = 1
|
||||
TypeAACLC = 2 // Low Complexity
|
||||
TypeAACLD = 23 // Low Delay (48000, 44100, 32000, 24000, 22050)
|
||||
TypeESCAPE = 31
|
||||
TypeAACELD = 39 // Enhanced Low Delay
|
||||
|
||||
AUTime = 1024
|
||||
|
||||
// FMTP streamtype=5 - audio stream
|
||||
FMTP = "streamtype=5;profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config="
|
||||
)
|
||||
|
||||
var sampleRates = [16]uint32{
|
||||
96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350,
|
||||
0, 0, 0, // protection from request sampleRates[15]
|
||||
}
|
||||
|
||||
func ConfigToCodec(conf []byte) *core.Codec {
|
||||
// https://en.wikipedia.org/wiki/MPEG-4_Part_3#MPEG-4_Audio_Object_Types
|
||||
rd := bits.NewReader(conf)
|
||||
|
||||
codec := &core.Codec{
|
||||
FmtpLine: FMTP + hex.EncodeToString(conf),
|
||||
PayloadType: core.PayloadTypeRAW,
|
||||
}
|
||||
|
||||
objType := rd.ReadBits(5)
|
||||
if objType == TypeESCAPE {
|
||||
objType = 32 + rd.ReadBits(6)
|
||||
}
|
||||
|
||||
switch objType {
|
||||
case TypeAACLC, TypeAACLD, TypeAACELD:
|
||||
codec.Name = core.CodecAAC
|
||||
default:
|
||||
codec.Name = fmt.Sprintf("AAC-%X", objType)
|
||||
}
|
||||
|
||||
if sampleRateIdx := rd.ReadBits8(4); sampleRateIdx < 0x0F {
|
||||
codec.ClockRate = sampleRates[sampleRateIdx]
|
||||
} else {
|
||||
codec.ClockRate = rd.ReadBits(24)
|
||||
}
|
||||
|
||||
codec.Channels = rd.ReadBits16(4)
|
||||
|
||||
return codec
|
||||
}
|
||||
|
||||
func DecodeConfig(b []byte) (objType, sampleFreqIdx, channels byte, sampleRate uint32) {
|
||||
rd := bits.NewReader(b)
|
||||
|
||||
objType = rd.ReadBits8(5)
|
||||
if objType == 0b11111 {
|
||||
objType = 32 + rd.ReadBits8(6)
|
||||
}
|
||||
|
||||
sampleFreqIdx = rd.ReadBits8(4)
|
||||
if sampleFreqIdx == 0b1111 {
|
||||
sampleRate = rd.ReadBits(24)
|
||||
}
|
||||
|
||||
channels = rd.ReadBits8(4)
|
||||
return
|
||||
}
|
||||
|
||||
func EncodeConfig(objType byte, sampleRate uint32, channels byte, shortFrame bool) []byte {
|
||||
wr := bits.NewWriter(nil)
|
||||
|
||||
if objType < TypeESCAPE {
|
||||
wr.WriteBits8(objType, 5)
|
||||
} else {
|
||||
wr.WriteBits8(TypeESCAPE, 5)
|
||||
wr.WriteBits8(objType-32, 6)
|
||||
}
|
||||
|
||||
i := indexUint32(sampleRates[:], sampleRate)
|
||||
if i >= 0 {
|
||||
wr.WriteBits8(byte(i), 4)
|
||||
} else {
|
||||
wr.WriteBits8(0xF, 4)
|
||||
wr.WriteBits(sampleRate, 24)
|
||||
}
|
||||
|
||||
wr.WriteBits8(channels, 4)
|
||||
|
||||
switch objType {
|
||||
case TypeAACLD:
|
||||
// https://github.com/FFmpeg/FFmpeg/blob/67d392b97941bb51fb7af3a3c9387f5ab895fa46/libavcodec/aacdec_template.c#L841
|
||||
wr.WriteBool(shortFrame)
|
||||
wr.WriteBit(0) // dependsOnCoreCoder
|
||||
wr.WriteBit(0) // extension_flag
|
||||
wr.WriteBits8(0, 2) // ep_config
|
||||
case TypeAACELD:
|
||||
// https://github.com/FFmpeg/FFmpeg/blob/67d392b97941bb51fb7af3a3c9387f5ab895fa46/libavcodec/aacdec_template.c#L922
|
||||
wr.WriteBool(shortFrame)
|
||||
wr.WriteBits8(0, 3) // res_flags
|
||||
wr.WriteBit(0) // ldSbrPresentFlag
|
||||
wr.WriteBits8(0, 4) // ELDEXT_TERM
|
||||
wr.WriteBits8(0, 2) // ep_config
|
||||
}
|
||||
|
||||
return wr.Bytes()
|
||||
}
|
||||
|
||||
func indexUint32(s []uint32, v uint32) int {
|
||||
for i := range s {
|
||||
if v == s[i] {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package aac
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConfigToCodec(t *testing.T) {
|
||||
s := "profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=F8EC3000"
|
||||
s = core.Between(s, "config=", ";")
|
||||
src, err := hex.DecodeString(s)
|
||||
require.Nil(t, err)
|
||||
|
||||
codec := ConfigToCodec(src)
|
||||
require.Equal(t, core.CodecAAC, codec.Name)
|
||||
require.Equal(t, uint32(24000), codec.ClockRate)
|
||||
require.Equal(t, uint16(1), codec.Channels)
|
||||
|
||||
dst := EncodeConfig(TypeAACELD, 24000, 1, true)
|
||||
require.Equal(t, src, dst)
|
||||
}
|
||||
|
||||
func TestADTS(t *testing.T) {
|
||||
// FFmpeg MPEG-TS AAC (one packet)
|
||||
s := "fff15080021ffc210049900219002380fff15080021ffc212049900219002380" //...
|
||||
src, err := hex.DecodeString(s)
|
||||
require.Nil(t, err)
|
||||
|
||||
codec := ADTSToCodec(src)
|
||||
require.Equal(t, uint32(44100), codec.ClockRate)
|
||||
require.Equal(t, uint16(2), codec.Channels)
|
||||
|
||||
size := ReadADTSSize(src)
|
||||
require.Equal(t, uint16(16), size)
|
||||
|
||||
dst := CodecToADTS(codec)
|
||||
WriteADTSSize(dst, size)
|
||||
|
||||
require.Equal(t, src[:len(dst)], dst)
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package aac
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/bits"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
func IsADTS(b []byte) bool {
|
||||
_ = b[1]
|
||||
return len(b) > 7 && b[0] == 0xFF && b[1]&0xF0 == 0xF0
|
||||
}
|
||||
|
||||
func ADTSToCodec(b []byte) *core.Codec {
|
||||
// 1. Check ADTS header
|
||||
if !IsADTS(b) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 2. Decode ADTS params
|
||||
// https://wiki.multimedia.cx/index.php/ADTS
|
||||
rd := bits.NewReader(b)
|
||||
_ = rd.ReadBits(12) // Syncword, all bits must be set to 1
|
||||
_ = rd.ReadBit() // MPEG Version, set to 0 for MPEG-4 and 1 for MPEG-2
|
||||
_ = rd.ReadBits(2) // Layer, always set to 0
|
||||
_ = rd.ReadBit() // Protection absence, set to 1 if there is no CRC and 0 if there is CRC
|
||||
objType := rd.ReadBits8(2) + 1 // Profile, the MPEG-4 Audio Object Type minus 1
|
||||
sampleRateIdx := rd.ReadBits8(4) // MPEG-4 Sampling Frequency Index
|
||||
_ = rd.ReadBit() // Private bit, guaranteed never to be used by MPEG, set to 0 when encoding, ignore when decoding
|
||||
channels := rd.ReadBits16(3) // MPEG-4 Channel Configuration
|
||||
|
||||
//_ = rd.ReadBit() // Originality, set to 1 to signal originality of the audio and 0 otherwise
|
||||
//_ = rd.ReadBit() // Home, set to 1 to signal home usage of the audio and 0 otherwise
|
||||
//_ = rd.ReadBit() // Copyright ID bit
|
||||
//_ = rd.ReadBit() // Copyright ID start
|
||||
//_ = rd.ReadBits(13) // Frame length
|
||||
//_ = rd.ReadBits(11) // Buffer fullness
|
||||
//_ = rd.ReadBits(2) // Number of AAC frames (Raw Data Blocks) in ADTS frame minus 1
|
||||
//_ = rd.ReadBits(16) // CRC check
|
||||
|
||||
// 3. Encode RTP config
|
||||
wr := bits.NewWriter(nil)
|
||||
wr.WriteBits8(objType, 5)
|
||||
wr.WriteBits8(sampleRateIdx, 4)
|
||||
wr.WriteBits16(channels, 4)
|
||||
conf := wr.Bytes()
|
||||
|
||||
codec := &core.Codec{
|
||||
Name: core.CodecAAC,
|
||||
ClockRate: sampleRates[sampleRateIdx],
|
||||
Channels: channels,
|
||||
FmtpLine: FMTP + hex.EncodeToString(conf),
|
||||
}
|
||||
return codec
|
||||
}
|
||||
|
||||
func ReadADTSSize(b []byte) uint16 {
|
||||
// AAAAAAAA AAAABCCD EEFFFFGH HHIJKLMM MMMMMMMM MMMOOOOO OOOOOOPP (QQQQQQQQ QQQQQQQQ)
|
||||
_ = b[5] // bounds
|
||||
return uint16(b[3]&0x03)<<(8+3) | uint16(b[4])<<3 | uint16(b[5]>>5)
|
||||
}
|
||||
|
||||
func WriteADTSSize(b []byte, size uint16) {
|
||||
// AAAAAAAA AAAABCCD EEFFFFGH HHIJKLMM MMMMMMMM MMMOOOOO OOOOOOPP (QQQQQQQQ QQQQQQQQ)
|
||||
_ = b[5] // bounds
|
||||
b[3] |= byte(size >> (8 + 3))
|
||||
b[4] = byte(size >> 3)
|
||||
b[5] |= byte(size << 5)
|
||||
return
|
||||
}
|
||||
|
||||
func ADTSTimeSize(b []byte) uint32 {
|
||||
var units uint32
|
||||
for len(b) > ADTSHeaderSize {
|
||||
auSize := ReadADTSSize(b)
|
||||
b = b[auSize:]
|
||||
units++
|
||||
}
|
||||
return units * AUTime
|
||||
}
|
||||
|
||||
func CodecToADTS(codec *core.Codec) []byte {
|
||||
s := core.Between(codec.FmtpLine, "config=", ";")
|
||||
conf, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
objType, sampleFreqIdx, channels, _ := DecodeConfig(conf)
|
||||
profile := objType - 1
|
||||
|
||||
wr := bits.NewWriter(nil)
|
||||
wr.WriteAllBits(1, 12) // Syncword, all bits must be set to 1
|
||||
wr.WriteBit(0) // MPEG Version, set to 0 for MPEG-4 and 1 for MPEG-2
|
||||
wr.WriteBits8(0, 2) // Layer, always set to 0
|
||||
wr.WriteBit(1) // Protection absence, set to 1 if there is no CRC and 0 if there is CRC
|
||||
wr.WriteBits8(profile, 2) // Profile, the MPEG-4 Audio Object Type minus 1
|
||||
wr.WriteBits8(sampleFreqIdx, 4) // MPEG-4 Sampling Frequency Index
|
||||
wr.WriteBit(0) // Private bit, guaranteed never to be used by MPEG, set to 0 when encoding, ignore when decoding
|
||||
wr.WriteBits8(channels, 3) // MPEG-4 Channel Configuration
|
||||
wr.WriteBit(0) // Originality, set to 1 to signal originality of the audio and 0 otherwise
|
||||
wr.WriteBit(0) // Home, set to 1 to signal home usage of the audio and 0 otherwise
|
||||
wr.WriteBit(0) // Copyright ID bit
|
||||
wr.WriteBit(0) // Copyright ID start
|
||||
wr.WriteBits16(0, 13) // Frame length
|
||||
wr.WriteAllBits(1, 11) // Buffer fullness (variable bitrate)
|
||||
wr.WriteBits8(0, 2) // Number of AAC frames (Raw Data Blocks) in ADTS frame minus 1
|
||||
|
||||
return wr.Bytes()
|
||||
}
|
||||
|
||||
func EncodeToADTS(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc {
|
||||
adts := CodecToADTS(codec)
|
||||
|
||||
return func(packet *rtp.Packet) {
|
||||
if !IsADTS(packet.Payload) {
|
||||
b := make([]byte, ADTSHeaderSize+len(packet.Payload))
|
||||
copy(b, adts)
|
||||
copy(b[ADTSHeaderSize:], packet.Payload)
|
||||
WriteADTSSize(b, uint16(len(b)))
|
||||
|
||||
clone := *packet
|
||||
clone.Payload = b
|
||||
handler(&clone)
|
||||
} else {
|
||||
handler(packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package aac
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
type Consumer struct {
|
||||
core.SuperConsumer
|
||||
wr *core.WriteBuffer
|
||||
}
|
||||
|
||||
func NewConsumer() *Consumer {
|
||||
cons := &Consumer{
|
||||
wr: core.NewWriteBuffer(nil),
|
||||
}
|
||||
cons.Medias = []*core.Media{
|
||||
{
|
||||
Kind: core.KindAudio,
|
||||
Direction: core.DirectionSendonly,
|
||||
Codecs: []*core.Codec{
|
||||
{Name: core.CodecAAC},
|
||||
},
|
||||
},
|
||||
}
|
||||
return cons
|
||||
}
|
||||
|
||||
func (c *Consumer) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error {
|
||||
sender := core.NewSender(media, track.Codec)
|
||||
|
||||
sender.Handler = func(pkt *rtp.Packet) {
|
||||
if n, err := c.wr.Write(pkt.Payload); err == nil {
|
||||
c.Send += n
|
||||
}
|
||||
}
|
||||
|
||||
if track.Codec.IsRTP() {
|
||||
sender.Handler = RTPToADTS(track.Codec, sender.Handler)
|
||||
} else {
|
||||
sender.Handler = EncodeToADTS(track.Codec, sender.Handler)
|
||||
}
|
||||
|
||||
sender.HandleRTP(track)
|
||||
c.Senders = append(c.Senders, sender)
|
||||
return nil
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
+87
-12
@@ -2,29 +2,52 @@ package aac
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
const RTPPacketVersionAAC = 0
|
||||
const ADTSHeaderSize = 7
|
||||
|
||||
func RTPDepay(handler core.HandlerFunc) core.HandlerFunc {
|
||||
var timestamp uint32
|
||||
|
||||
return func(packet *rtp.Packet) {
|
||||
// support ONLY 2 bytes header size!
|
||||
// streamtype=5;profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=1408
|
||||
// https://datatracker.ietf.org/doc/html/rfc3640
|
||||
headersSize := binary.BigEndian.Uint16(packet.Payload) >> 3
|
||||
|
||||
//log.Printf("[RTP/AAC] units: %d, size: %4d, ts: %10d, %t", headersSize/2, len(packet.Payload), packet.Timestamp, packet.Marker)
|
||||
|
||||
data := packet.Payload[2+headersSize:]
|
||||
if IsADTS(data) {
|
||||
data = data[7:]
|
||||
if len(packet.Payload) < int(2+headersSize) {
|
||||
return
|
||||
}
|
||||
|
||||
clone := *packet
|
||||
clone.Version = RTPPacketVersionAAC
|
||||
clone.Payload = data
|
||||
handler(&clone)
|
||||
headers := packet.Payload[2 : 2+headersSize]
|
||||
units := packet.Payload[2+headersSize:]
|
||||
|
||||
for len(headers) > 0 {
|
||||
unitSize := binary.BigEndian.Uint16(headers) >> 3
|
||||
|
||||
unit := units[:unitSize]
|
||||
|
||||
headers = headers[2:]
|
||||
units = units[unitSize:]
|
||||
|
||||
timestamp += AUTime
|
||||
|
||||
clone := *packet
|
||||
clone.Version = RTPPacketVersionAAC
|
||||
clone.Timestamp = timestamp
|
||||
if IsADTS(unit) {
|
||||
clone.Payload = unit[ADTSHeaderSize:]
|
||||
} else {
|
||||
clone.Payload = unit
|
||||
}
|
||||
handler(&clone)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,11 +61,11 @@ func RTPPay(handler core.HandlerFunc) core.HandlerFunc {
|
||||
}
|
||||
|
||||
// support ONLY one unit in payload
|
||||
size := uint16(len(packet.Payload))
|
||||
auSize := uint16(len(packet.Payload))
|
||||
// 2 bytes header size + 2 bytes first payload size
|
||||
payload := make([]byte, 2+2+size)
|
||||
payload := make([]byte, 2+2+auSize)
|
||||
payload[1] = 16 // header size in bits
|
||||
binary.BigEndian.PutUint16(payload[2:], size<<3)
|
||||
binary.BigEndian.PutUint16(payload[2:], auSize<<3)
|
||||
copy(payload[4:], packet.Payload)
|
||||
|
||||
clone := rtp.Packet{
|
||||
@@ -58,6 +81,58 @@ func RTPPay(handler core.HandlerFunc) core.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func IsADTS(b []byte) bool {
|
||||
return len(b) > 7 && b[0] == 0xFF && b[1]&0xF0 == 0xF0
|
||||
func ADTStoRTP(src []byte) (dst []byte) {
|
||||
dst = make([]byte, 2) // header bytes
|
||||
for i, n := 0, len(src)-ADTSHeaderSize; i < n; {
|
||||
auSize := ReadADTSSize(src[i:])
|
||||
dst = append(dst, byte(auSize>>5), byte(auSize<<3)) // size in bits
|
||||
i += int(auSize)
|
||||
}
|
||||
hdrSize := uint16(len(dst) - 2)
|
||||
binary.BigEndian.PutUint16(dst, hdrSize<<3) // size in bits
|
||||
return append(dst, src...)
|
||||
}
|
||||
|
||||
func RTPTimeSize(b []byte) uint32 {
|
||||
// convert RTP header size to units count
|
||||
units := binary.BigEndian.Uint16(b) >> 4
|
||||
return uint32(units) * AUTime
|
||||
}
|
||||
|
||||
func RTPToADTS(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc {
|
||||
adts := CodecToADTS(codec)
|
||||
|
||||
return func(packet *rtp.Packet) {
|
||||
src := packet.Payload
|
||||
dst := make([]byte, 0, len(src))
|
||||
|
||||
headersSize := binary.BigEndian.Uint16(src) >> 3
|
||||
headers := src[2 : 2+headersSize]
|
||||
units := src[2+headersSize:]
|
||||
|
||||
for len(headers) > 0 {
|
||||
unitSize := binary.BigEndian.Uint16(headers) >> 3
|
||||
headers = headers[2:]
|
||||
unit := units[:unitSize]
|
||||
units = units[unitSize:]
|
||||
|
||||
if !IsADTS(unit) {
|
||||
i := len(dst)
|
||||
dst = append(dst, adts...)
|
||||
WriteADTSSize(dst[i:], ADTSHeaderSize+uint16(len(unit)))
|
||||
}
|
||||
|
||||
dst = append(dst, unit...)
|
||||
}
|
||||
|
||||
clone := *packet
|
||||
clone.Version = RTPPacketVersionAAC
|
||||
clone.Payload = dst
|
||||
handler(&clone)
|
||||
}
|
||||
}
|
||||
|
||||
func RTPToCodec(b []byte) *core.Codec {
|
||||
hdrSize := binary.BigEndian.Uint16(b) / 8
|
||||
return ADTSToCodec(b[2+hdrSize:])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package bits
|
||||
|
||||
type Reader struct {
|
||||
EOF bool // if end of buffer raised during reading
|
||||
|
||||
buf []byte // total buf
|
||||
byte byte // current byte
|
||||
bits byte // bits left in byte
|
||||
pos int // current pos in buf
|
||||
}
|
||||
|
||||
func NewReader(b []byte) *Reader {
|
||||
return &Reader{buf: b}
|
||||
}
|
||||
|
||||
//goland:noinspection GoStandardMethods
|
||||
func (r *Reader) ReadByte() byte {
|
||||
if r.bits != 0 {
|
||||
return r.ReadBits8(8)
|
||||
}
|
||||
|
||||
if r.pos >= len(r.buf) {
|
||||
r.EOF = true
|
||||
return 0
|
||||
}
|
||||
|
||||
b := r.buf[r.pos]
|
||||
r.pos++
|
||||
return b
|
||||
}
|
||||
|
||||
func (r *Reader) ReadUint16() uint16 {
|
||||
if r.bits != 0 {
|
||||
return r.ReadBits16(16)
|
||||
}
|
||||
return uint16(r.ReadByte())<<8 | uint16(r.ReadByte())
|
||||
}
|
||||
|
||||
func (r *Reader) ReadUint24() uint32 {
|
||||
if r.bits != 0 {
|
||||
return r.ReadBits(24)
|
||||
}
|
||||
return uint32(r.ReadByte())<<16 | uint32(r.ReadByte())<<8 | uint32(r.ReadByte())
|
||||
}
|
||||
|
||||
func (r *Reader) ReadUint32() uint32 {
|
||||
if r.bits != 0 {
|
||||
return r.ReadBits(32)
|
||||
}
|
||||
return uint32(r.ReadByte())<<24 | uint32(r.ReadByte())<<16 | uint32(r.ReadByte())<<8 | uint32(r.ReadByte())
|
||||
}
|
||||
|
||||
func (r *Reader) ReadBit() byte {
|
||||
if r.bits == 0 {
|
||||
r.byte = r.ReadByte()
|
||||
r.bits = 7
|
||||
} else {
|
||||
r.bits--
|
||||
}
|
||||
|
||||
return (r.byte >> r.bits) & 0b1
|
||||
}
|
||||
|
||||
func (r *Reader) ReadBits(n byte) (res uint32) {
|
||||
for i := n - 1; i != 255; i-- {
|
||||
res |= uint32(r.ReadBit()) << i
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (r *Reader) ReadBits8(n byte) (res uint8) {
|
||||
for i := n - 1; i != 255; i-- {
|
||||
res |= r.ReadBit() << i
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (r *Reader) ReadBits16(n byte) (res uint16) {
|
||||
for i := n - 1; i != 255; i-- {
|
||||
res |= uint16(r.ReadBit()) << i
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (r *Reader) ReadBits64(n byte) (res uint64) {
|
||||
for i := n - 1; i != 255; i-- {
|
||||
res |= uint64(r.ReadBit()) << i
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (r *Reader) ReadBytes(n int) (b []byte) {
|
||||
if r.bits == 0 {
|
||||
if r.pos+n > len(r.buf) {
|
||||
r.EOF = true
|
||||
return nil
|
||||
}
|
||||
|
||||
b = r.buf[r.pos : r.pos+n]
|
||||
r.pos += n
|
||||
} else {
|
||||
b = make([]byte, n)
|
||||
for i := 0; i < n; i++ {
|
||||
b[i] = r.ReadByte()
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ReadUEGolomb - ReadExponentialGolomb (unsigned)
|
||||
func (r *Reader) ReadUEGolomb() uint32 {
|
||||
var size byte
|
||||
for size = 0; size < 32; size++ {
|
||||
if b := r.ReadBit(); b != 0 || r.EOF {
|
||||
break
|
||||
}
|
||||
}
|
||||
return r.ReadBits(size) + (1 << size) - 1
|
||||
}
|
||||
|
||||
// ReadSEGolomb - ReadSignedExponentialGolomb
|
||||
func (r *Reader) ReadSEGolomb() int32 {
|
||||
if b := r.ReadUEGolomb(); b%2 == 0 {
|
||||
return -int32(b >> 1)
|
||||
} else {
|
||||
return int32(b >> 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package bits
|
||||
|
||||
type Writer struct {
|
||||
buf []byte // total buf
|
||||
byte *byte // pointer to current byte
|
||||
bits byte // bits left in byte
|
||||
}
|
||||
|
||||
func NewWriter(buf []byte) *Writer {
|
||||
return &Writer{buf: buf}
|
||||
}
|
||||
|
||||
//goland:noinspection GoStandardMethods
|
||||
func (w *Writer) WriteByte(b byte) {
|
||||
if w.bits != 0 {
|
||||
w.WriteBits8(b, 8)
|
||||
}
|
||||
|
||||
w.buf = append(w.buf, b)
|
||||
}
|
||||
|
||||
func (w *Writer) WriteBit(b byte) {
|
||||
if w.bits == 0 {
|
||||
w.buf = append(w.buf, 0)
|
||||
w.byte = &w.buf[len(w.buf)-1]
|
||||
w.bits = 7
|
||||
} else {
|
||||
w.bits--
|
||||
}
|
||||
|
||||
*w.byte |= (b & 1) << w.bits
|
||||
}
|
||||
|
||||
func (w *Writer) WriteBits(v uint32, n byte) {
|
||||
for i := n - 1; i != 255; i-- {
|
||||
w.WriteBit(byte(v>>i) & 0b1)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Writer) WriteBits16(v uint16, n byte) {
|
||||
for i := n - 1; i != 255; i-- {
|
||||
w.WriteBit(byte(v>>i) & 0b1)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Writer) WriteBits8(v, n byte) {
|
||||
for i := n - 1; i != 255; i-- {
|
||||
w.WriteBit((v >> i) & 0b1)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Writer) WriteAllBits(bit, n byte) {
|
||||
for i := byte(0); i < n; i++ {
|
||||
w.WriteBit(bit)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Writer) WriteBool(b bool) {
|
||||
if b {
|
||||
w.WriteBit(1)
|
||||
} else {
|
||||
w.WriteBit(0)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Writer) WriteUint16(v uint16) {
|
||||
if w.bits != 0 {
|
||||
w.WriteBits16(v, 16)
|
||||
}
|
||||
|
||||
w.buf = append(w.buf, byte(v>>8), byte(v))
|
||||
}
|
||||
|
||||
func (w *Writer) WriteBytes(bytes ...byte) {
|
||||
if w.bits != 0 {
|
||||
for _, b := range bytes {
|
||||
w.WriteByte(b)
|
||||
}
|
||||
}
|
||||
|
||||
w.buf = append(w.buf, bytes...)
|
||||
}
|
||||
|
||||
func (w *Writer) Bytes() []byte {
|
||||
return w.buf
|
||||
}
|
||||
|
||||
func (w *Writer) Len() int {
|
||||
return len(w.buf)
|
||||
}
|
||||
|
||||
func (w *Writer) Reset() {
|
||||
w.buf = w.buf[:0]
|
||||
w.bits = 0
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
// Package bubble, because:
|
||||
// Request URL: /bubble/live?ch=0&stream=0
|
||||
// Response Conten-Type: video/bubble
|
||||
// https://github.com/Lynch234ok/lynch-git/blob/master/app_rebulid/src/bubble.c
|
||||
package bubble
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264/annexb"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
core.Listener
|
||||
|
||||
url string
|
||||
conn net.Conn
|
||||
|
||||
videoCodec string
|
||||
channel int
|
||||
stream int
|
||||
|
||||
r *bufio.Reader
|
||||
|
||||
medias []*core.Media
|
||||
receivers []*core.Receiver
|
||||
|
||||
videoTrack *core.Receiver
|
||||
audioTrack *core.Receiver
|
||||
|
||||
recv int
|
||||
}
|
||||
|
||||
func NewClient(url string) *Client {
|
||||
return &Client{url: url}
|
||||
}
|
||||
|
||||
const (
|
||||
SyncByte = 0xAA
|
||||
PacketAuth = 0x00
|
||||
PacketMedia = 0x01
|
||||
PacketStart = 0x0A
|
||||
)
|
||||
|
||||
const Timeout = time.Second * 5
|
||||
|
||||
func (c *Client) Dial() (err error) {
|
||||
u, err := url.Parse(c.url)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if c.conn, err = net.DialTimeout("tcp", u.Host, Timeout); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = c.conn.SetDeadline(time.Now().Add(Timeout)); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
req := &tcp.Request{Method: "GET", URL: &url.URL{Path: u.Path, RawQuery: u.RawQuery}, Proto: "HTTP/1.1"}
|
||||
if err = req.Write(c.conn); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.r = bufio.NewReader(c.conn)
|
||||
res, err := tcp.ReadResponse(c.r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return errors.New("wrong response: " + res.Status)
|
||||
}
|
||||
|
||||
// 1. Read 1024 bytes with XML, some cameras returns exact 1024, but some - 923
|
||||
xml := make([]byte, 1024)
|
||||
if _, err = c.r.Read(xml); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Write size uint32 + unknown 4b + user 20b + pass 20b
|
||||
b := make([]byte, 48)
|
||||
binary.BigEndian.PutUint32(b, 44)
|
||||
|
||||
if u.User != nil {
|
||||
copy(b[8:], u.User.Username())
|
||||
pass, _ := u.User.Password()
|
||||
copy(b[28:], pass)
|
||||
} else {
|
||||
copy(b[8:], "admin")
|
||||
}
|
||||
|
||||
if err = c.Write(PacketAuth, 0x0E16C271, b); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Read response
|
||||
cmd, b, err := c.Read()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if cmd != PacketAuth || len(b) != 44 || b[4] != 3 || b[8] != 1 {
|
||||
return errors.New("wrong auth response")
|
||||
}
|
||||
|
||||
// 4. Parse XML (from 1)
|
||||
query := u.Query()
|
||||
|
||||
stream := query.Get("stream")
|
||||
if stream != "" {
|
||||
c.stream = core.Atoi(stream)
|
||||
} else {
|
||||
stream = "0"
|
||||
}
|
||||
|
||||
// <bubble version="1.0" vin="1"><vin0 stream="2">
|
||||
// <stream0 name="720p.264" size="2304x1296" x1="yes" x2="yes" x4="yes" />
|
||||
// <stream1 name="360p.265" size="640x360" x1="yes" x2="yes" x4="yes" />
|
||||
// <vin0>
|
||||
// </bubble>
|
||||
re := regexp.MustCompile("<stream" + stream + " [^>]+")
|
||||
stream = re.FindString(string(xml))
|
||||
if strings.Contains(stream, ".265") {
|
||||
c.videoCodec = core.CodecH265
|
||||
} else {
|
||||
c.videoCodec = core.CodecH264
|
||||
}
|
||||
|
||||
if ch := query.Get("ch"); ch != "" {
|
||||
c.channel = core.Atoi(ch)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Client) Write(command byte, timestamp uint32, payload []byte) error {
|
||||
if err := c.conn.SetWriteDeadline(time.Now().Add(Timeout)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 0xAA + size uint32 + cmd byte + ts uint32 + payload
|
||||
b := make([]byte, 14+len(payload))
|
||||
b[0] = SyncByte
|
||||
binary.BigEndian.PutUint32(b[1:], uint32(5+len(payload)))
|
||||
b[5] = command
|
||||
binary.BigEndian.PutUint32(b[6:], timestamp)
|
||||
copy(b[10:], payload)
|
||||
|
||||
_, err := c.conn.Write(b)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) Read() (byte, []byte, error) {
|
||||
if err := c.conn.SetReadDeadline(time.Now().Add(Timeout)); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
// 0xAA + size uint32 + cmd byte + ts uint32 + payload
|
||||
b := make([]byte, 10)
|
||||
if _, err := io.ReadFull(c.r, b); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
if b[0] != SyncByte {
|
||||
return 0, nil, errors.New("wrong start byte")
|
||||
}
|
||||
|
||||
size := binary.BigEndian.Uint32(b[1:])
|
||||
payload := make([]byte, size-1-4)
|
||||
if _, err := io.ReadFull(c.r, payload); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
//timestamp := binary.BigEndian.Uint32(b[6:]) // in ms
|
||||
|
||||
return b[5], payload, nil
|
||||
}
|
||||
|
||||
func (c *Client) Play() error {
|
||||
// yeah, there's no mistake about the little endian
|
||||
b := make([]byte, 16)
|
||||
binary.LittleEndian.PutUint32(b, uint32(c.channel))
|
||||
binary.LittleEndian.PutUint32(b[4:], uint32(c.stream))
|
||||
binary.LittleEndian.PutUint32(b[8:], 1) // opened
|
||||
return c.Write(PacketStart, 0x0E16C2DF, b)
|
||||
}
|
||||
|
||||
func (c *Client) Handle() error {
|
||||
var audioTS uint32
|
||||
|
||||
for {
|
||||
cmd, b, err := c.Read()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.recv += len(b)
|
||||
|
||||
if cmd != PacketMedia {
|
||||
continue
|
||||
}
|
||||
|
||||
// size uint32 + type 1b + channel 1b
|
||||
// type = 1 for keyframe, 2 for other frame, 0 for audio
|
||||
|
||||
if b[4] > 0 {
|
||||
if c.videoTrack == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
pkt := &rtp.Packet{
|
||||
Header: rtp.Header{
|
||||
Timestamp: core.Now90000(),
|
||||
},
|
||||
Payload: annexb.EncodeToAVCC(b[6:], false),
|
||||
}
|
||||
c.videoTrack.WriteRTP(pkt)
|
||||
} else {
|
||||
if c.audioTrack == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
//binary.LittleEndian.Uint32(b[6:]) // entries (always 1)
|
||||
//size := binary.LittleEndian.Uint32(b[10:]) // size
|
||||
//mk := binary.LittleEndian.Uint64(b[14:]) // pts (uint64_t)
|
||||
//binary.LittleEndian.Uint32(b[22:]) // gtime (time_t)
|
||||
//name := b[26:34] // g711
|
||||
//rate := binary.LittleEndian.Uint32(b[34:]) // sample rate
|
||||
//width := binary.LittleEndian.Uint32(b[38:]) // samplewidth
|
||||
|
||||
pkt := &rtp.Packet{
|
||||
Header: rtp.Header{
|
||||
Version: 2,
|
||||
Marker: true,
|
||||
Timestamp: audioTS,
|
||||
},
|
||||
Payload: b[6+36:],
|
||||
}
|
||||
audioTS += uint32(len(pkt.Payload))
|
||||
c.audioTrack.WriteRTP(pkt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
return c.conn.Close()
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package bubble
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
func (c *Client) GetMedias() []*core.Media {
|
||||
if c.medias == nil {
|
||||
c.medias = []*core.Media{
|
||||
{
|
||||
Kind: core.KindVideo,
|
||||
Direction: core.DirectionRecvonly,
|
||||
Codecs: []*core.Codec{
|
||||
{Name: c.videoCodec, ClockRate: 90000, PayloadType: core.PayloadTypeRAW},
|
||||
},
|
||||
},
|
||||
{
|
||||
Kind: core.KindAudio,
|
||||
Direction: core.DirectionRecvonly,
|
||||
Codecs: []*core.Codec{
|
||||
{Name: core.CodecPCMA, ClockRate: 8000, PayloadType: 8},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return c.medias
|
||||
}
|
||||
|
||||
func (c *Client) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) {
|
||||
for _, track := range c.receivers {
|
||||
if track.Codec == codec {
|
||||
return track, nil
|
||||
}
|
||||
}
|
||||
|
||||
track := core.NewReceiver(media, codec)
|
||||
|
||||
switch media.Kind {
|
||||
case core.KindVideo:
|
||||
c.videoTrack = track
|
||||
case core.KindAudio:
|
||||
c.audioTrack = track
|
||||
}
|
||||
|
||||
c.receivers = append(c.receivers, track)
|
||||
|
||||
return track, nil
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
if err := c.Play(); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Handle()
|
||||
}
|
||||
|
||||
func (c *Client) Stop() error {
|
||||
for _, receiver := range c.receivers {
|
||||
receiver.Close()
|
||||
}
|
||||
return c.Close()
|
||||
}
|
||||
|
||||
func (c *Client) MarshalJSON() ([]byte, error) {
|
||||
info := &core.Info{
|
||||
Type: "Bubble active producer",
|
||||
Medias: c.medias,
|
||||
Recv: c.recv,
|
||||
Receivers: c.receivers,
|
||||
}
|
||||
return json.Marshal(info)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
## PCM
|
||||
|
||||
**RTSP**
|
||||
|
||||
- PayloadType=10 - L16/44100/2 - Linear PCM 16-bit big endian
|
||||
- PayloadType=11 - L16/44100/1 - Linear PCM 16-bit big endian
|
||||
|
||||
https://en.wikipedia.org/wiki/RTP_payload_formats
|
||||
|
||||
**Apple QuickTime**
|
||||
|
||||
- `raw` - 16-bit data is stored in little endian format
|
||||
- `twos` - 16-bit data is stored in big endian format
|
||||
- `sowt` - 16-bit data is stored in little endian format
|
||||
- `in24` - denotes 24-bit, big endian
|
||||
- `in32` - denotes 32-bit, big endian
|
||||
- `fl32` - denotes 32-bit floating point PCM
|
||||
- `fl64` - denotes 64-bit floating point PCM
|
||||
- `alaw` - denotes A-law logarithmic PCM
|
||||
- `ulaw` - denotes mu-law logarithmic PCM
|
||||
|
||||
https://wiki.multimedia.cx/index.php/PCM
|
||||
|
||||
**FFmpeg RTSP**
|
||||
|
||||
```
|
||||
pcm_s16be, 44100 Hz, stereo => 10
|
||||
pcm_s16be, 48000 Hz, stereo => 96 L16/48000/2
|
||||
pcm_s16be, 44100 Hz, mono => 11
|
||||
|
||||
pcm_s16le, 48000 Hz, stereo => 96 (b=AS:1536)
|
||||
pcm_s16le, 44100 Hz, stereo => 96 (b=AS:1411)
|
||||
pcm_s16le, 16000 Hz, stereo => 96 (b=AS:512)
|
||||
pcm_s16le, 8000 Hz, stereo => 96 (b=AS:256)
|
||||
|
||||
pcm_s16le, 48000 Hz, mono => 96 (b=AS:768)
|
||||
pcm_s16le, 44100 Hz, mono => 96 (b=AS:705)
|
||||
pcm_s16le, 16000 Hz, mono => 96 (b=AS:256)
|
||||
pcm_s16le, 8000 Hz, mono => 96 (b=AS:128)
|
||||
```
|
||||
+62
-1
@@ -3,10 +3,11 @@ package core
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"github.com/pion/sdp/v3"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/pion/sdp/v3"
|
||||
)
|
||||
|
||||
type Codec struct {
|
||||
@@ -51,6 +52,30 @@ func (c *Codec) IsRTP() bool {
|
||||
return c.PayloadType != PayloadTypeRAW
|
||||
}
|
||||
|
||||
func (c *Codec) IsVideo() bool {
|
||||
return c.Kind() == KindVideo
|
||||
}
|
||||
|
||||
func (c *Codec) IsAudio() bool {
|
||||
return c.Kind() == KindAudio
|
||||
}
|
||||
|
||||
func (c *Codec) Kind() string {
|
||||
return GetKind(c.Name)
|
||||
}
|
||||
|
||||
func (c *Codec) PrintName() string {
|
||||
switch c.Name {
|
||||
case CodecAAC:
|
||||
return "AAC"
|
||||
case CodecPCM:
|
||||
return "S16B"
|
||||
case CodecPCML:
|
||||
return "S16L"
|
||||
}
|
||||
return c.Name
|
||||
}
|
||||
|
||||
func (c *Codec) Clone() *Codec {
|
||||
clone := *c
|
||||
return &clone
|
||||
@@ -112,6 +137,42 @@ func UnmarshalCodec(md *sdp.MediaDescription, payloadType string) *Codec {
|
||||
case "26":
|
||||
c.Name = CodecJPEG
|
||||
c.ClockRate = 90000
|
||||
case "96", "97", "98":
|
||||
if len(md.Bandwidth) == 0 {
|
||||
c.Name = payloadType
|
||||
break
|
||||
}
|
||||
|
||||
// FFmpeg + RTSP + pcm_s16le = doesn't pass info about codec name and params
|
||||
// so try to guess the codec based on bitrate
|
||||
// https://github.com/AlexxIT/go2rtc/issues/523
|
||||
switch md.Bandwidth[0].Bandwidth {
|
||||
case 128:
|
||||
c.ClockRate = 8000
|
||||
case 256:
|
||||
c.ClockRate = 16000
|
||||
case 384:
|
||||
c.ClockRate = 24000
|
||||
case 512:
|
||||
c.ClockRate = 32000
|
||||
case 705:
|
||||
c.ClockRate = 44100
|
||||
case 768:
|
||||
c.ClockRate = 48000
|
||||
case 1411:
|
||||
// default Windows DShow
|
||||
c.ClockRate = 44100
|
||||
c.Channels = 2
|
||||
case 1536:
|
||||
// default Linux ALSA
|
||||
c.ClockRate = 48000
|
||||
c.Channels = 2
|
||||
default:
|
||||
c.Name = payloadType
|
||||
break
|
||||
}
|
||||
|
||||
c.Name = CodecPCML
|
||||
default:
|
||||
c.Name = payloadType
|
||||
}
|
||||
|
||||
+77
-1
@@ -25,7 +25,9 @@ const (
|
||||
CodecOpus = "OPUS" // payloadType: 111
|
||||
CodecG722 = "G722"
|
||||
CodecMP3 = "MPA" // payload: 14, aka MPEG-1 Layer III
|
||||
CodecPCM = "L16" // Linear PCM
|
||||
CodecPCM = "L16" // Linear PCM (big endian)
|
||||
|
||||
CodecPCML = "PCML" // Linear PCM (little endian)
|
||||
|
||||
CodecELD = "ELD" // AAC-ELD
|
||||
CodecFLAC = "FLAC"
|
||||
@@ -45,7 +47,10 @@ type Producer interface {
|
||||
// GetTrack - return Receiver, that can only produce rtp.Packet(s)
|
||||
GetTrack(media *Media, codec *Codec) (*Receiver, error)
|
||||
|
||||
// Deprecated: rename to Run()
|
||||
Start() error
|
||||
|
||||
// Deprecated: rename to Close()
|
||||
Stop() error
|
||||
}
|
||||
|
||||
@@ -57,6 +62,7 @@ type Consumer interface {
|
||||
|
||||
AddTrack(media *Media, codec *Codec, track *Receiver) error
|
||||
|
||||
// Deprecated: rename to Close()
|
||||
Stop() error
|
||||
}
|
||||
|
||||
@@ -88,6 +94,7 @@ type Info struct {
|
||||
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"`
|
||||
@@ -99,3 +106,72 @@ 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
|
||||
}
|
||||
|
||||
+19
-14
@@ -1,28 +1,37 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
cryptorand "crypto/rand"
|
||||
"github.com/rs/zerolog/log"
|
||||
"crypto/rand"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
BufferSize = 64 * 1024 // 64K
|
||||
ConnDialTimeout = time.Second * 3
|
||||
ConnDeadline = time.Second * 5
|
||||
ProbeTimeout = time.Second * 3
|
||||
)
|
||||
|
||||
// Now90000 - timestamp for Video (clock rate = 90000 samples per second)
|
||||
// same as: uint32(time.Duration(time.Now().UnixNano()) * 90000 / time.Second)
|
||||
func Now90000() uint32 {
|
||||
return uint32(time.Duration(time.Now().UnixMilli()) * 90)
|
||||
return uint32(time.Duration(time.Now().UnixNano()) * 90000 / time.Second)
|
||||
}
|
||||
|
||||
const symbols = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_"
|
||||
|
||||
// RandString base10 - numbers, base16 - hex, base36 - digits+letters, base64 - URL safe symbols
|
||||
// RandString base10 - numbers, base16 - hex, base36 - digits+letters
|
||||
// base64 - URL safe symbols, base0 - crypto random
|
||||
func RandString(size, base byte) string {
|
||||
b := make([]byte, size)
|
||||
if _, err := cryptorand.Read(b); err != nil {
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if base == 0 {
|
||||
return string(b)
|
||||
}
|
||||
for i := byte(0); i < size; i++ {
|
||||
b[i] = symbols[b[i]%base]
|
||||
}
|
||||
@@ -45,12 +54,7 @@ func Between(s, sub1, sub2 string) string {
|
||||
}
|
||||
s = s[i+len(sub1):]
|
||||
|
||||
if len(sub2) == 1 {
|
||||
i = strings.IndexByte(s, sub2[0])
|
||||
} else {
|
||||
i = strings.Index(s, sub2)
|
||||
}
|
||||
if i >= 0 {
|
||||
if i = strings.Index(s, sub2); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
|
||||
@@ -58,7 +62,9 @@ func Between(s, sub1, sub2 string) string {
|
||||
}
|
||||
|
||||
func Atoi(s string) (i int) {
|
||||
i, _ = strconv.Atoi(s)
|
||||
if s != "" {
|
||||
i, _ = strconv.Atoi(s)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -70,7 +76,6 @@ func Assert(ok bool) {
|
||||
}
|
||||
|
||||
func Caller() string {
|
||||
log.Error().Caller(0).Send()
|
||||
_, file, line, _ := runtime.Caller(1)
|
||||
return file + ":" + strconv.Itoa(line)
|
||||
}
|
||||
|
||||
+3
-2
@@ -3,8 +3,9 @@ package core
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/pion/sdp/v3"
|
||||
"strings"
|
||||
|
||||
"github.com/pion/sdp/v3"
|
||||
)
|
||||
|
||||
// Media take best from:
|
||||
@@ -93,7 +94,7 @@ func GetKind(name string) string {
|
||||
switch name {
|
||||
case CodecH264, CodecH265, CodecVP8, CodecVP9, CodecAV1, CodecJPEG:
|
||||
return KindVideo
|
||||
case CodecPCMU, CodecPCMA, CodecAAC, CodecOpus, CodecG722, CodecMP3, CodecPCM, CodecELD, CodecFLAC:
|
||||
case CodecPCMU, CodecPCMA, CodecAAC, CodecOpus, CodecG722, CodecMP3, CodecPCM, CodecPCML, CodecELD, CodecFLAC:
|
||||
return KindAudio
|
||||
}
|
||||
return ""
|
||||
|
||||
@@ -2,11 +2,12 @@ package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/pion/sdp/v3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSDP(t *testing.T) {
|
||||
@@ -38,7 +39,7 @@ func TestParseQuery(t *testing.T) {
|
||||
u, _ = url.Parse(rawULR)
|
||||
medias = ParseQuery(u.Query())
|
||||
assert.Equal(t, []*Media{
|
||||
{Kind: KindVideo, Direction: DirectionRecvonly, Codecs: []*Codec{{Name: CodecAny}}},
|
||||
{Kind: KindVideo, Direction: DirectionSendonly, Codecs: []*Codec{{Name: CodecAny}}},
|
||||
}, medias)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user