mirror of
https://github.com/AlexxIT/go2rtc.git
synced 2025-10-02 15:12:15 +08:00
Compare commits
434 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 | ||
![]() |
da92256910 | ||
![]() |
035b824645 | ||
![]() |
2a91c4625a | ||
![]() |
23dd5b450c | ||
![]() |
f617c148cd | ||
![]() |
b5f4c7f75b | ||
![]() |
d44efb84a0 | ||
![]() |
03968d2f2e | ||
![]() |
3c371e7046 | ||
![]() |
4656086985 | ||
![]() |
e78f9fa69d | ||
![]() |
2e8be342ef | ||
![]() |
5387e88fe3 | ||
![]() |
1746f55eda | ||
![]() |
4d53889519 | ||
![]() |
6d9d89bbe3 | ||
![]() |
c1923627c0 | ||
![]() |
95ca5f5fe1 | ||
![]() |
4bbd3a1cd2 | ||
![]() |
9c8a1d8b19 | ||
![]() |
53967fc72a | ||
![]() |
31f870e950 | ||
![]() |
c7d228daff | ||
![]() |
378f071e2c | ||
![]() |
75f61b38ac | ||
![]() |
bc770f1a85 | ||
![]() |
d276311fcf | ||
![]() |
1e14dc9ab2 | ||
![]() |
8dbaa4ba93 | ||
![]() |
f0893bd78b | ||
![]() |
6247746177 | ||
![]() |
a20de73ab2 | ||
![]() |
813c8b3b3d | ||
![]() |
63d9c6c2b7 | ||
![]() |
2610f15eb6 | ||
![]() |
9268acf1ca | ||
![]() |
55fdf1a647 | ||
![]() |
5fe07aeea0 | ||
![]() |
e8b22bca99 | ||
![]() |
5926c1deb9 | ||
![]() |
dd98edc48e | ||
![]() |
fb1cc7dfc2 | ||
![]() |
7626a09c1c | ||
![]() |
db85533e74 | ||
![]() |
5939c8acba | ||
![]() |
e985ad23a2 | ||
![]() |
7452eb5e05 | ||
![]() |
5f9788209d | ||
![]() |
c07ddb8309 | ||
![]() |
79f1dcfea3 | ||
![]() |
3feaf852af | ||
![]() |
76ec70d2a0 | ||
![]() |
6cef5faf27 | ||
![]() |
edb4e6eaad | ||
![]() |
116319f876 | ||
![]() |
a0e6005598 | ||
![]() |
fd580b6f2c | ||
![]() |
1837e7c86c | ||
![]() |
235f2fde0d | ||
![]() |
35087e0812 | ||
![]() |
da08d8e973 | ||
![]() |
757091e43d | ||
![]() |
a5c4854aeb | ||
![]() |
4b4deaaaf2 | ||
![]() |
553f5ff0d8 | ||
![]() |
25dc3664fd | ||
![]() |
8dd9991268 | ||
![]() |
d633d331bb | ||
![]() |
7d3fbf2ee0 | ||
![]() |
af717b2172 | ||
![]() |
c44aaebd65 | ||
![]() |
d6259fc0e9 | ||
![]() |
5c657d557a | ||
![]() |
93be5cd92f | ||
![]() |
cf6a35d0c7 | ||
![]() |
af79e6054b | ||
![]() |
9f3d5e7460 | ||
![]() |
abbf180b1b | ||
![]() |
696588e52e | ||
![]() |
3e97ce8b2a | ||
![]() |
722b2827a1 | ||
![]() |
69598b508c | ||
![]() |
f49fcc4f68 | ||
![]() |
59347a409e | ||
![]() |
45b25d29b7 | ||
![]() |
49e861d1b0 | ||
![]() |
b1701e856a | ||
![]() |
a6260d0f56 | ||
![]() |
693d41be87 | ||
![]() |
222dc6a5c2 | ||
![]() |
8fde2b6fe5 | ||
![]() |
15e205cc01 | ||
![]() |
1db9ed4946 | ||
![]() |
fd83d151d2 | ||
![]() |
91a7b5be27 | ||
![]() |
a36359f3dd |
189
.github/workflows/build.yml
vendored
Normal file
189
.github/workflows/build.yml
vendored
Normal file
@@ -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
|
75
.github/workflows/docker.yml
vendored
75
.github/workflows/docker.yml
vendored
@@ -1,75 +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@v3
|
||||
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 }}
|
||||
|
||||
- name: Build and push Hardware
|
||||
uses: docker/build-push-action@v3
|
||||
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 }}
|
2
.github/workflows/gh-pages.yml
vendored
2
.github/workflows/gh-pages.yml
vendored
@@ -34,4 +34,4 @@ jobs:
|
||||
path: './website'
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v1
|
||||
uses: actions/deploy-pages@v2
|
||||
|
99
.github/workflows/release.yml
vendored
99
.github/workflows/release.yml
vendored
@@ -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
|
16
.github/workflows/test.yml
vendored
16
.github/workflows/test.yml
vendored
@@ -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.19'
|
||||
go-version: '1.21'
|
||||
|
||||
- name: Build Go binary
|
||||
run: go build -ldflags "-s -w" -trimpath -o ./go2rtc
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/${{ matrix.platform }}
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
|
||||
- name: Build and push Hardware
|
||||
if: matrix.platform == 'amd64'
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: hardware.Dockerfile
|
||||
|
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,8 +1,7 @@
|
||||
|
||||
.idea/
|
||||
|
||||
.tmp/
|
||||
|
||||
go2rtc.yaml
|
||||
|
||||
go2rtc.json
|
||||
|
||||
0_test.go
|
||||
|
@@ -2,7 +2,7 @@
|
||||
|
||||
# 0. Prepare images
|
||||
ARG PYTHON_VERSION="3.11"
|
||||
ARG GO_VERSION="1.19"
|
||||
ARG GO_VERSION="1.21"
|
||||
ARG NGROK_VERSION="3"
|
||||
|
||||
FROM python:${PYTHON_VERSION}-alpine AS base
|
||||
@@ -40,7 +40,9 @@ FROM base
|
||||
|
||||
# Install ffmpeg, tini (for signal handling),
|
||||
# and other common tools for the echo source.
|
||||
RUN apk add --no-cache tini ffmpeg bash curl jq
|
||||
# alsa-plugins-pulse for ALSA support (+0MB)
|
||||
# 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
|
||||
|
465
README.md
465
README.md
@@ -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,30 +42,38 @@ 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)
|
||||
* [Source: RTSP](#source-rtsp)
|
||||
* [Source: RTMP](#source-rtmp)
|
||||
* [Source: HTTP](#source-http)
|
||||
* [Source: ONVIF](#source-onvif)
|
||||
* [Source: FFmpeg](#source-ffmpeg)
|
||||
* [Source: FFmpeg Device](#source-ffmpeg-device)
|
||||
* [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)
|
||||
@@ -97,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
|
||||
|
||||
@@ -124,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
|
||||
@@ -156,14 +180,17 @@ Available source types:
|
||||
|
||||
- [rtsp](#source-rtsp) - `RTSP` and `RTSPS` cameras with [two way audio](#two-way-audio) support
|
||||
- [rtmp](#source-rtmp) - `RTMP` streams
|
||||
- [http](#source-http) - `HTTP-FLV`, `MPEG-TS`, `JPEG` (snapshots), `MJPEG` streams
|
||||
- [http](#source-http) - `HTTP-FLV`, `MPEG-TS`, `JPEG` (snapshots), `MJPEG` streams
|
||||
- [onvif](#source-onvif) - get camera `RTSP` link and snapshot link using `ONVIF` protocol
|
||||
- [ffmpeg](#source-ffmpeg) - FFmpeg integration (`HLS`, `files` and many others)
|
||||
- [ffmpeg:device](#source-ffmpeg-device) - local USB Camera or Webcam
|
||||
- [exec](#source-exec) - advanced FFmpeg and GStreamer integration
|
||||
- [exec](#source-exec) - get media from external app output
|
||||
- [echo](#source-echo) - get stream link from bash or python
|
||||
- [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
|
||||
@@ -178,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
|
||||
@@ -197,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
|
||||
```
|
||||
|
||||
@@ -205,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).
|
||||
@@ -229,6 +277,8 @@ Support Content-Type:
|
||||
- **HTTP-MJPEG** (`multipart/x`) - simple MJPEG stream over HTTP
|
||||
- **MPEG-TS** (`video/mpeg`) - legacy [streaming format](https://en.wikipedia.org/wiki/MPEG_transport_stream)
|
||||
|
||||
Source also support HTTP and TCP streams with autodetection for different formats: **MJPEG**, **H.264/H.265 bitstream**, **MPEG-TS**.
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
# [HTTP-FLV] stream in video/x-flv format
|
||||
@@ -239,10 +289,29 @@ streams:
|
||||
|
||||
# [MJPEG] stream will be proxied without modification
|
||||
http_mjpeg: https://mjpeg.sanford.io/count.mjpeg
|
||||
|
||||
# [MJPEG or H.264/H.265 bitstream or MPEG-TS]
|
||||
tcp_magic: tcp://192.168.1.123:12345
|
||||
|
||||
# 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.
|
||||
|
||||
#### Source: ONVIF
|
||||
|
||||
The source is not very useful if you already know RTSP and snapshot links for your camera. But it can be useful if you don't.
|
||||
|
||||
**WebUI > Add** webpage support ONVIF autodiscovery. Your server must be on the same subnet as the camera. If you use docker, you must use "network host".
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
dahua1: onvif://admin:password@192.168.1.123
|
||||
reolink1: onvif://admin:password@192.168.1.123:8000
|
||||
tapo1: onvif://admin:password@192.168.1.123:2020
|
||||
```
|
||||
|
||||
#### Source: FFmpeg
|
||||
|
||||
You can get any stream or file or device via FFmpeg and push it to go2rtc. The app will automatically start FFmpeg with the proper arguments when someone starts watching the stream.
|
||||
@@ -273,7 +342,7 @@ streams:
|
||||
rotate: ffmpeg:rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0#video=h264#rotate=90
|
||||
```
|
||||
|
||||
All trascoding formats has [built-in templates](https://github.com/AlexxIT/go2rtc/blob/master/cmd/ffmpeg/ffmpeg.go): `h264`, `h265`, `opus`, `pcmu`, `pcmu/16000`, `pcmu/48000`, `pcma`, `pcma/16000`, `pcma/48000`, `aac`, `aac/16000`.
|
||||
All trascoding formats has [built-in templates](https://github.com/AlexxIT/go2rtc/blob/master/internal/ffmpeg/ffmpeg.go): `h264`, `h265`, `opus`, `pcmu`, `pcmu/16000`, `pcmu/48000`, `pcma`, `pcma/16000`, `pcma/48000`, `aac`, `aac/16000`.
|
||||
|
||||
But you can override them via YAML config. You can also add your own formats to config and use them with source params.
|
||||
|
||||
@@ -281,45 +350,67 @@ 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
|
||||
|
||||
You can get video from any USB-camera or Webcam as RTSP or WebRTC stream. This is part of FFmpeg integration.
|
||||
|
||||
- check available devices in Web interface
|
||||
- `resolution` and `framerate` must be supported by your camera!
|
||||
- `video_size` and `framerate` must be supported by your camera!
|
||||
- for Linux supported only video for now
|
||||
- for macOS you can stream Facetime camera or whole Desktop!
|
||||
- for macOS important to set right framerate
|
||||
|
||||
Format: `ffmpeg:device?{input-params}#{param1}#{param2}#{param3}`
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
linux_usbcam: ffmpeg:device?video=0&resolution=1280x720#video=h264
|
||||
linux_usbcam: ffmpeg:device?video=0&video_size=1280x720#video=h264
|
||||
windows_webcam: ffmpeg:device?video=0#video=h264
|
||||
macos_facetime: ffmpeg:device?video=0&audio=1&resolution=1280x720&framerate=30#video=h264#audio=pcma
|
||||
macos_facetime: ffmpeg:device?video=0&audio=1&video_size=1280x720&framerate=30#video=h264#audio=pcma
|
||||
```
|
||||
|
||||
**PS.** It is recommended to check the available devices in the WebUI add page.
|
||||
|
||||
#### Source: Exec
|
||||
|
||||
FFmpeg source just a shortcut to exec source. You can get any stream or file or device via FFmpeg or GStreamer and push it to go2rtc via RTSP protocol:
|
||||
Exec source can run any external application and expect data from it. Two transports are supported - **pipe** and **RTSP**.
|
||||
|
||||
If you want to use **RTSP** transport - the command must contain the `{output}` argument in any place. On launch, it will be replaced by the local address of the RTSP server.
|
||||
|
||||
**pipe** reads data from app stdout in different formats: **MJPEG**, **H.264/H.265 bitstream**, **MPEG-TS**.
|
||||
|
||||
The source can be used with:
|
||||
|
||||
- [FFmpeg](https://ffmpeg.org/) - go2rtc ffmpeg source just a shortcut to exec source
|
||||
- [GStreamer](https://gstreamer.freedesktop.org/)
|
||||
- [Raspberry Pi Cameras](https://www.raspberrypi.com/documentation/computers/camera_software.html)
|
||||
- any your own software
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
stream1: exec:ffmpeg -hide_banner -re -stream_loop -1 -i /media/BigBuckBunny.mp4 -c copy -rtsp_transport tcp -f rtsp {output}
|
||||
stream: exec:ffmpeg -re -i /media/BigBuckBunny.mp4 -c copy -rtsp_transport tcp -f rtsp {output}
|
||||
picam_h264: exec:libcamera-vid -t 0 --inline -o -
|
||||
picam_mjpeg: exec:libcamera-vid -t 0 --codec mjpeg -o -
|
||||
```
|
||||
|
||||
#### Source: Echo
|
||||
@@ -352,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:
|
||||
|
||||
@@ -362,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).
|
||||
@@ -380,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
|
||||
@@ -399,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/).
|
||||
@@ -412,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:
|
||||
@@ -424,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
|
||||
|
||||
@@ -437,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:
|
||||
@@ -450,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.
|
||||
@@ -474,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
|
||||
@@ -535,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**
|
||||
|
||||
@@ -571,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:
|
||||
@@ -580,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
|
||||
|
||||
@@ -611,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.
|
||||
@@ -678,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:
|
||||
@@ -770,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:
|
||||
|
||||
@@ -791,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
|
||||
|
||||
@@ -803,11 +1053,19 @@ Provides several features:
|
||||
|
||||
API examples:
|
||||
|
||||
- MP4 stream: `http://192.168.1.123:1984/api/stream.mp4?src=camera1`
|
||||
- MP4 snapshot: `http://192.168.1.123:1984/api/frame.mp4?src=camera1`
|
||||
- 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).
|
||||
@@ -845,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
|
||||
|
||||
@@ -895,7 +1156,7 @@ But it cannot be done for [RTSP](#module-rtsp), [HTTP progressive streaming](#mo
|
||||
|
||||
Without filters:
|
||||
|
||||
- RTSP will provide only the first video and only the first audio
|
||||
- RTSP will provide only the first video and only the first audio (any codec)
|
||||
- MP4 will include only compatible codecs (H264, H265, AAC)
|
||||
- HLS will output in the legacy TS format (H264 without audio)
|
||||
|
||||
@@ -906,23 +1167,29 @@ Some examples:
|
||||
- `rtsp://192.168.1.123:8554/camera1?video=h264&audio=aac&audio=opus` - H264 video codec and two separate audio tracks
|
||||
- `rtsp://192.168.1.123:8554/camera1?video&audio=all` - any video codec and all audio codecs as separate tracks
|
||||
- `http://192.168.1.123:1984/api/stream.m3u8?src=camera1&mp4` - HLS stream with MP4 compatible codecs (HLS/fMP4)
|
||||
- `http://192.168.1.123:1984/api/stream.mp4?src=camera1&video=h264,h265&audio=aac,opus,mp3,pcma,pcmu` - MP4 file with non standard audio codecs, does not work in some players
|
||||
- `http://192.168.1.123:1984/api/stream.m3u8?src=camera1&mp4=flac` - HLS stream with PCMA/PCMU/PCM audio support (HLS/fMP4), won't work on old devices
|
||||
- `http://192.168.1.123:1984/api/stream.mp4?src=camera1&mp4=flac` - MP4 file with PCMA/PCMU/PCM audio support, won't work on old devices (ex. iOS 12)
|
||||
- `http://192.168.1.123:1984/api/stream.mp4?src=camera1&mp4=all` - MP4 file with non standard audio codecs, won't work on some players
|
||||
|
||||
## Codecs madness
|
||||
|
||||
`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, OPUS | H264, H265*, AAC, OPUS, PCMU, PCMA, MP3 |
|
||||
| Desktop Edge | H264, OPUS, PCMU, PCMA | H264, H265*, AAC, OPUS | H264, H265*, AAC, OPUS, PCMU, PCMA, MP3 |
|
||||
| Desktop Safari | H264, H265*, OPUS, PCMU, PCMA | H264, H265, AAC | **no!** |
|
||||
| Desktop Firefox | H264, OPUS, PCMU, PCMA | H264, AAC, OPUS | H264, AAC, OPUS |
|
||||
| Android Chrome 107+ | H264, OPUS, PCMU, PCMA | H264, H265*, AAC, OPUS | H264, ?, AAC, OPUS, PCMU, PCMA, MP3 |
|
||||
| iPad Safari 13+ | H264, H265*, OPUS, PCMU, PCMA | H264, H265, AAC | **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/)
|
||||
@@ -931,9 +1198,9 @@ Some examples:
|
||||
|
||||
**Audio**
|
||||
|
||||
- Go2rtc support [automatic repack](#built-in-transcoding) `PCMA/PCMU/PCM` codecs to `FLAC` for MSE/MP4/HLS so they will work almost anywhere
|
||||
- **WebRTC** audio codecs: `PCMU/8000`, `PCMA/8000`, `OPUS/48000/2`
|
||||
- `OPUS` and `MP3` inside **MP4** is part of the standard, but some players do not support them anyway (especially Apple)
|
||||
- `PCMU` and `PCMA` inside **MP4** isn't a standard, but some players support them, for example Chromium browsers
|
||||
|
||||
**Apple devices**
|
||||
|
||||
@@ -941,6 +1208,45 @@ Some examples:
|
||||
- iPhones don't support MSE technology because it competes with the HTTP Live Streaming (HLS) technology, invented by Apple
|
||||
- HLS is the worst technology for **live** streaming, it still exists only because of iPhones
|
||||
|
||||
**Codec names**
|
||||
|
||||
- H264 = H.264 = AVC (Advanced Video Coding)
|
||||
- H265 = H.265 = HEVC (High Efficiency Video Coding)
|
||||
- PCMU = G.711 PCM (A-law) = PCM A-law (`alaw`)
|
||||
- PCMA = G.711 PCM (µ-law) = PCM mu-law (`mulaw`)
|
||||
- PCM = L16 = PCM signed 16-bit big-endian (`s16be`)
|
||||
- AAC = MPEG4-GENERIC
|
||||
- MP3 = MPEG-1 Audio Layer III or MPEG-2 Audio Layer III
|
||||
|
||||
## Built-in transcoding
|
||||
|
||||
There are no plans to embed complex transcoding algorithms inside go2rtc. [FFmpeg source](#source-ffmpeg) does a great job with this. Including [hardware acceleration](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration) support.
|
||||
|
||||
But go2rtc has some simple algorithms. They are turned on automatically, you do not need to set them up additionally.
|
||||
|
||||
**PCM for MSE/MP4/HLS**
|
||||
|
||||
Go2rtc can pack `PCMA`, `PCMU` and `PCM` codecs into an MP4 container so that they work in all browsers and all built-in players on modern devices. Including Apple QuickTime:
|
||||
|
||||
```
|
||||
PCMA/PCMU => PCM => FLAC => MSE/MP4/HLS
|
||||
```
|
||||
|
||||
**Resample PCMA/PCMU for WebRTC**
|
||||
|
||||
By default WebRTC support only `PCMA/8000` and `PCMU/8000`. But go2rtc can automatically resample PCMA and PCMU codec with with a different sample rate. Also go2rtc can transcode `PCM` codec to `PCMA/8000`, so WebRTC can play it:
|
||||
|
||||
```
|
||||
PCM/xxx => PCMA/8000 => WebRTC
|
||||
PCMA/xxx => PCMA/8000 => WebRTC
|
||||
PCMU/xxx => PCMU/8000 => WebRTC
|
||||
```
|
||||
|
||||
**Important**
|
||||
|
||||
- FLAC codec not supported in a RTSP stream. If you using Frigate or Hass for recording MP4 files with PCMA/PCMU/PCM audio - you should setup transcoding to AAC codec.
|
||||
- PCMA and PCMU are VERY low quality codecs. Them support only 256! different sounds. Use them only when you have no other options.
|
||||
|
||||
## Codecs negotiation
|
||||
|
||||
For example, you want to watch RTSP-stream from [Dahua IPC-K42](https://www.dahuasecurity.com/fr/products/All-Products/Network-Cameras/Wireless-Series/Wi-Fi-Series/4MP/IPC-K42) camera in your Chrome browser.
|
||||
@@ -973,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
api/README.md
Normal file
117
api/README.md
Normal file
@@ -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"}
|
||||
```
|
530
api/openapi.yaml
Normal file
530
api/openapi.yaml
Normal file
@@ -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 |
@@ -1,19 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
echo "Starting go2rtc..." >&2
|
||||
|
||||
readonly config_path="/config"
|
||||
|
||||
if [[ -x "${config_path}/go2rtc" ]]; then
|
||||
readonly binary_path="${config_path}/go2rtc"
|
||||
echo "Using go2rtc binary from '${binary_path}' instead of the embedded one" >&2
|
||||
else
|
||||
readonly binary_path="/usr/local/bin/go2rtc"
|
||||
fi
|
||||
|
||||
# set cwd for go2rtc (for config file, Hass integration, etc)
|
||||
cd "${config_path}" || echo "Could not change working directory to '${config_path}'" >&2
|
||||
|
||||
exec "${binary_path}"
|
@@ -1,4 +0,0 @@
|
||||
**Project layout**
|
||||
|
||||
- https://github.com/golang-standards/project-layout
|
||||
- https://github.com/micro/micro
|
@@ -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()
|
||||
}
|
@@ -1,25 +0,0 @@
|
||||
package dvrip
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/dvrip"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("dvrip", handle)
|
||||
}
|
||||
|
||||
func handle(url string) (core.Producer, error) {
|
||||
conn := dvrip.NewClient(url)
|
||||
if err := conn.Dial(); 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
|
||||
}
|
@@ -1,61 +0,0 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// https://trac.ffmpeg.org/wiki/Capture/Webcam
|
||||
const deviceInputPrefix = "-f avfoundation"
|
||||
|
||||
func deviceInputSuffix(videoIdx, audioIdx int) string {
|
||||
video := findMedia(core.KindVideo, videoIdx)
|
||||
audio := findMedia(core.KindAudio, audioIdx)
|
||||
switch {
|
||||
case video != nil && audio != nil:
|
||||
return `"` + video.ID + `:` + audio.ID + `"`
|
||||
case video != nil:
|
||||
return `"` + video.ID + `"`
|
||||
case audio != nil:
|
||||
return `"` + audio.ID + `"`
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func loadMedias() {
|
||||
cmd := exec.Command(
|
||||
Bin, "-hide_banner", "-list_devices", "true", "-f", "avfoundation", "-i", "dummy",
|
||||
)
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.Stderr = &buf
|
||||
_ = cmd.Run()
|
||||
|
||||
var kind string
|
||||
|
||||
lines := strings.Split(buf.String(), "\n")
|
||||
process:
|
||||
for _, line := range lines {
|
||||
switch {
|
||||
case strings.HasSuffix(line, "video devices:"):
|
||||
kind = core.KindVideo
|
||||
continue
|
||||
case strings.HasSuffix(line, "audio devices:"):
|
||||
kind = core.KindAudio
|
||||
continue
|
||||
case strings.HasPrefix(line, "dummy"):
|
||||
break process
|
||||
}
|
||||
|
||||
// [AVFoundation indev @ 0x7fad54604380] [0] FaceTime HD Camera
|
||||
name := line[42:]
|
||||
media := loadMedia(kind, name)
|
||||
medias = append(medias, media)
|
||||
}
|
||||
}
|
||||
|
||||
func loadMedia(kind, name string) *core.Media {
|
||||
return &core.Media{Kind: kind, ID: name}
|
||||
}
|
@@ -1,48 +0,0 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"io/ioutil"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// https://trac.ffmpeg.org/wiki/Capture/Webcam
|
||||
const deviceInputPrefix = "-f v4l2"
|
||||
|
||||
func deviceInputSuffix(videoIdx, audioIdx int) string {
|
||||
video := findMedia(core.KindVideo, videoIdx)
|
||||
return video.ID
|
||||
}
|
||||
|
||||
func loadMedias() {
|
||||
files, err := ioutil.ReadDir("/dev")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, file := range files {
|
||||
log.Trace().Msg("[ffmpeg] " + file.Name())
|
||||
if strings.HasPrefix(file.Name(), core.KindVideo) {
|
||||
media := loadMedia(core.KindVideo, "/dev/"+file.Name())
|
||||
if media != nil {
|
||||
medias = append(medias, media)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadMedia(kind, name string) *core.Media {
|
||||
cmd := exec.Command(
|
||||
Bin, "-hide_banner", "-f", "v4l2", "-list_formats", "all", "-i", name,
|
||||
)
|
||||
var buf bytes.Buffer
|
||||
cmd.Stderr = &buf
|
||||
_ = cmd.Run()
|
||||
|
||||
if !bytes.Contains(buf.Bytes(), []byte("Raw")) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &core.Media{Kind: kind, ID: name}
|
||||
}
|
@@ -1,57 +0,0 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// https://trac.ffmpeg.org/wiki/DirectShow
|
||||
const deviceInputPrefix = "-f dshow"
|
||||
|
||||
func deviceInputSuffix(videoIdx, audioIdx int) string {
|
||||
video := findMedia(core.KindVideo, videoIdx)
|
||||
audio := findMedia(core.KindAudio, audioIdx)
|
||||
switch {
|
||||
case video != nil && audio != nil:
|
||||
return `video="` + video.ID + `":audio=` + audio.ID + `"`
|
||||
case video != nil:
|
||||
return `video="` + video.ID + `"`
|
||||
case audio != nil:
|
||||
return `audio="` + audio.ID + `"`
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func loadMedias() {
|
||||
cmd := exec.Command(
|
||||
Bin, "-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "",
|
||||
)
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.Stderr = &buf
|
||||
_ = cmd.Run()
|
||||
|
||||
lines := strings.Split(buf.String(), "\r\n")
|
||||
for _, line := range lines {
|
||||
var kind string
|
||||
if strings.HasSuffix(line, "(video)") {
|
||||
kind = core.KindVideo
|
||||
} else if strings.HasSuffix(line, "(audio)") {
|
||||
kind = core.KindAudio
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
// hope we have constant prefix and suffix sizes
|
||||
// [dshow @ 00000181e8d028c0] "VMware Virtual USB Video Device" (video)
|
||||
name := line[28 : len(line)-9]
|
||||
media := loadMedia(kind, name)
|
||||
medias = append(medias, media)
|
||||
}
|
||||
}
|
||||
|
||||
func loadMedia(kind, name string) *core.Media {
|
||||
return &core.Media{Kind: kind, ID: name}
|
||||
}
|
@@ -1,91 +0,0 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/rs/zerolog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
log = app.GetLogger("exec")
|
||||
|
||||
api.HandleFunc("api/devices", handle)
|
||||
}
|
||||
|
||||
func GetInput(src string) (string, error) {
|
||||
if medias == nil {
|
||||
loadMedias()
|
||||
}
|
||||
|
||||
input := deviceInputPrefix
|
||||
|
||||
var videoIdx, audioIdx int
|
||||
if i := strings.IndexByte(src, '?'); i > 0 {
|
||||
query, err := url.ParseQuery(src[i+1:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for key, value := range query {
|
||||
switch key {
|
||||
case "video":
|
||||
videoIdx, _ = strconv.Atoi(value[0])
|
||||
case "audio":
|
||||
audioIdx, _ = strconv.Atoi(value[0])
|
||||
case "framerate":
|
||||
input += " -framerate " + value[0]
|
||||
case "resolution":
|
||||
input += " -video_size " + value[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input += " -i " + deviceInputSuffix(videoIdx, audioIdx)
|
||||
|
||||
return input, nil
|
||||
}
|
||||
|
||||
var Bin string
|
||||
var log zerolog.Logger
|
||||
var medias []*core.Media
|
||||
|
||||
func findMedia(kind string, index int) *core.Media {
|
||||
for _, media := range medias {
|
||||
if media.Kind != kind {
|
||||
continue
|
||||
}
|
||||
if index == 0 {
|
||||
return media
|
||||
}
|
||||
index--
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handle(w http.ResponseWriter, r *http.Request) {
|
||||
if medias == nil {
|
||||
loadMedias()
|
||||
}
|
||||
|
||||
var items []api.Stream
|
||||
var iv, ia int
|
||||
|
||||
for _, media := range medias {
|
||||
var source string
|
||||
switch media.Kind {
|
||||
case core.KindVideo:
|
||||
source = "ffmpeg:device?video=" + strconv.Itoa(iv)
|
||||
iv++
|
||||
case core.KindAudio:
|
||||
source = "ffmpeg:device?audio=" + strconv.Itoa(ia)
|
||||
ia++
|
||||
}
|
||||
items = append(items, api.Stream{Name: media.ID, URL: source})
|
||||
}
|
||||
|
||||
api.ResponseStreams(w, items)
|
||||
}
|
@@ -1,14 +0,0 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
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 -an -vf transpose=1,transpose=1 -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}", args.String())
|
||||
|
||||
args = parseArgs("rtsp://example.com#video=h264#rotate=180#hardware=vaapi")
|
||||
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())
|
||||
}
|
@@ -1,120 +0,0 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
EngineSoftware = "software"
|
||||
EngineVAAPI = "vaapi" // Intel iGPU and AMD GPU
|
||||
EngineV4L2M2M = "v4l2m2m" // Raspberry Pi 3 and 4
|
||||
EngineCUDA = "cuda" // NVidia on Windows and Linux
|
||||
EngineDXVA2 = "dxva2" // Intel on Windows
|
||||
EngineVideoToolbox = "videotoolbox" // macOS
|
||||
)
|
||||
|
||||
var cache = map[string]string{}
|
||||
|
||||
// MakeHardware converts software FFmpeg args to hardware args
|
||||
// empty engine for autoselect
|
||||
func MakeHardware(args *Args, engine string) {
|
||||
for i, codec := range args.codecs {
|
||||
if len(codec) < 12 {
|
||||
continue // skip short line (-c:v libx264...)
|
||||
}
|
||||
|
||||
// get current codec name
|
||||
name := cut(codec, ' ', 1)
|
||||
switch name {
|
||||
case "libx264":
|
||||
name = "h264"
|
||||
case "libx265":
|
||||
name = "h265"
|
||||
case "mjpeg":
|
||||
default:
|
||||
continue // skip unsupported codec
|
||||
}
|
||||
|
||||
// temporary disable probe for H265 and MJPEG
|
||||
if engine == "" && name == "h264" {
|
||||
if engine = cache[name]; engine == "" {
|
||||
engine = ProbeHardware(name)
|
||||
cache[name] = engine
|
||||
}
|
||||
}
|
||||
|
||||
switch engine {
|
||||
case EngineVAAPI:
|
||||
args.input = "-hwaccel vaapi -hwaccel_output_format vaapi " + args.input
|
||||
args.codecs[i] = defaults[name+"/"+engine]
|
||||
|
||||
for i, filter := range args.filters {
|
||||
if strings.HasPrefix(filter, "scale=") {
|
||||
args.filters[i] = "scale_vaapi=" + filter[6:]
|
||||
}
|
||||
if strings.HasPrefix(filter, "transpose=") {
|
||||
if filter == "transpose=1,transpose=1" { // 180 degrees half-turn
|
||||
args.filters[i] = "transpose_vaapi=4" // reversal
|
||||
} else {
|
||||
args.filters[i] = "transpose_vaapi=" + filter[10:]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fix if input doesn't support hwaccel, do nothing when support
|
||||
args.InsertFilter("format=vaapi|nv12,hwupload")
|
||||
|
||||
case EngineCUDA:
|
||||
args.input = "-hwaccel cuda -hwaccel_output_format cuda -extra_hw_frames 2 " + args.input
|
||||
args.codecs[i] = defaults[name+"/"+engine]
|
||||
|
||||
for i, filter := range args.filters {
|
||||
if strings.HasPrefix(filter, "scale=") {
|
||||
args.filters[i] = "scale_cuda=" + filter[6:]
|
||||
}
|
||||
}
|
||||
|
||||
case EngineDXVA2:
|
||||
args.input = "-hwaccel dxva2 -hwaccel_output_format dxva2_vld " + args.input
|
||||
args.codecs[i] = defaults[name+"/"+engine]
|
||||
|
||||
for i, filter := range args.filters {
|
||||
if strings.HasPrefix(filter, "scale=") {
|
||||
args.filters[i] = "scale_qsv=" + filter[6:]
|
||||
}
|
||||
}
|
||||
|
||||
args.InsertFilter("hwmap=derive_device=qsv,format=qsv")
|
||||
|
||||
case EngineVideoToolbox:
|
||||
args.input = "-hwaccel videotoolbox -hwaccel_output_format videotoolbox_vld " + args.input
|
||||
args.codecs[i] = defaults[name+"/"+engine]
|
||||
|
||||
case EngineV4L2M2M:
|
||||
args.codecs[i] = defaults[name+"/"+engine]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func run(arg ...string) bool {
|
||||
err := exec.Command(defaults["bin"], arg...).Run()
|
||||
log.Printf("%v %v", arg, err)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func cut(s string, sep byte, pos int) string {
|
||||
for n := 0; n < pos; n++ {
|
||||
if i := strings.IndexByte(s, sep); i > 0 {
|
||||
s = s[i+1:]
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
if i := strings.IndexByte(s, sep); i > 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
@@ -1,21 +0,0 @@
|
||||
package ffmpeg
|
||||
|
||||
func ProbeHardware(name string) string {
|
||||
switch name {
|
||||
case "h264":
|
||||
if run(
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-c", "h264_videotoolbox", "-f", "null", "-") {
|
||||
return EngineVideoToolbox
|
||||
}
|
||||
|
||||
case "h265":
|
||||
if run(
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-c", "hevc_videotoolbox", "-f", "null", "-") {
|
||||
return EngineVideoToolbox
|
||||
}
|
||||
}
|
||||
|
||||
return EngineSoftware
|
||||
}
|
@@ -1,67 +0,0 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func ProbeHardware(name string) string {
|
||||
if runtime.GOARCH == "arm64" || runtime.GOARCH == "arm" {
|
||||
switch name {
|
||||
case "h264":
|
||||
if run(
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-c", "h264_v4l2m2m", "-f", "null", "-") {
|
||||
return EngineV4L2M2M
|
||||
}
|
||||
|
||||
case "h265":
|
||||
if run(
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-c", "hevc_v4l2m2m", "-f", "null", "-") {
|
||||
return EngineV4L2M2M
|
||||
}
|
||||
}
|
||||
|
||||
return EngineSoftware
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "h264":
|
||||
if run("-init_hw_device", "cuda",
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-c", "h264_nvenc", "-f", "null", "-") {
|
||||
return EngineCUDA
|
||||
}
|
||||
|
||||
if run("-init_hw_device", "vaapi",
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-vf", "format=nv12,hwupload",
|
||||
"-c", "h264_vaapi", "-f", "null", "-") {
|
||||
return EngineVAAPI
|
||||
}
|
||||
|
||||
case "h265":
|
||||
if run("-init_hw_device", "cuda",
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-c", "hevc_nvenc", "-f", "null", "-") {
|
||||
return EngineCUDA
|
||||
}
|
||||
|
||||
if run("-init_hw_device", "vaapi",
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-vf", "format=nv12,hwupload",
|
||||
"-c", "hevc_vaapi", "-f", "null", "-") {
|
||||
return EngineVAAPI
|
||||
}
|
||||
|
||||
case "mjpeg":
|
||||
if run("-init_hw_device", "vaapi",
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-vf", "format=nv12,hwupload",
|
||||
"-c", "mjpeg_vaapi", "-f", "null", "-") {
|
||||
return EngineVAAPI
|
||||
}
|
||||
}
|
||||
|
||||
return EngineSoftware
|
||||
}
|
@@ -1,40 +0,0 @@
|
||||
package ffmpeg
|
||||
|
||||
func ProbeHardware(name string) string {
|
||||
switch name {
|
||||
case "h264":
|
||||
if run("-init_hw_device", "cuda",
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-c", "h264_nvenc", "-f", "null", "-") {
|
||||
return EngineCUDA
|
||||
}
|
||||
|
||||
if run("-init_hw_device", "dxva2",
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-c", "h264_qsv", "-f", "null", "-") {
|
||||
return EngineDXVA2
|
||||
}
|
||||
|
||||
case "h265":
|
||||
if run("-init_hw_device", "cuda",
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-c", "hevc_nvenc", "-f", "null", "-") {
|
||||
return EngineCUDA
|
||||
}
|
||||
|
||||
if run("-init_hw_device", "dxva2",
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-c", "hevc_qsv", "-f", "null", "-") {
|
||||
return EngineDXVA2
|
||||
}
|
||||
|
||||
case "mjpeg":
|
||||
if run("-init_hw_device", "dxva2",
|
||||
"-f", "lavfi", "-i", "testsrc2", "-t", "1",
|
||||
"-c", "mjpeg_qsv", "-f", "null", "-") {
|
||||
return EngineDXVA2
|
||||
}
|
||||
}
|
||||
|
||||
return EngineSoftware
|
||||
}
|
173
cmd/hass/api.go
173
cmd/hass/api.go
@@ -1,173 +0,0 @@
|
||||
package hass
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/cmd/webrtc"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func initAPI() {
|
||||
ok := func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":1,"payload":{}}`))
|
||||
}
|
||||
|
||||
// support https://www.home-assistant.io/integrations/rtsp_to_webrtc/
|
||||
api.HandleFunc("/static", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
api.HandleFunc("/streams", ok)
|
||||
|
||||
api.HandleFunc("/stream/", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
// /stream/{id}/add
|
||||
case strings.HasSuffix(r.RequestURI, "/add"):
|
||||
var v addJSON
|
||||
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// we can get three types of links:
|
||||
// 1. link to go2rtc stream: rtsp://...:8554/{stream_name}
|
||||
// 2. static link to Hass camera
|
||||
// 3. dynamic link to Hass camera
|
||||
stream := streams.Get(v.Name)
|
||||
if stream == nil {
|
||||
// check if it is rtsp link to go2rtc
|
||||
stream = rtspStream(v.Channels.First.Url)
|
||||
if stream != nil {
|
||||
streams.New(v.Name, stream)
|
||||
} else {
|
||||
stream = streams.New(v.Name, "{input}")
|
||||
}
|
||||
}
|
||||
|
||||
stream.SetSource(v.Channels.First.Url)
|
||||
|
||||
ok(w, r)
|
||||
|
||||
// /stream/{id}/channel/0/webrtc
|
||||
default:
|
||||
i := strings.IndexByte(r.RequestURI[8:], '/')
|
||||
if i <= 0 {
|
||||
log.Warn().Msgf("wrong request: %s", r.RequestURI)
|
||||
return
|
||||
}
|
||||
name := r.RequestURI[8 : 8+i]
|
||||
|
||||
stream := streams.Get(name)
|
||||
if stream == nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] parse form")
|
||||
return
|
||||
}
|
||||
|
||||
s := r.FormValue("data")
|
||||
offer, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] sdp64 decode")
|
||||
return
|
||||
}
|
||||
|
||||
s, err = webrtc.ExchangeSDP(stream, string(offer), "WebRTC/Hass sync", r.UserAgent())
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] exchange SDP")
|
||||
return
|
||||
}
|
||||
|
||||
s = base64.StdEncoding.EncodeToString([]byte(s))
|
||||
_, _ = w.Write([]byte(s))
|
||||
}
|
||||
})
|
||||
|
||||
// api from RTSPtoWebRTC
|
||||
api.HandleFunc("/stream", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
str := r.FormValue("sdp64")
|
||||
offer, err := base64.StdEncoding.DecodeString(str)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
src := r.FormValue("url")
|
||||
src, err = url.QueryUnescape(src)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
if stream = rtspStream(src); stream != nil {
|
||||
streams.New(src, stream)
|
||||
} else {
|
||||
stream = streams.New(src, src)
|
||||
}
|
||||
}
|
||||
|
||||
str, err = webrtc.ExchangeSDP(stream, string(offer), "WebRTC/Hass sync", r.UserAgent())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
v := struct {
|
||||
Answer string `json:"sdp64"`
|
||||
}{
|
||||
Answer: base64.StdEncoding.EncodeToString([]byte(str)),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
})
|
||||
}
|
||||
|
||||
func HassioAddr() string {
|
||||
ints, _ := net.Interfaces()
|
||||
|
||||
for _, i := range ints {
|
||||
if i.Name != "hassio" {
|
||||
continue
|
||||
}
|
||||
|
||||
addrs, _ := i.Addrs()
|
||||
for _, addr := range addrs {
|
||||
if addr, ok := addr.(*net.IPNet); ok {
|
||||
return addr.IP.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func rtspStream(url string) *streams.Stream {
|
||||
if strings.HasPrefix(url, "rtsp://") {
|
||||
if i := strings.IndexByte(url[7:], '/'); i > 0 {
|
||||
return streams.Get(url[8+i:])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type addJSON struct {
|
||||
Name string `json:"name"`
|
||||
Channels struct {
|
||||
First struct {
|
||||
//Name string `json:"name"`
|
||||
Url string `json:"url"`
|
||||
} `json:"0"`
|
||||
} `json:"channels"`
|
||||
}
|
145
cmd/hass/hass.go
145
cmd/hass/hass.go
@@ -1,145 +0,0 @@
|
||||
package hass
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/roborock"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/rs/zerolog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var conf struct {
|
||||
API struct {
|
||||
Listen string `json:"listen"`
|
||||
} `yaml:"api"`
|
||||
Mod struct {
|
||||
Config string `yaml:"config"`
|
||||
} `yaml:"hass"`
|
||||
}
|
||||
|
||||
app.LoadConfig(&conf)
|
||||
|
||||
log = app.GetLogger("hass")
|
||||
|
||||
initAPI()
|
||||
|
||||
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)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
streams.HandleFunc("hass", func(url string) (core.Producer, error) {
|
||||
if hurl := entries[url[5:]]; hurl != "" {
|
||||
return streams.GetProducer(hurl)
|
||||
}
|
||||
return nil, fmt.Errorf("can't get url: %s", url)
|
||||
})
|
||||
|
||||
// for Addon listen on hassio interface, so WebUI feature will work
|
||||
if conf.API.Listen == "127.0.0.1:1984" {
|
||||
if addr := HassioAddr(); addr != "" {
|
||||
addr += ":1984"
|
||||
go func() {
|
||||
log.Info().Str("addr", addr).Msg("[hass] listen")
|
||||
if err := http.ListenAndServe(addr, api.Handler); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func importEntries(config string) map[string]string {
|
||||
// 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
|
||||
}
|
||||
|
||||
var storage struct {
|
||||
Data struct {
|
||||
Entries []struct {
|
||||
Title string `json:"title"`
|
||||
Domain string `json:"domain"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Options json.RawMessage `json:"options"`
|
||||
} `json:"entries"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(b, &storage); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
urls := map[string]string{}
|
||||
|
||||
for _, entrie := range storage.Data.Entries {
|
||||
switch entrie.Domain {
|
||||
case "generic":
|
||||
var options struct {
|
||||
StreamSource string `json:"stream_source"`
|
||||
}
|
||||
if err = json.Unmarshal(entrie.Options, &options); err != nil {
|
||||
continue
|
||||
}
|
||||
urls[entrie.Title] = options.StreamSource
|
||||
|
||||
case "homekit_controller":
|
||||
if !bytes.Contains(entrie.Data, []byte("iOSPairingId")) {
|
||||
continue
|
||||
}
|
||||
|
||||
var data struct {
|
||||
ClientID string `json:"iOSPairingId"`
|
||||
ClientPrivate string `json:"iOSDeviceLTSK"`
|
||||
ClientPublic string `json:"iOSDeviceLTPK"`
|
||||
DeviceID string `json:"AccessoryPairingID"`
|
||||
DevicePublic string `json:"AccessoryLTPK"`
|
||||
DeviceHost string `json:"AccessoryIP"`
|
||||
DevicePort uint16 `json:"AccessoryPort"`
|
||||
}
|
||||
if err = json.Unmarshal(entrie.Data, &data); err != nil {
|
||||
continue
|
||||
}
|
||||
urls[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,
|
||||
data.DeviceID, data.DevicePublic,
|
||||
)
|
||||
|
||||
case "roborock":
|
||||
_ = json.Unmarshal(entrie.Data, &roborock.Auth)
|
||||
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
log.Info().Str("url", "hass:"+entrie.Title).Msg("[hass] load stream")
|
||||
//streams.Get("hass:" + entrie.Title)
|
||||
}
|
||||
|
||||
return urls
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
@@ -1,140 +0,0 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app/store"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/mdns"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func apiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
items := make([]any, 0)
|
||||
|
||||
for name, src := range store.GetDict("streams") {
|
||||
if src := src.(string); strings.HasPrefix(src, "homekit") {
|
||||
u, err := url.Parse(src)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
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)
|
||||
|
||||
case "POST":
|
||||
// TODO: post params...
|
||||
|
||||
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()))
|
||||
}
|
||||
|
||||
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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hkPair(deviceID, pin, name string) (err error) {
|
||||
var conn *hap.Conn
|
||||
|
||||
if conn, err = hap.Pair(deviceID, pin); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}()
|
||||
|
||||
if err = conn.ListPairings(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = conn.DeletePairing(conn.ClientID); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
|
||||
delete(dict, name)
|
||||
|
||||
return store.Set("streams", dict)
|
||||
}
|
||||
|
||||
return 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"`
|
||||
}
|
@@ -1,32 +0,0 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/srtp"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/homekit"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
log = app.GetLogger("homekit")
|
||||
|
||||
streams.HandleFunc("homekit", streamHandler)
|
||||
|
||||
api.HandleFunc("api/homekit", apiHandler)
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
|
||||
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
|
||||
}
|
@@ -1,65 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mjpeg"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mpegts"
|
||||
"github.com/AlexxIT/go2rtc/pkg/rtmp"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("http", handle)
|
||||
streams.HandleFunc("https", handle)
|
||||
}
|
||||
|
||||
func handle(url string) (core.Producer, error) {
|
||||
// first we get the Content-Type to define supported producer
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := tcp.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, errors.New(res.Status)
|
||||
}
|
||||
|
||||
ct := res.Header.Get("Content-Type")
|
||||
if i := strings.IndexByte(ct, ';'); i > 0 {
|
||||
ct = ct[:i]
|
||||
}
|
||||
|
||||
switch ct {
|
||||
case "image/jpeg", "multipart/x-mixed-replace":
|
||||
return mjpeg.NewClient(res), nil
|
||||
|
||||
case "video/x-flv":
|
||||
var conn *rtmp.Client
|
||||
if conn, err = rtmp.Accept(res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = conn.Describe(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
|
||||
case "video/mpeg":
|
||||
client := mpegts.NewClient(res)
|
||||
if err = client.Handle(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unsupported Content-Type: %s", ct)
|
||||
}
|
162
cmd/mp4/mp4.go
162
cmd/mp4/mp4.go
@@ -1,162 +0,0 @@
|
||||
package mp4
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mp4"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/rs/zerolog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
log = app.GetLogger("mp4")
|
||||
|
||||
api.HandleWS("mse", handlerWSMSE)
|
||||
api.HandleWS("mp4", handlerWSMP4)
|
||||
|
||||
api.HandleFunc("api/frame.mp4", handlerKeyframe)
|
||||
api.HandleFunc("api/stream.mp4", handlerMP4)
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
|
||||
func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
|
||||
// Chrome 105 does two requests: without Range and with `Range: bytes=0-`
|
||||
ua := r.UserAgent()
|
||||
if strings.Contains(ua, " Chrome/") {
|
||||
if r.Header.Values("Range") == nil {
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
src := r.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
exit := make(chan []byte)
|
||||
|
||||
cons := &mp4.Segment{OnlyKeyframe: true}
|
||||
cons.Listen(func(msg any) {
|
||||
if data, ok := msg.([]byte); ok && exit != nil {
|
||||
exit <- data
|
||||
exit = nil
|
||||
}
|
||||
})
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
data := <-exit
|
||||
|
||||
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)
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
|
||||
func handlerMP4(w http.ResponseWriter, r *http.Request) {
|
||||
log.Trace().Msgf("[mp4] %s %+v", r.Method, r.Header)
|
||||
|
||||
// Chrome has Safari in UA, so check first Chrome and later Safari
|
||||
ua := r.UserAgent()
|
||||
if strings.Contains(ua, " Chrome/") {
|
||||
if r.Header.Values("Range") == nil {
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
} else if strings.Contains(ua, " Safari/") {
|
||||
// auto redirect to HLS/fMP4 format, because Safari not support MP4 stream
|
||||
url := "stream.m3u8?" + r.URL.RawQuery
|
||||
if !r.URL.Query().Has("mp4") {
|
||||
url += "&mp4"
|
||||
}
|
||||
|
||||
http.Redirect(w, r, url, http.StatusMovedPermanently)
|
||||
return
|
||||
}
|
||||
|
||||
src := r.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
exit := make(chan error)
|
||||
|
||||
cons := &mp4.Consumer{
|
||||
RemoteAddr: tcp.RemoteAddr(r),
|
||||
UserAgent: r.UserAgent(),
|
||||
Medias: core.ParseQuery(r.URL.Query()),
|
||||
}
|
||||
|
||||
cons.Listen(func(msg any) {
|
||||
if data, ok := msg.([]byte); ok {
|
||||
if _, err := w.Write(data); err != nil && exit != nil {
|
||||
exit <- err
|
||||
exit = nil
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
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()
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = w.Write(data); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
cons.Start()
|
||||
|
||||
var duration *time.Timer
|
||||
if s := r.URL.Query().Get("duration"); s != "" {
|
||||
if i, _ := strconv.Atoi(s); i > 0 {
|
||||
duration = time.AfterFunc(time.Second*time.Duration(i), func() {
|
||||
if exit != nil {
|
||||
exit <- nil
|
||||
exit = nil
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
err = <-exit
|
||||
|
||||
log.Trace().Err(err).Caller().Send()
|
||||
|
||||
if duration != nil {
|
||||
duration.Stop()
|
||||
}
|
||||
}
|
138
cmd/mp4/ws.go
138
cmd/mp4/ws.go
@@ -1,138 +0,0 @@
|
||||
package mp4
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/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)
|
||||
if stream == nil {
|
||||
return errors.New(api.StreamNotFound)
|
||||
}
|
||||
|
||||
cons := &mp4.Consumer{
|
||||
RemoteAddr: tcp.RemoteAddr(tr.Request),
|
||||
UserAgent: tr.Request.UserAgent(),
|
||||
}
|
||||
|
||||
if codecs := msg.String(); codecs != "" {
|
||||
log.Trace().Str("codecs", codecs).Msgf("[mp4] new WS/MSE consumer")
|
||||
cons.Medias = parseMedias(codecs, true)
|
||||
}
|
||||
|
||||
cons.Listen(func(msg any) {
|
||||
if data, ok := msg.([]byte); ok {
|
||||
tr.Write(data)
|
||||
}
|
||||
})
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Debug().Err(err).Msg("[mp4] add consumer")
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
if stream == nil {
|
||||
return errors.New(api.StreamNotFound)
|
||||
}
|
||||
|
||||
cons := &mp4.Segment{
|
||||
RemoteAddr: tcp.RemoteAddr(tr.Request),
|
||||
UserAgent: tr.Request.UserAgent(),
|
||||
OnlyKeyframe: true,
|
||||
}
|
||||
|
||||
if codecs := msg.String(); codecs != "" {
|
||||
log.Trace().Str("codecs", codecs).Msgf("[mp4] new WS/MP4 consumer")
|
||||
cons.Medias = parseMedias(codecs, false)
|
||||
}
|
||||
|
||||
cons.Listen(func(msg any) {
|
||||
if data, ok := msg.([]byte); ok {
|
||||
tr.Write(data)
|
||||
}
|
||||
})
|
||||
|
||||
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.OnClose(func() {
|
||||
stream.RemoveConsumer(cons)
|
||||
})
|
||||
|
||||
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.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
|
||||
}
|
@@ -1,43 +0,0 @@
|
||||
package mpegts
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mpegts"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
api.HandleFunc("api/stream.ts", apiHandle)
|
||||
}
|
||||
|
||||
func apiHandle(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
dst := r.URL.Query().Get("dst")
|
||||
stream := streams.Get(dst)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
res := &http.Response{Body: r.Body, Request: r}
|
||||
client := mpegts.NewClient(res)
|
||||
|
||||
if err := client.Handle(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
stream.AddProducer(client)
|
||||
|
||||
if err := client.Handle(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
stream.RemoveProducer(client)
|
||||
}
|
@@ -1,62 +0,0 @@
|
||||
package rtmp
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/rtmp"
|
||||
"github.com/rs/zerolog/log"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("rtmp", streamsHandle)
|
||||
|
||||
api.HandleFunc("api/stream.flv", apiHandle)
|
||||
}
|
||||
|
||||
func streamsHandle(url string) (core.Producer, error) {
|
||||
conn := rtmp.NewClient(url)
|
||||
if err := conn.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := conn.Describe(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func apiHandle(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
dst := r.URL.Query().Get("dst")
|
||||
stream := streams.Get(dst)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
res := &http.Response{Body: r.Body, Request: r}
|
||||
client, err := rtmp.Accept(res)
|
||||
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 {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
|
||||
stream.RemoveProducer(client)
|
||||
}
|
@@ -1,45 +0,0 @@
|
||||
package srtp
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/pkg/srtp"
|
||||
"net"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var cfg struct {
|
||||
Mod struct {
|
||||
Listen string `yaml:"listen"`
|
||||
} `yaml:"srtp"`
|
||||
}
|
||||
|
||||
// default config
|
||||
cfg.Mod.Listen = ":8443"
|
||||
|
||||
// load config from YAML
|
||||
app.LoadConfig(&cfg)
|
||||
|
||||
if cfg.Mod.Listen == "" {
|
||||
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()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
var Server *srtp.Server
|
@@ -1,41 +0,0 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func GetProducer(url string) (core.Producer, error) {
|
||||
handler := getHandler(url)
|
||||
if handler == nil {
|
||||
return nil, fmt.Errorf("unsupported scheme: %s", url)
|
||||
}
|
||||
return handler(url)
|
||||
}
|
@@ -1,116 +0,0 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app/store"
|
||||
"github.com/rs/zerolog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
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 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 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
|
||||
}
|
||||
|
||||
if stream := Get(name); stream != nil {
|
||||
stream.SetSource(src)
|
||||
} else {
|
||||
New(name, 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{}
|
@@ -1,229 +0,0 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
type Stream struct {
|
||||
producers []*Producer
|
||||
consumers []core.Consumer
|
||||
mu sync.Mutex
|
||||
requests int32
|
||||
}
|
||||
|
||||
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
|
||||
case []any:
|
||||
s := new(Stream)
|
||||
for _, source := range source {
|
||||
prod := &Producer{url: source.(string)}
|
||||
s.producers = append(s.producers, prod)
|
||||
}
|
||||
return s
|
||||
case *Stream:
|
||||
return source
|
||||
case map[string]any:
|
||||
return NewStream(source["url"])
|
||||
case nil:
|
||||
return new(Stream)
|
||||
default:
|
||||
panic(core.Caller())
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
atomic.AddInt32(&s.requests, 1)
|
||||
|
||||
var producers []*Producer // matched producers for consumer
|
||||
|
||||
var codecs string
|
||||
|
||||
// Step 1. Get consumer medias
|
||||
for _, consMedia := range cons.GetMedias() {
|
||||
|
||||
producers:
|
||||
for _, prod := range s.producers {
|
||||
if err = prod.Dial(); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Step 2. Get producer medias (not tracks yet)
|
||||
for _, prodMedia := range prod.GetMedias() {
|
||||
collectCodecs(prodMedia, &codecs)
|
||||
|
||||
// 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:
|
||||
// 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:
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
producers = append(producers, 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(producers) == 0 {
|
||||
if len(codecs) > 0 {
|
||||
return errors.New("codecs not match: " + codecs)
|
||||
}
|
||||
|
||||
for i, producer := range s.producers {
|
||||
if producer.lastErr != nil {
|
||||
return fmt.Errorf("source %d error: %w", i, producer.lastErr)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("sources unavailable: %d", len(s.producers))
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.consumers = append(s.consumers, cons)
|
||||
s.mu.Unlock()
|
||||
|
||||
// there may be duplicates, but that's not a problem
|
||||
for _, prod := range producers {
|
||||
prod.start()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Stream) RemoveConsumer(cons core.Consumer) {
|
||||
_ = cons.Stop()
|
||||
|
||||
s.mu.Lock()
|
||||
for i, consumer := range s.consumers {
|
||||
if consumer == cons {
|
||||
s.consumers = append(s.consumers[:i], s.consumers[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
s.stopProducers()
|
||||
}
|
||||
|
||||
func (s *Stream) AddProducer(prod core.Producer) {
|
||||
producer := &Producer{conn: prod, state: stateExternal}
|
||||
s.mu.Lock()
|
||||
s.producers = append(s.producers, producer)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Stream) RemoveProducer(prod core.Producer) {
|
||||
s.mu.Lock()
|
||||
for i, producer := range s.producers {
|
||||
if producer.conn == prod {
|
||||
s.producers = append(s.producers[:i], s.producers[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Stream) stopProducers() {
|
||||
s.mu.Lock()
|
||||
producers:
|
||||
for _, producer := range s.producers {
|
||||
for _, track := range producer.receivers {
|
||||
if len(track.Senders()) > 0 {
|
||||
continue producers
|
||||
}
|
||||
}
|
||||
producer.stop()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Stream) MarshalJSON() ([]byte, error) {
|
||||
if !s.mu.TryLock() {
|
||||
log.Warn().Msgf("[streams] json locked")
|
||||
return json.Marshal(nil)
|
||||
}
|
||||
|
||||
var info struct {
|
||||
Producers []*Producer `json:"producers"`
|
||||
Consumers []core.Consumer `json:"consumers"`
|
||||
}
|
||||
info.Producers = s.producers
|
||||
info.Consumers = s.consumers
|
||||
|
||||
s.mu.Unlock()
|
||||
|
||||
return json.Marshal(info)
|
||||
}
|
||||
|
||||
func collectCodecs(media *core.Media, codecs *string) {
|
||||
if media.Direction == core.DirectionRecvonly {
|
||||
return
|
||||
}
|
||||
|
||||
for _, codec := range media.Codecs {
|
||||
name := codec.Name
|
||||
if name == core.CodecAAC {
|
||||
name = "AAC"
|
||||
}
|
||||
if strings.Contains(*codecs, name) {
|
||||
continue
|
||||
}
|
||||
if len(*codecs) > 0 {
|
||||
*codecs += ","
|
||||
}
|
||||
*codecs += name
|
||||
}
|
||||
}
|
@@ -1,178 +0,0 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"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] {
|
||||
case "ws", "wss":
|
||||
return asyncClient(url)
|
||||
case "http", "https":
|
||||
return syncClient(url)
|
||||
}
|
||||
}
|
||||
return nil, errors.New("unsupported url: " + url)
|
||||
}
|
||||
|
||||
// asyncClient can connect only to go2rtc server
|
||||
// ex: ws://localhost:1984/api/ws?src=camera1
|
||||
func asyncClient(url string) (core.Producer, error) {
|
||||
// 1. Connect to signalign server
|
||||
ws, _, err := websocket.DefaultDialer.Dial(url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = ws.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// 2. Create PeerConnection
|
||||
pc, err := PeerConnection(true)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var sendOffer 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})
|
||||
}
|
||||
})
|
||||
|
||||
medias := []*core.Media{
|
||||
{Kind: core.KindVideo, Direction: core.DirectionRecvonly},
|
||||
{Kind: core.KindAudio, Direction: core.DirectionRecvonly},
|
||||
{Kind: core.KindAudio, Direction: core.DirectionSendonly},
|
||||
}
|
||||
|
||||
// 3. Create offer
|
||||
offer, err := prod.CreateOffer(medias)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4. Send offer
|
||||
msg := &api.Message{Type: "webrtc/offer", Value: offer}
|
||||
if err = ws.WriteJSON(msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sendOffer.Done()
|
||||
|
||||
// 5. Get answer
|
||||
if err = ws.ReadJSON(msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if msg.Type != "webrtc/answer" {
|
||||
return nil, errors.New("wrong answer: " + msg.Type)
|
||||
}
|
||||
|
||||
answer := msg.String()
|
||||
if err = prod.SetAnswer(answer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 6. Continue to receiving candidates
|
||||
go func() {
|
||||
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)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "webrtc/candidate":
|
||||
if msg.Value != nil {
|
||||
_ = prod.AddCandidate(msg.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ = ws.Close()
|
||||
}()
|
||||
|
||||
return prod, nil
|
||||
}
|
||||
|
||||
// syncClient - support WebRTC-HTTP Egress Protocol (WHEP)
|
||||
// ex: http://localhost:1984/api/webrtc?src=camera1
|
||||
func syncClient(url string) (core.Producer, error) {
|
||||
// 2. Create PeerConnection
|
||||
pc, err := PeerConnection(true)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prod := webrtc.NewConn(pc)
|
||||
prod.Desc = "WebRTC/WHEP sync"
|
||||
prod.Mode = core.ModeActiveProducer
|
||||
|
||||
medias := []*core.Media{
|
||||
{Kind: core.KindVideo, Direction: core.DirectionRecvonly},
|
||||
{Kind: core.KindAudio, Direction: core.DirectionRecvonly},
|
||||
}
|
||||
|
||||
// 3. Create offer
|
||||
offer, err := prod.CreateCompleteOffer(medias)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, strings.NewReader(offer))
|
||||
req.Header.Set("Content-Type", MimeSDP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := http.Client{Timeout: time.Second * 5000}
|
||||
defer client.CloseIdleConnections()
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
answer, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = prod.SetAnswer(string(answer)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return prod, nil
|
||||
}
|
20
examples/go2rtc_hass/main.go
Normal file
20
examples/go2rtc_hass/main.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/hass"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app.Init()
|
||||
streams.Init()
|
||||
|
||||
api.Init()
|
||||
|
||||
hass.Init()
|
||||
|
||||
shell.RunUntilSignal()
|
||||
}
|
17
examples/go2rtc_rtsp/main.go
Normal file
17
examples/go2rtc_rtsp/main.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app.Init()
|
||||
streams.Init()
|
||||
|
||||
rtsp.Init()
|
||||
|
||||
shell.RunUntilSignal()
|
||||
}
|
123
examples/homekit_info/main.go
Normal file
123
examples/homekit_info/main.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
39
examples/mdns/main.go
Normal file
39
examples/mdns/main.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
63
go.mod
63
go.mod
@@ -1,59 +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/stretchr/testify v1.8.2
|
||||
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.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
|
||||
)
|
||||
|
172
go.sum
172
go.sum
@@ -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,104 +50,111 @@ 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=
|
||||
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f h1:1R9KdKjCNSd7F8iGTxIpoID9prlYH8nuNYKt0XvweHA=
|
||||
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f/go.mod h1:vQhwQ4meQEDfahT5kd61wLAF5AAeh5ZPLVI4JJ/tYo8=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.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=
|
||||
@@ -171,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=
|
||||
@@ -183,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=
|
||||
@@ -227,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=
|
||||
|
@@ -1,7 +1,9 @@
|
||||
# syntax=docker/dockerfile:labs
|
||||
|
||||
# 0. Prepare images
|
||||
# only debian 12 (bookworm) has latest ffmpeg
|
||||
ARG DEBIAN_VERSION="bookworm-slim"
|
||||
ARG GO_VERSION="1.19-buster"
|
||||
ARG GO_VERSION="1.21-bookworm"
|
||||
ARG NGROK_VERSION="3"
|
||||
|
||||
FROM debian:${DEBIAN_VERSION} AS base
|
||||
@@ -10,43 +12,55 @@ 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
|
||||
|
||||
# Cache dependencies
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -ldflags "-s -w" -trimpath
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -ldflags "-s -w" -trimpath
|
||||
|
||||
|
||||
# 2. Collect all files
|
||||
FROM scratch AS rootfs
|
||||
|
||||
COPY --from=build /build/go2rtc /usr/local/bin/
|
||||
COPY --from=ngrok /bin/ngrok /usr/local/bin/
|
||||
COPY ./build/docker/run.sh /
|
||||
|
||||
COPY --link --from=build /build/go2rtc /usr/local/bin/
|
||||
COPY --link --from=ngrok /bin/ngrok /usr/local/bin/
|
||||
|
||||
# 3. Final image
|
||||
FROM base
|
||||
|
||||
# Prepare apt for buildkit cache
|
||||
RUN rm -f /etc/apt/apt.conf.d/docker-clean \
|
||||
&& echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' >/etc/apt/apt.conf.d/keep-cache
|
||||
# Install ffmpeg, bash (for run.sh), tini (for signal handling),
|
||||
# and other common tools for the echo source.
|
||||
# non-free for Intel QSV support (not used by go2rtc, just for tests)
|
||||
RUN echo 'deb http://deb.debian.org/debian bookworm non-free' > /etc/apt/sources.list.d/debian-non-free.list && \
|
||||
apt-get -y update && apt-get -y install tini ffmpeg python3 curl jq intel-media-va-driver-non-free
|
||||
# libasound2-plugins for ALSA support
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked --mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
echo 'deb http://deb.debian.org/debian bookworm non-free' > /etc/apt/sources.list.d/debian-non-free.list && \
|
||||
apt-get -y update && apt-get -y install tini ffmpeg \
|
||||
python3 curl jq \
|
||||
intel-media-va-driver-non-free \
|
||||
libasound2-plugins
|
||||
|
||||
COPY --link --from=rootfs / /
|
||||
|
||||
COPY --from=rootfs / /
|
||||
|
||||
RUN chmod a+x /run.sh && mkdir -p /config
|
||||
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
|
||||
VOLUME /config
|
||||
WORKDIR /config
|
||||
# https://github.com/NVIDIA/nvidia-docker/wiki/Installation-(Native-GPU-Support)
|
||||
ENV NVIDIA_VISIBLE_DEVICES all
|
||||
ENV NVIDIA_DRIVER_CAPABILITIES compute,video,utility
|
||||
|
||||
CMD ["/run.sh"]
|
||||
CMD ["go2rtc", "-config", "/config/go2rtc.yaml"]
|
||||
|
4
internal/api/README.md
Normal file
4
internal/api/README.md
Normal file
@@ -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/
|
@@ -1,15 +1,19 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/cmd/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
|
||||
@@ -121,10 +204,12 @@ func middlewareCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
var ln net.Listener
|
||||
var mu sync.Mutex
|
||||
|
||||
func apiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -132,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) {
|
||||
@@ -148,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/cmd/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
|
||||
}
|
@@ -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.3.1"
|
||||
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
|
35
internal/app/migrate.go
Normal file
35
internal/app/migrate.go
Normal file
@@ -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,17 +1,17 @@
|
||||
package tapo
|
||||
package bubble
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/bubble"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tapo"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("tapo", handle)
|
||||
streams.HandleFunc("bubble", handle)
|
||||
}
|
||||
|
||||
func handle(url string) (core.Producer, error) {
|
||||
conn := tapo.NewClient(url)
|
||||
conn := bubble.NewClient(url)
|
||||
if err := conn.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
@@ -1,8 +1,8 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
)
|
||||
|
||||
var stackSkip = [][]byte{
|
||||
@@ -13,15 +15,18 @@ var stackSkip = [][]byte{
|
||||
[]byte("created by os/signal.Notify"),
|
||||
|
||||
// api/stack.go
|
||||
[]byte("github.com/AlexxIT/go2rtc/cmd/api.stackHandler"),
|
||||
[]byte("github.com/AlexxIT/go2rtc/internal/api.stackHandler"),
|
||||
|
||||
// api/api.go
|
||||
[]byte("created by github.com/AlexxIT/go2rtc/cmd/api.Init"),
|
||||
[]byte("created by github.com/AlexxIT/go2rtc/internal/api.Init"),
|
||||
[]byte("created by net/http.(*connReader).startBackgroundRead"),
|
||||
[]byte("created by net/http.(*Server).Serve"), // TODO: why two?
|
||||
|
||||
[]byte("created by github.com/AlexxIT/go2rtc/cmd/rtsp.Init"),
|
||||
[]byte("created by github.com/AlexxIT/go2rtc/cmd/srtp.Init"),
|
||||
[]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"),
|
||||
@@ -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)
|
||||
}
|
174
internal/dvrip/dvrip.go
Normal file
174
internal/dvrip/dvrip.go
Normal file
@@ -0,0 +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) {
|
||||
client, err := dvrip.Dial(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
const Port = 34569 // UDP port number for dvrip discovery
|
||||
|
||||
func apiDvrip(w http.ResponseWriter, r *http.Request) {
|
||||
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"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
"os/exec"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
)
|
||||
|
||||
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
|
||||
})
|
||||
}
|
@@ -5,26 +5,22 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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/magic"
|
||||
pkg "github.com/AlexxIT/go2rtc/pkg/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
"github.com/rs/zerolog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
// depends on RTSP server
|
||||
if rtsp.Port == "" {
|
||||
return
|
||||
}
|
||||
|
||||
rtsp.HandleFunc(func(conn *pkg.Conn) bool {
|
||||
waitersMu.Lock()
|
||||
waiter := waiters[conn.URL.Path]
|
||||
@@ -43,30 +39,58 @@ func Init() {
|
||||
}
|
||||
})
|
||||
|
||||
streams.HandleFunc("exec", Handle)
|
||||
streams.HandleFunc("exec", execHandle)
|
||||
|
||||
log = app.GetLogger("exec")
|
||||
}
|
||||
|
||||
func Handle(url string) (core.Producer, error) {
|
||||
sum := md5.Sum([]byte(url))
|
||||
path := "/" + hex.EncodeToString(sum[:])
|
||||
func execHandle(url string) (core.Producer, error) {
|
||||
var path string
|
||||
|
||||
url = strings.Replace(
|
||||
url, "{output}", "rtsp://localhost:"+rtsp.Port+path, 1,
|
||||
)
|
||||
args := shell.QuoteSplit(url[5:]) // remove `exec:`
|
||||
for i, arg := range args {
|
||||
if arg == "{output}" {
|
||||
if rtsp.Port == "" {
|
||||
return nil, errors.New("rtsp module disabled")
|
||||
}
|
||||
|
||||
// remove `exec:`
|
||||
args := shell.QuoteSplit(url[5:])
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
|
||||
if log.Trace().Enabled() {
|
||||
cmd.Stdout = os.Stdout
|
||||
sum := md5.Sum([]byte(url))
|
||||
path = "/" + hex.EncodeToString(sum[:])
|
||||
args[i] = "rtsp://127.0.0.1:" + rtsp.Port + path
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
if log.Debug().Enabled() {
|
||||
cmd.Stderr = os.Stderr
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
return handlePipe(url, cmd)
|
||||
}
|
||||
|
||||
return handleRTSP(url, path, cmd)
|
||||
}
|
||||
|
||||
func handlePipe(url string, cmd *exec.Cmd) (core.Producer, error) {
|
||||
r, err := PipeCloser(cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return magic.Open(r)
|
||||
}
|
||||
|
||||
func handleRTSP(url, path string, cmd *exec.Cmd) (core.Producer, error) {
|
||||
if log.Trace().Enabled() {
|
||||
cmd.Stdout = os.Stdout
|
||||
}
|
||||
|
||||
ch := make(chan core.Producer)
|
||||
|
||||
waitersMu.Lock()
|
30
internal/exec/pipe.go
Normal file
30
internal/exec/pipe.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"os/exec"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
// PipeCloser - return StdoutPipe that Kill cmd on Close call
|
||||
func PipeCloser(cmd *exec.Cmd) (io.ReadCloser, error) {
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// add buffer for pipe reader to reduce syscall
|
||||
return pipeCloser{bufio.NewReaderSize(stdout, core.BufferSize), stdout, cmd}, nil
|
||||
}
|
||||
|
||||
type pipeCloser struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
func (p pipeCloser) Close() error {
|
||||
return core.Any(p.Closer.Close(), p.cmd.Process.Kill(), p.cmd.Wait())
|
||||
}
|
86
internal/ffmpeg/device/device_darwin.go
Normal file
86
internal/ffmpeg/device/device_darwin.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
func queryToInput(query url.Values) string {
|
||||
video := query.Get("video")
|
||||
audio := query.Get("audio")
|
||||
|
||||
if video == "" && audio == "" {
|
||||
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() {
|
||||
// [AVFoundation indev @ 0x147f04510] AVFoundation video devices:
|
||||
// [AVFoundation indev @ 0x147f04510] [0] FaceTime HD Camera
|
||||
// [AVFoundation indev @ 0x147f04510] [1] Capture screen 0
|
||||
// [AVFoundation indev @ 0x147f04510] AVFoundation audio devices:
|
||||
// [AVFoundation indev @ 0x147f04510] [0] MacBook Pro Microphone
|
||||
cmd := exec.Command(
|
||||
Bin, "-hide_banner", "-list_devices", "true", "-f", "avfoundation", "-i", "",
|
||||
)
|
||||
b, _ := cmd.CombinedOutput()
|
||||
|
||||
re := regexp.MustCompile(`\[\d+] (.+)`)
|
||||
|
||||
var kind string
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
switch {
|
||||
case strings.HasSuffix(line, "video devices:"):
|
||||
kind = core.KindVideo
|
||||
continue
|
||||
case strings.HasSuffix(line, "audio devices:"):
|
||||
kind = core.KindAudio
|
||||
continue
|
||||
}
|
||||
|
||||
m := re.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
name := m[1]
|
||||
|
||||
switch kind {
|
||||
case core.KindVideo:
|
||||
videos = append(videos, name)
|
||||
case core.KindAudio:
|
||||
audios = append(audios, name)
|
||||
}
|
||||
|
||||
streams = append(streams, &api.Source{
|
||||
Name: name, URL: "ffmpeg:device?" + kind + "=" + name,
|
||||
})
|
||||
}
|
||||
}
|
99
internal/ffmpeg/device/device_linux.go
Normal file
99
internal/ffmpeg/device/device_linux.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
func queryToInput(query url.Values) string {
|
||||
if video := query.Get("video"); video != "" {
|
||||
// https://ffmpeg.org/ffmpeg-devices.html#video4linux2_002c-v4l2
|
||||
input := "-f v4l2"
|
||||
|
||||
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 ""
|
||||
}
|
||||
|
||||
func initDevices() {
|
||||
files, err := os.ReadDir("/dev")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if !strings.HasPrefix(file.Name(), core.KindVideo) {
|
||||
continue
|
||||
}
|
||||
|
||||
name := "/dev/" + file.Name()
|
||||
|
||||
cmd := exec.Command(
|
||||
Bin, "-hide_banner", "-f", "v4l2", "-list_formats", "all", "-i", name,
|
||||
)
|
||||
b, _ := cmd.CombinedOutput()
|
||||
|
||||
// [video4linux2,v4l2 @ 0x204e1c0] Compressed: mjpeg : Motion-JPEG : 640x360 1280x720 1920x1080
|
||||
// [video4linux2,v4l2 @ 0x204e1c0] Raw : yuyv422 : YUYV 4:2:2 : 640x360 1280x720 1920x1080
|
||||
// [video4linux2,v4l2 @ 0x204e1c0] Compressed: h264 : H.264 : 640x360 1280x720 1920x1080
|
||||
re := regexp.MustCompile("(Raw *|Compressed): +(.+?) : +(.+?) : (.+)")
|
||||
m := re.FindAllStringSubmatch(string(b), -1)
|
||||
for _, i := range m {
|
||||
size, _, _ := strings.Cut(i[4], " ")
|
||||
stream := &api.Source{
|
||||
Name: i[3],
|
||||
Info: i[4],
|
||||
URL: "ffmpeg:device?video=" + name + "&input_format=" + i[2] + "&video_size=" + size,
|
||||
}
|
||||
|
||||
if i[1] != "Compressed" {
|
||||
stream.URL += "#video=h264#hardware"
|
||||
}
|
||||
|
||||
videos = append(videos, name)
|
||||
streams = append(streams, stream)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
98
internal/ffmpeg/device/device_windows.go
Normal file
98
internal/ffmpeg/device/device_windows.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
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 {
|
||||
case video != "" && audio != "":
|
||||
return `video="` + video + `":audio=` + audio + `"`
|
||||
case video != "":
|
||||
return `video="` + video + `"`
|
||||
case audio != "":
|
||||
return `audio="` + audio + `"`
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func initDevices() {
|
||||
cmd := exec.Command(
|
||||
Bin, "-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "",
|
||||
)
|
||||
b, _ := cmd.CombinedOutput()
|
||||
|
||||
re := regexp.MustCompile(`"([^"]+)" \((video|audio)\)`)
|
||||
for _, m := range re.FindAllStringSubmatch(string(b), -1) {
|
||||
name := m[1]
|
||||
kind := m[2]
|
||||
|
||||
stream := &api.Source{
|
||||
Name: name, URL: "ffmpeg:device?" + kind + "=" + name,
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case core.KindVideo:
|
||||
videos = append(videos, name)
|
||||
stream.URL += "#video=h264#hardware"
|
||||
case core.KindAudio:
|
||||
audios = append(audios, name)
|
||||
stream.URL += "&channels=1&sample_rate=16000&audio_buffer_size=10"
|
||||
}
|
||||
|
||||
streams = append(streams, stream)
|
||||
}
|
||||
}
|
57
internal/ffmpeg/device/devices.go
Normal file
57
internal/ffmpeg/device/devices.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
)
|
||||
|
||||
func Init(bin string) {
|
||||
Bin = bin
|
||||
|
||||
api.HandleFunc("api/ffmpeg/devices", apiDevices)
|
||||
}
|
||||
|
||||
func GetInput(src string) (string, error) {
|
||||
i := strings.IndexByte(src, '?')
|
||||
if i < 0 {
|
||||
return "", errors.New("empty query: " + src)
|
||||
}
|
||||
|
||||
query, err := url.ParseQuery(src[i+1:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
runonce.Do(initDevices)
|
||||
|
||||
if input := queryToInput(query); input != "" {
|
||||
return input, nil
|
||||
}
|
||||
|
||||
return "", errors.New("wrong query: " + src)
|
||||
}
|
||||
|
||||
var Bin string
|
||||
|
||||
var videos, audios []string
|
||||
var streams []*api.Source
|
||||
var runonce sync.Once
|
||||
|
||||
func apiDevices(w http.ResponseWriter, r *http.Request) {
|
||||
runonce.Do(initDevices)
|
||||
|
||||
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
|
||||
}
|
@@ -1,17 +1,15 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/exec"
|
||||
"github.com/AlexxIT/go2rtc/cmd/ffmpeg/device"
|
||||
"github.com/AlexxIT/go2rtc/cmd/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"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/ffmpeg"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
@@ -27,16 +25,13 @@ 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 exec.Handle("exec:" + args.String())
|
||||
streams.RedirectFunc("ffmpeg", func(url string) (string, error) {
|
||||
args := parseArgs(url[7:])
|
||||
return "exec:" + args.String(), nil
|
||||
})
|
||||
|
||||
device.Bin = defaults["bin"]
|
||||
device.Init()
|
||||
device.Init(defaults["bin"])
|
||||
hardware.Init(defaults["bin"])
|
||||
}
|
||||
|
||||
var defaults = map[string]string{
|
||||
@@ -46,21 +41,28 @@ 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}",
|
||||
|
||||
// output
|
||||
"output": "-user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}",
|
||||
"output": "-user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}",
|
||||
"output/mjpeg": "-f mjpeg -",
|
||||
|
||||
// `-preset superfast` - we can't use ultrafast because it doesn't support `-profile main -level 4.1`
|
||||
// `-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",
|
||||
"h265": "-c:v libx265 -g 50 -profile:v high -level:v 5.1 -preset:v superfast -tune:v zerolatency",
|
||||
"mjpeg": "-c:v mjpeg -force_duplicated_matrix:v 1 -huffman:v 0 -pix_fmt:v yuvj420p",
|
||||
// `-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",
|
||||
|
||||
"opus": "-c:a libopus -ar:a 48000 -ac:a 2",
|
||||
// https://ffmpeg.org/ffmpeg-codecs.html#libopus-1
|
||||
// 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",
|
||||
@@ -70,10 +72,11 @@ var defaults = map[string]string{
|
||||
"aac": "-c:a aac", // keep sample rate and channels
|
||||
"aac/16000": "-c:a aac -ar:a 16000 -ac:a 1",
|
||||
"mp3": "-c:a libmp3lame -q:a 8",
|
||||
"pcm": "-c:a pcm_s16be",
|
||||
"pcm/8000": "-c:a pcm_s16be -ar:a 8000 -ac:a 1",
|
||||
"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
|
||||
@@ -88,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",
|
||||
@@ -101,34 +104,40 @@ 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]
|
||||
}
|
||||
return strings.Replace(template, "{input}", s, 1)
|
||||
}
|
||||
|
||||
func parseArgs(s string) *Args {
|
||||
func parseArgs(s string) *ffmpeg.Args {
|
||||
// init FFmpeg arguments
|
||||
args := &Args{
|
||||
bin: defaults["bin"],
|
||||
global: defaults["global"],
|
||||
output: defaults["output"],
|
||||
args := &ffmpeg.Args{
|
||||
Bin: defaults["bin"],
|
||||
Global: defaults["global"],
|
||||
Output: defaults["output"],
|
||||
}
|
||||
|
||||
var query url.Values
|
||||
if i := strings.IndexByte(s, '#'); i > 0 {
|
||||
query = parseQuery(s[i+1:])
|
||||
args.video = len(query["video"])
|
||||
args.audio = len(query["audio"])
|
||||
query = streams.ParseQuery(s[i+1:])
|
||||
args.Video = len(query["video"])
|
||||
args.Audio = len(query["audio"])
|
||||
s = s[:i]
|
||||
}
|
||||
|
||||
@@ -139,46 +148,46 @@ func parseArgs(s string) *Args {
|
||||
if i := strings.Index(s, "://"); i > 0 {
|
||||
switch s[:i] {
|
||||
case "http", "https", "rtmp":
|
||||
args.input = inputTemplate("http", s, query)
|
||||
args.Input = inputTemplate("http", s, query)
|
||||
case "rtsp", "rtsps":
|
||||
// https://ffmpeg.org/ffmpeg-protocols.html#rtsp
|
||||
// skip unnecessary input tracks
|
||||
switch {
|
||||
case (args.video > 0 && args.audio > 0) || (args.video == 0 && args.audio == 0):
|
||||
args.input = "-allowed_media_types video+audio "
|
||||
case args.video > 0:
|
||||
args.input = "-allowed_media_types video "
|
||||
case args.audio > 0:
|
||||
args.input = "-allowed_media_types audio "
|
||||
case (args.Video > 0 && args.Audio > 0) || (args.Video == 0 && args.Audio == 0):
|
||||
args.Input = "-allowed_media_types video+audio "
|
||||
case args.Video > 0:
|
||||
args.Input = "-allowed_media_types video "
|
||||
case args.Audio > 0:
|
||||
args.Input = "-allowed_media_types audio "
|
||||
}
|
||||
|
||||
args.input += inputTemplate("rtsp", s, query)
|
||||
args.Input += inputTemplate("rtsp", s, query)
|
||||
default:
|
||||
args.input = "-i " + s
|
||||
args.Input = "-i " + s
|
||||
}
|
||||
} else if streams.Get(s) != nil {
|
||||
s = "rtsp://localhost:" + rtsp.Port + "/" + s
|
||||
s = "rtsp://127.0.0.1:" + rtsp.Port + "/" + s
|
||||
switch {
|
||||
case args.video > 0 && args.audio == 0:
|
||||
case args.Video > 0 && args.Audio == 0:
|
||||
s += "?video"
|
||||
case args.audio > 0 && args.video == 0:
|
||||
case args.Audio > 0 && args.Video == 0:
|
||||
s += "?audio"
|
||||
default:
|
||||
s += "?video&audio"
|
||||
}
|
||||
args.input = inputTemplate("rtsp", s, query)
|
||||
args.Input = inputTemplate("rtsp", s, query)
|
||||
} else if strings.HasPrefix(s, "device?") {
|
||||
var err error
|
||||
args.input, err = device.GetInput(s)
|
||||
args.Input, err = device.GetInput(s)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
args.input = inputTemplate("file", s, query)
|
||||
args.Input = inputTemplate("file", s, query)
|
||||
}
|
||||
|
||||
if query["async"] != nil {
|
||||
args.input = "-use_wallclock_as_timestamps 1 -async 1 " + args.input
|
||||
args.Input = "-use_wallclock_as_timestamps 1 -async 1 " + args.Input
|
||||
}
|
||||
|
||||
// Parse query params:
|
||||
@@ -190,6 +199,8 @@ func parseArgs(s string) *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)
|
||||
}
|
||||
|
||||
@@ -225,8 +236,20 @@ func parseArgs(s string) *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 {
|
||||
if args.Video > 0 {
|
||||
for _, video := range query["video"] {
|
||||
if video != "copy" {
|
||||
if codec := defaults[video]; codec != "" {
|
||||
@@ -238,12 +261,10 @@ func parseArgs(s string) *Args {
|
||||
args.AddCodec("-c:v copy")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
args.AddCodec("-vn")
|
||||
}
|
||||
|
||||
// 4. Process audio codecs
|
||||
if args.audio > 0 {
|
||||
if args.Audio > 0 {
|
||||
for _, audio := range query["audio"] {
|
||||
if audio != "copy" {
|
||||
if codec := defaults[audio]; codec != "" {
|
||||
@@ -255,104 +276,28 @@ func parseArgs(s string) *Args {
|
||||
args.AddCodec("-c:a copy")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
args.AddCodec("-an")
|
||||
}
|
||||
|
||||
if query["hardware"] != nil {
|
||||
MakeHardware(args, query["hardware"][0])
|
||||
hardware.MakeHardware(args, query["hardware"][0], defaults)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
if (args.Video == 1 && args.Audio == 0 && query.Get("video") == "mjpeg") ||
|
||||
// no transcoding from mjpeg input
|
||||
(args.Video == 0 && args.Audio == 0 && strings.Contains(args.Input, " mjpeg ")) {
|
||||
args.Output = defaults["output/mjpeg"]
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
func parseQuery(s string) map[string][]string {
|
||||
query := map[string][]string{}
|
||||
for _, key := range strings.Split(s, "#") {
|
||||
var value string
|
||||
i := strings.IndexByte(key, '=')
|
||||
if i > 0 {
|
||||
key, value = key[:i], key[i+1:]
|
||||
}
|
||||
query[key] = append(query[key], value)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
type Args struct {
|
||||
bin string // ffmpeg
|
||||
global string // -hide_banner -v error
|
||||
input string // -re -stream_loop -1 -i /media/bunny.mp4
|
||||
codecs []string // -c:v libx264 -g:v 30 -preset:v ultrafast -tune:v zerolatency
|
||||
filters []string // scale=1920:1080
|
||||
output string // -f rtsp {output}
|
||||
|
||||
video, audio int // count of video and audio params
|
||||
}
|
||||
|
||||
func (a *Args) AddCodec(codec string) {
|
||||
a.codecs = append(a.codecs, codec)
|
||||
}
|
||||
|
||||
func (a *Args) AddFilter(filter string) {
|
||||
a.filters = append(a.filters, filter)
|
||||
}
|
||||
|
||||
func (a *Args) InsertFilter(filter string) {
|
||||
a.filters = append([]string{filter}, a.filters...)
|
||||
}
|
||||
|
||||
func (a *Args) String() string {
|
||||
b := bytes.NewBuffer(make([]byte, 0, 512))
|
||||
|
||||
b.WriteString(a.bin)
|
||||
|
||||
if a.global != "" {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(a.global)
|
||||
}
|
||||
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(a.input)
|
||||
|
||||
multimode := a.video > 1 || a.audio > 1
|
||||
var iv, ia int
|
||||
|
||||
for _, codec := range a.codecs {
|
||||
// support multiple video and/or audio codecs
|
||||
if multimode && len(codec) >= 5 {
|
||||
switch codec[:5] {
|
||||
case "-c:v ":
|
||||
codec = "-map 0:v:0? " + strings.ReplaceAll(codec, ":v ", ":v:"+strconv.Itoa(iv)+" ")
|
||||
iv++
|
||||
case "-c:a ":
|
||||
codec = "-map 0:a:0? " + strings.ReplaceAll(codec, ":a ", ":a:"+strconv.Itoa(ia)+" ")
|
||||
ia++
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(codec)
|
||||
}
|
||||
|
||||
if a.filters != nil {
|
||||
for i, filter := range a.filters {
|
||||
if i == 0 {
|
||||
b.WriteString(" -vf ")
|
||||
} else {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
b.WriteString(filter)
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(a.output)
|
||||
|
||||
return b.String()
|
||||
}
|
215
internal/ffmpeg/ffmpeg_test.go
Normal file
215
internal/ffmpeg/ffmpeg_test.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
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())
|
||||
|
||||
// [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")
|
||||
require.Equal(t, `ffmpeg -hide_banner -re -i /media/bbb.mp4 -c:v mjpeg -an -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())
|
||||
}
|
176
internal/ffmpeg/hardware/hardware.go
Normal file
176
internal/ffmpeg/hardware/hardware.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package hardware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/pkg/ffmpeg"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
EngineSoftware = "software"
|
||||
EngineVAAPI = "vaapi" // Intel iGPU and AMD GPU
|
||||
EngineV4L2M2M = "v4l2m2m" // Raspberry Pi 3 and 4
|
||||
EngineCUDA = "cuda" // NVidia on Windows and Linux
|
||||
EngineDXVA2 = "dxva2" // Intel on Windows
|
||||
EngineVideoToolbox = "videotoolbox" // macOS
|
||||
)
|
||||
|
||||
func Init(bin string) {
|
||||
api.HandleFunc("api/ffmpeg/hardware", func(w http.ResponseWriter, r *http.Request) {
|
||||
api.ResponseSources(w, ProbeAll(bin))
|
||||
})
|
||||
}
|
||||
|
||||
// MakeHardware converts software FFmpeg args to hardware args
|
||||
// empty engine for autoselect
|
||||
func MakeHardware(args *ffmpeg.Args, engine string, defaults map[string]string) {
|
||||
for i, codec := range args.Codecs {
|
||||
if len(codec) < 10 {
|
||||
continue // skip short line (-c:v mjpeg...)
|
||||
}
|
||||
|
||||
// get current codec name
|
||||
name := cut(codec, ' ', 1)
|
||||
switch name {
|
||||
case "libx264":
|
||||
name = "h264"
|
||||
case "libx265":
|
||||
name = "h265"
|
||||
case "mjpeg":
|
||||
default:
|
||||
continue // skip unsupported codec
|
||||
}
|
||||
|
||||
// temporary disable probe for H265
|
||||
if engine == "" && name != "h265" {
|
||||
if engine = cache[name]; engine == "" {
|
||||
engine = ProbeHardware(args.Bin, name)
|
||||
cache[name] = engine
|
||||
}
|
||||
}
|
||||
|
||||
switch engine {
|
||||
case EngineVAAPI:
|
||||
args.Codecs[i] = defaults[name+"/"+engine]
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
case EngineCUDA:
|
||||
args.Codecs[i] = defaults[name+"/"+engine]
|
||||
|
||||
// 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:
|
||||
args.Input = "-hwaccel dxva2 -hwaccel_output_format dxva2_vld " + args.Input
|
||||
args.Codecs[i] = defaults[name+"/"+engine]
|
||||
|
||||
for i, filter := range args.Filters {
|
||||
if strings.HasPrefix(filter, "scale=") {
|
||||
args.Filters[i] = "scale_qsv=" + filter[6:]
|
||||
}
|
||||
}
|
||||
|
||||
args.InsertFilter("hwmap=derive_device=qsv,format=qsv")
|
||||
|
||||
case EngineVideoToolbox:
|
||||
args.Input = "-hwaccel videotoolbox -hwaccel_output_format videotoolbox_vld " + args.Input
|
||||
args.Codecs[i] = defaults[name+"/"+engine]
|
||||
|
||||
case EngineV4L2M2M:
|
||||
args.Codecs[i] = defaults[name+"/"+engine]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cache = map[string]string{}
|
||||
|
||||
func run(bin string, args string) bool {
|
||||
err := exec.Command(bin, strings.Split(args, " ")...).Run()
|
||||
log.Printf("%v %v", args, err)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func runToString(bin string, args string) string {
|
||||
if run(bin, args) {
|
||||
return "OK"
|
||||
} else {
|
||||
return "ERROR"
|
||||
}
|
||||
}
|
||||
|
||||
func cut(s string, sep byte, pos int) string {
|
||||
for n := 0; n < pos; n++ {
|
||||
if i := strings.IndexByte(s, sep); i > 0 {
|
||||
s = s[i+1:]
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
if i := strings.IndexByte(s, sep); i > 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
37
internal/ffmpeg/hardware/hardware_darwin.go
Normal file
37
internal/ffmpeg/hardware/hardware_darwin.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package hardware
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
)
|
||||
|
||||
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.Source {
|
||||
return []*api.Source{
|
||||
{
|
||||
Name: runToString(bin, ProbeVideoToolboxH264),
|
||||
URL: "ffmpeg:...#video=h264#hardware=" + EngineVideoToolbox,
|
||||
},
|
||||
{
|
||||
Name: runToString(bin, ProbeVideoToolboxH265),
|
||||
URL: "ffmpeg:...#video=h265#hardware=" + EngineVideoToolbox,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ProbeHardware(bin, name string) string {
|
||||
switch name {
|
||||
case "h264":
|
||||
if run(bin, ProbeVideoToolboxH264) {
|
||||
return EngineVideoToolbox
|
||||
}
|
||||
|
||||
case "h265":
|
||||
if run(bin, ProbeVideoToolboxH265) {
|
||||
return EngineVideoToolbox
|
||||
}
|
||||
}
|
||||
|
||||
return EngineSoftware
|
||||
}
|
95
internal/ffmpeg/hardware/hardware_linux.go
Normal file
95
internal/ffmpeg/hardware/hardware_linux.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package hardware
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
)
|
||||
|
||||
const ProbeV4L2M2MH264 = "-f lavfi -i testsrc2 -t 1 -c h264_v4l2m2m -f null -"
|
||||
const ProbeV4L2M2MH265 = "-f lavfi -i testsrc2 -t 1 -c hevc_v4l2m2m -f null -"
|
||||
const ProbeVAAPIH264 = "-init_hw_device vaapi -f lavfi -i testsrc2 -t 1 -vf format=nv12,hwupload -c h264_vaapi -f null -"
|
||||
const ProbeVAAPIH265 = "-init_hw_device vaapi -f lavfi -i testsrc2 -t 1 -vf format=nv12,hwupload -c hevc_vaapi -f null -"
|
||||
const ProbeVAAPIJPEG = "-init_hw_device vaapi -f lavfi -i testsrc2 -t 1 -vf format=nv12,hwupload -c mjpeg_vaapi -f null -"
|
||||
const ProbeCUDAH264 = "-init_hw_device cuda -f lavfi -i testsrc2 -t 1 -c h264_nvenc -f null -"
|
||||
const ProbeCUDAH265 = "-init_hw_device cuda -f lavfi -i testsrc2 -t 1 -c hevc_nvenc -f null -"
|
||||
|
||||
func ProbeAll(bin string) []*api.Source {
|
||||
if runtime.GOARCH == "arm64" || runtime.GOARCH == "arm" {
|
||||
return []*api.Source{
|
||||
{
|
||||
Name: runToString(bin, ProbeV4L2M2MH264),
|
||||
URL: "ffmpeg:...#video=h264#hardware=" + EngineV4L2M2M,
|
||||
},
|
||||
{
|
||||
Name: runToString(bin, ProbeV4L2M2MH265),
|
||||
URL: "ffmpeg:...#video=h265#hardware=" + EngineV4L2M2M,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return []*api.Source{
|
||||
{
|
||||
Name: runToString(bin, ProbeVAAPIH264),
|
||||
URL: "ffmpeg:...#video=h264#hardware=" + EngineVAAPI,
|
||||
},
|
||||
{
|
||||
Name: runToString(bin, ProbeVAAPIH265),
|
||||
URL: "ffmpeg:...#video=h265#hardware=" + EngineVAAPI,
|
||||
},
|
||||
{
|
||||
Name: runToString(bin, ProbeVAAPIJPEG),
|
||||
URL: "ffmpeg:...#video=mjpeg#hardware=" + EngineVAAPI,
|
||||
},
|
||||
{
|
||||
Name: runToString(bin, ProbeCUDAH264),
|
||||
URL: "ffmpeg:...#video=h264#hardware=" + EngineCUDA,
|
||||
},
|
||||
{
|
||||
Name: runToString(bin, ProbeCUDAH265),
|
||||
URL: "ffmpeg:...#video=h265#hardware=" + EngineCUDA,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ProbeHardware(bin, name string) string {
|
||||
if runtime.GOARCH == "arm64" || runtime.GOARCH == "arm" {
|
||||
switch name {
|
||||
case "h264":
|
||||
if run(bin, ProbeV4L2M2MH264) {
|
||||
return EngineV4L2M2M
|
||||
}
|
||||
case "h265":
|
||||
if run(bin, ProbeV4L2M2MH265) {
|
||||
return EngineV4L2M2M
|
||||
}
|
||||
}
|
||||
|
||||
return EngineSoftware
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "h264":
|
||||
if run(bin, ProbeCUDAH264) {
|
||||
return EngineCUDA
|
||||
}
|
||||
if run(bin, ProbeVAAPIH264) {
|
||||
return EngineVAAPI
|
||||
}
|
||||
|
||||
case "h265":
|
||||
if run(bin, ProbeCUDAH265) {
|
||||
return EngineCUDA
|
||||
}
|
||||
if run(bin, ProbeVAAPIH265) {
|
||||
return EngineVAAPI
|
||||
}
|
||||
|
||||
case "mjpeg":
|
||||
if run(bin, ProbeVAAPIJPEG) {
|
||||
return EngineVAAPI
|
||||
}
|
||||
}
|
||||
|
||||
return EngineSoftware
|
||||
}
|
61
internal/ffmpeg/hardware/hardware_windows.go
Normal file
61
internal/ffmpeg/hardware/hardware_windows.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package hardware
|
||||
|
||||
import "github.com/AlexxIT/go2rtc/internal/api"
|
||||
|
||||
const ProbeDXVA2H264 = "-init_hw_device dxva2 -f lavfi -i testsrc2 -t 1 -c h264_qsv -f null -"
|
||||
const ProbeDXVA2H265 = "-init_hw_device dxva2 -f lavfi -i testsrc2 -t 1 -c hevc_qsv -f null -"
|
||||
const ProbeDXVA2JPEG = "-init_hw_device dxva2 -f lavfi -i testsrc2 -t 1 -c mjpeg_qsv -f null -"
|
||||
const ProbeCUDAH264 = "-init_hw_device cuda -f lavfi -i testsrc2 -t 1 -c h264_nvenc -f null -"
|
||||
const ProbeCUDAH265 = "-init_hw_device cuda -f lavfi -i testsrc2 -t 1 -c hevc_nvenc -f null -"
|
||||
|
||||
func ProbeAll(bin string) []*api.Source {
|
||||
return []*api.Source{
|
||||
{
|
||||
Name: runToString(bin, ProbeDXVA2H264),
|
||||
URL: "ffmpeg:...#video=h264#hardware=" + EngineDXVA2,
|
||||
},
|
||||
{
|
||||
Name: runToString(bin, ProbeDXVA2H265),
|
||||
URL: "ffmpeg:...#video=h265#hardware=" + EngineDXVA2,
|
||||
},
|
||||
{
|
||||
Name: runToString(bin, ProbeDXVA2JPEG),
|
||||
URL: "ffmpeg:...#video=mjpeg#hardware=" + EngineDXVA2,
|
||||
},
|
||||
{
|
||||
Name: runToString(bin, ProbeCUDAH264),
|
||||
URL: "ffmpeg:...#video=h264#hardware=" + EngineCUDA,
|
||||
},
|
||||
{
|
||||
Name: runToString(bin, ProbeCUDAH265),
|
||||
URL: "ffmpeg:...#video=h265#hardware=" + EngineCUDA,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ProbeHardware(bin, name string) string {
|
||||
switch name {
|
||||
case "h264":
|
||||
if run(bin, ProbeCUDAH264) {
|
||||
return EngineCUDA
|
||||
}
|
||||
if run(bin, ProbeDXVA2H264) {
|
||||
return EngineDXVA2
|
||||
}
|
||||
|
||||
case "h265":
|
||||
if run(bin, ProbeCUDAH265) {
|
||||
return EngineCUDA
|
||||
}
|
||||
if run(bin, ProbeDXVA2H265) {
|
||||
return EngineDXVA2
|
||||
}
|
||||
|
||||
case "mjpeg":
|
||||
if run(bin, ProbeDXVA2JPEG) {
|
||||
return EngineDXVA2
|
||||
}
|
||||
}
|
||||
|
||||
return EngineSoftware
|
||||
}
|
83
internal/ffmpeg/jpeg.go
Normal file
83
internal/ffmpeg/jpeg.go
Normal file
@@ -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
|
||||
}
|
23
internal/ffmpeg/jpeg_test.go
Normal file
23
internal/ffmpeg/jpeg_test.go
Normal file
@@ -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())
|
||||
}
|
104
internal/hass/api.go
Normal file
104
internal/hass/api.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package hass
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/internal/webrtc"
|
||||
)
|
||||
|
||||
func apiOK(w http.ResponseWriter, r *http.Request) {
|
||||
api.Response(w, `{"status":1,"payload":{}}`, api.MimeJSON)
|
||||
}
|
||||
|
||||
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 {
|
||||
ints, _ := net.Interfaces()
|
||||
|
||||
for _, i := range ints {
|
||||
if i.Name != "hassio" {
|
||||
continue
|
||||
}
|
||||
|
||||
addrs, _ := i.Addrs()
|
||||
for _, addr := range addrs {
|
||||
if addr, ok := addr.(*net.IPNet); ok {
|
||||
return addr.IP.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
type addJSON struct {
|
||||
Name string `json:"name"`
|
||||
Channels struct {
|
||||
First struct {
|
||||
//Name string `json:"name"`
|
||||
Url string `json:"url"`
|
||||
} `json:"0"`
|
||||
} `json:"channels"`
|
||||
}
|
218
internal/hass/hass.go
Normal file
218
internal/hass/hass.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package hass
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var conf struct {
|
||||
API struct {
|
||||
Listen string `json:"listen"`
|
||||
} `yaml:"api"`
|
||||
Mod struct {
|
||||
Config string `yaml:"config"`
|
||||
} `yaml:"hass"`
|
||||
}
|
||||
|
||||
app.LoadConfig(&conf)
|
||||
|
||||
log = app.GetLogger("hass")
|
||||
|
||||
// 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)
|
||||
|
||||
api.HandleFunc("api/hass", func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "no hass config", http.StatusNotFound)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
api.HandleFunc("api/hass", func(w http.ResponseWriter, _ *http.Request) {
|
||||
once.Do(func() {
|
||||
// load WebRTC entities from Hass API, works only for add-on version
|
||||
if token := hass.SupervisorToken(); token != "" {
|
||||
if err := importWebRTC(token); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
var items []*api.Source
|
||||
for name, url := range entities {
|
||||
items = append(items, &api.Source{
|
||||
Name: name, URL: "hass:" + name, Location: url,
|
||||
})
|
||||
}
|
||||
api.ResponseSources(w, items)
|
||||
})
|
||||
|
||||
// for Addon listen on hassio interface, so WebUI feature will work
|
||||
if conf.API.Listen == "127.0.0.1:1984" {
|
||||
if addr := HassioAddr(); addr != "" {
|
||||
addr += ":1984"
|
||||
go func() {
|
||||
log.Info().Str("addr", addr).Msg("[hass] listen")
|
||||
if err := http.ListenAndServe(addr, api.Handler); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 err
|
||||
}
|
||||
|
||||
var storage struct {
|
||||
Data struct {
|
||||
Entries []struct {
|
||||
Title string `json:"title"`
|
||||
Domain string `json:"domain"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Options json.RawMessage `json:"options"`
|
||||
} `json:"entries"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(b, &storage); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entrie := range storage.Data.Entries {
|
||||
switch entrie.Domain {
|
||||
case "generic":
|
||||
var options struct {
|
||||
StreamSource string `json:"stream_source"`
|
||||
}
|
||||
if err = json.Unmarshal(entrie.Options, &options); err != nil {
|
||||
continue
|
||||
}
|
||||
entities[entrie.Title] = options.StreamSource
|
||||
|
||||
case "homekit_controller":
|
||||
if !bytes.Contains(entrie.Data, []byte("iOSPairingId")) {
|
||||
continue
|
||||
}
|
||||
|
||||
var data struct {
|
||||
ClientID string `json:"iOSPairingId"`
|
||||
ClientPrivate string `json:"iOSDeviceLTSK"`
|
||||
ClientPublic string `json:"iOSDeviceLTPK"`
|
||||
DeviceID string `json:"AccessoryPairingID"`
|
||||
DevicePublic string `json:"AccessoryLTPK"`
|
||||
DeviceHost string `json:"AccessoryIP"`
|
||||
DevicePort uint16 `json:"AccessoryPort"`
|
||||
}
|
||||
if err = json.Unmarshal(entrie.Data, &data); err != nil {
|
||||
continue
|
||||
}
|
||||
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,
|
||||
data.DeviceID, data.DevicePublic,
|
||||
)
|
||||
|
||||
case "roborock":
|
||||
_ = json.Unmarshal(entrie.Data, &roborock.Auth)
|
||||
|
||||
case "onvif":
|
||||
var data struct {
|
||||
Host string `json:"host" json:"host"`
|
||||
Port uint16 `json:"port" json:"port"`
|
||||
Username string `json:"username" json:"username"`
|
||||
Password string `json:"password" json:"password"`
|
||||
}
|
||||
if err = json.Unmarshal(entrie.Data, &data); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if data.Username != "" && data.Password != "" {
|
||||
entities[entrie.Title] = fmt.Sprintf(
|
||||
"onvif://%s:%s@%s:%d", data.Username, data.Password, data.Host, data.Port,
|
||||
)
|
||||
} else {
|
||||
entities[entrie.Title] = fmt.Sprintf("onvif://%s:%d", data.Host, data.Port)
|
||||
}
|
||||
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debug().Str("url", "hass:"+entrie.Title).Msg("[hass] load config")
|
||||
//streams.Get("hass:" + entrie.Title)
|
||||
}
|
||||
|
||||
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
|
3
internal/hls/README.md
Normal file
3
internal/hls/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
## Useful links
|
||||
|
||||
- https://walterebert.com/playground/video/hls/
|
@@ -1,21 +1,24 @@
|
||||
package hls
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"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"
|
||||
"strconv"
|
||||
"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,28 +28,17 @@ 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
|
||||
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
|
||||
|
||||
// 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) {
|
||||
// CORS important for Chromecast
|
||||
@@ -59,83 +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.(any).(*core.Listener).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()
|
||||
sessionsMu.Lock()
|
||||
sessions[session.id] = session
|
||||
sessionsMu.Unlock()
|
||||
|
||||
sid := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
go session.Run()
|
||||
|
||||
// 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`
|
||||
}
|
||||
|
||||
sessions[sid] = session
|
||||
|
||||
// bandwidth important for Safari, codecs useful for smooth playback
|
||||
data := []byte(`#EXTM3U
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS="` + cons.MimeCodecs() + `"
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -150,15 +110,15 @@ func handlerPlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
sid := r.URL.Query().Get("id")
|
||||
sessionsMu.RLock()
|
||||
session := sessions[sid]
|
||||
sessionsMu.RUnlock()
|
||||
if session == nil {
|
||||
http.NotFound(w, r)
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -173,7 +133,9 @@ func handlerSegmentTS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
sid := r.URL.Query().Get("id")
|
||||
sessionsMu.RLock()
|
||||
session := sessions[sid]
|
||||
sessionsMu.RUnlock()
|
||||
if session == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -181,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()
|
||||
}
|
||||
@@ -212,13 +165,22 @@ func handlerInit(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
sid := r.URL.Query().Get("id")
|
||||
sessionsMu.RLock()
|
||||
session := sessions[sid]
|
||||
sessionsMu.RUnlock()
|
||||
if session == nil {
|
||||
http.NotFound(w, r)
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -228,12 +190,16 @@ 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()
|
||||
if session == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -241,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()
|
||||
}
|
127
internal/hls/session.go
Normal file
127
internal/hls/session.go
Normal file
@@ -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
|
||||
}
|
54
internal/hls/ws.go
Normal file
54
internal/hls/ws.go
Normal file
@@ -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
|
||||
}
|
139
internal/homekit/api.go
Normal file
139
internal/homekit/api.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"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":
|
||||
sources, err := discovery()
|
||||
if err != nil {
|
||||
api.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, source := range sources {
|
||||
if source.Location == "" {
|
||||
source.Location = " "
|
||||
}
|
||||
}
|
||||
|
||||
api.ResponseSources(w, sources)
|
||||
|
||||
case "POST":
|
||||
if err := r.ParseMultipartForm(1024); err != nil {
|
||||
api.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := apiPair(r.Form.Get("id"), r.Form.Get("url")); err != nil {
|
||||
api.Error(w, err)
|
||||
}
|
||||
|
||||
case "DELETE":
|
||||
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 discovery() ([]*api.Source, error) {
|
||||
var sources []*api.Source
|
||||
|
||||
// 1. Get streams from Discovery
|
||||
err := mdns.Discovery(mdns.ServiceHAP, func(entry *mdns.ServiceEntry) bool {
|
||||
log.Trace().Msgf("[homekit] mdns=%s", entry)
|
||||
|
||||
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],
|
||||
),
|
||||
}
|
||||
|
||||
sources = append(sources, source)
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sources, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
197
internal/homekit/homekit.go
Normal file
197
internal/homekit/homekit.go
Normal file
@@ -0,0 +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) {
|
||||
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 ""
|
||||
}
|
265
internal/homekit/server.go
Normal file
265
internal/homekit/server.go
Normal file
@@ -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])
|
||||
}
|
91
internal/http/http.go
Normal file
91
internal/http/http.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"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() {
|
||||
streams.HandleFunc("http", handleHTTP)
|
||||
streams.HandleFunc("https", handleHTTP)
|
||||
streams.HandleFunc("httpx", handleHTTP)
|
||||
|
||||
streams.HandleFunc("tcp", handleTCP)
|
||||
}
|
||||
|
||||
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", 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
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
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]
|
||||
}
|
||||
|
||||
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 ct == "multipart/x-mixed-replace":
|
||||
return multipart.Open(res.Body)
|
||||
|
||||
case ct == "application/vnd.apple.mpegurl" || ext == "m3u8":
|
||||
return hls.OpenURL(req.URL, res.Body)
|
||||
}
|
||||
|
||||
return magic.Open(res.Body)
|
||||
}
|
||||
|
||||
func handleTCP(rawURL string) (core.Producer, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", u.Host, core.ConnDialTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return magic.Open(conn)
|
||||
}
|
@@ -1,7 +1,7 @@
|
||||
package isapi
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/isapi"
|
||||
)
|
@@ -1,7 +1,7 @@
|
||||
package ivideon
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/ivideon"
|
||||
"strings"
|
@@ -2,67 +2,75 @@ package mjpeg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mjpeg"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/rs/zerolog/log"
|
||||
"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"
|
||||
"github.com/AlexxIT/go2rtc/pkg/magic"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mjpeg"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
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 := &mjpeg.Consumer{
|
||||
RemoteAddr: tcp.RemoteAddr(r),
|
||||
UserAgent: r.UserAgent(),
|
||||
}
|
||||
cons.Listen(func(msg any) {
|
||||
switch msg := msg.(type) {
|
||||
case []byte:
|
||||
exit <- msg
|
||||
}
|
||||
})
|
||||
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)
|
||||
|
||||
switch cons.CodecName() {
|
||||
case core.CodecH264, core.CodecH265:
|
||||
ts := time.Now()
|
||||
var err error
|
||||
if b, err = ffmpeg.JPEGWithQuery(b, r.URL.Query()); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
log.Debug().Msgf("[mjpeg] transcoding time=%s", time.Since(ts))
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -73,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")
|
||||
@@ -111,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) {
|
||||
@@ -139,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)
|
147
internal/mp4/mp4.go
Normal file
147
internal/mp4/mp4.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package mp4
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/api/ws"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mp4"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
log = app.GetLogger("mp4")
|
||||
|
||||
ws.HandleFunc("mse", handlerWSMSE)
|
||||
ws.HandleFunc("mp4", handlerWSMP4)
|
||||
|
||||
api.HandleFunc("api/frame.mp4", handlerKeyframe)
|
||||
api.HandleFunc("api/stream.mp4", handlerMP4)
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
|
||||
func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
|
||||
// Chrome 105 does two requests: without Range and with `Range: bytes=0-`
|
||||
ua := r.UserAgent()
|
||||
if strings.Contains(ua, " Chrome/") {
|
||||
if r.Header.Values("Range") == nil {
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
src := query.Get("src")
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
cons := mp4.NewKeyframe(nil)
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
once := &core.OnceBuffer{} // init and first frame
|
||||
_, _ = cons.WriteTo(once)
|
||||
|
||||
stream.RemoveConsumer(cons)
|
||||
|
||||
// Apple Safari won't show frame without length
|
||||
header := w.Header()
|
||||
header.Set("Content-Length", strconv.Itoa(once.Len()))
|
||||
header.Set("Content-Type", mp4.ContentType(cons.Codecs()))
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
func handlerMP4(w http.ResponseWriter, r *http.Request) {
|
||||
log.Trace().Msgf("[mp4] %s %+v", r.Method, r.Header)
|
||||
|
||||
query := r.URL.Query()
|
||||
|
||||
ua := r.UserAgent()
|
||||
if strings.Contains(ua, " Safari/") && !strings.Contains(ua, " Chrome/") && !query.Has("duration") {
|
||||
// auto redirect to HLS/fMP4 format, because Safari not support MP4 stream
|
||||
url := "stream.m3u8?" + r.URL.RawQuery
|
||||
if !query.Has("mp4") {
|
||||
url += "&mp4"
|
||||
}
|
||||
|
||||
http.Redirect(w, r, url, http.StatusMovedPermanently)
|
||||
return
|
||||
}
|
||||
|
||||
src := query.Get("src")
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
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()
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if rotate := query.Get("rotate"); rotate != "" {
|
||||
cons.Rotate = core.Atoi(rotate)
|
||||
}
|
||||
|
||||
if scale := query.Get("scale"); scale != "" {
|
||||
if sx, sy, ok := strings.Cut(scale, ":"); ok {
|
||||
cons.ScaleX = core.Atoi(sx)
|
||||
cons.ScaleY = core.Atoi(sy)
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
_ = cons.Stop()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = cons.WriteTo(w)
|
||||
|
||||
stream.RemoveConsumer(cons)
|
||||
|
||||
if duration != nil {
|
||||
duration.Stop()
|
||||
}
|
||||
}
|
78
internal/mp4/ws.go
Normal file
78
internal/mp4/ws.go
Normal file
@@ -0,0 +1,78 @@
|
||||
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"
|
||||
)
|
||||
|
||||
func handlerWSMSE(tr *ws.Transport, msg *ws.Message) error {
|
||||
stream := streams.GetOrPatch(tr.Request.URL.Query())
|
||||
if stream == nil {
|
||||
return errors.New(api.StreamNotFound)
|
||||
}
|
||||
|
||||
var medias []*core.Media
|
||||
if codecs := msg.String(); codecs != "" {
|
||||
log.Trace().Str("codecs", codecs).Msgf("[mp4] new WS/MSE consumer")
|
||||
medias = mp4.ParseCodecs(codecs, true)
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func handlerWSMP4(tr *ws.Transport, msg *ws.Message) error {
|
||||
stream := streams.GetOrPatch(tr.Request.URL.Query())
|
||||
if stream == nil {
|
||||
return errors.New(api.StreamNotFound)
|
||||
}
|
||||
|
||||
var medias []*core.Media
|
||||
if codecs := msg.String(); codecs != "" {
|
||||
log.Trace().Str("codecs", codecs).Msgf("[mp4] new WS/MP4 consumer")
|
||||
medias = mp4.ParseCodecs(codecs, false)
|
||||
}
|
||||
|
||||
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(&ws.Message{Type: "mse", Value: mp4.ContentType(cons.Codecs())})
|
||||
|
||||
go cons.WriteTo(tr.Writer())
|
||||
|
||||
tr.OnClose(func() {
|
||||
stream.RemoveConsumer(cons)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
36
internal/mpegts/aac.go
Normal file
36
internal/mpegts/aac.go
Normal file
@@ -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)
|
||||
}
|
74
internal/mpegts/mpegts.go
Normal file
74
internal/mpegts/mpegts.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package mpegts
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mpegts"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
api.HandleFunc("api/stream.ts", apiHandle)
|
||||
api.HandleFunc("api/stream.aac", apiStreamAAC)
|
||||
}
|
||||
|
||||
func apiHandle(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
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 {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
res := &http.Response{Body: r.Body, Request: r}
|
||||
client, err := mpegts.Open(res.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
stream.AddProducer(client)
|
||||
|
||||
if err = client.Start(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
stream.RemoveProducer(client)
|
||||
}
|
56
internal/nest/init.go
Normal file
56
internal/nest/init.go
Normal file
@@ -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)
|
||||
}
|
@@ -2,8 +2,8 @@ package ngrok
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/webrtc"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/webrtc"
|
||||
"github.com/AlexxIT/go2rtc/pkg/ngrok"
|
||||
"github.com/rs/zerolog"
|
||||
"net"
|
195
internal/onvif/init.go
Normal file
195
internal/onvif/init.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"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() {
|
||||
log = app.GetLogger("onvif")
|
||||
|
||||
streams.HandleFunc("onvif", streamOnvif)
|
||||
|
||||
// ONVIF server on all suburls
|
||||
api.HandleFunc("/onvif/", onvifDeviceService)
|
||||
|
||||
// ONVIF client autodiscovery
|
||||
api.HandleFunc("api/onvif", apiOnvif)
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
|
||||
func streamOnvif(rawURL string) (core.Producer, error) {
|
||||
client, err := onvif.NewClient(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
uri, err := client.GetURI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debug().Msgf("[onvif] new uri=%s", uri)
|
||||
|
||||
return streams.GetProducer(uri)
|
||||
}
|
||||
|
||||
func onvifDeviceService(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
action := onvif.GetRequestAction(b)
|
||||
if action == "" {
|
||||
http.Error(w, "malformed request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Trace().Msgf("[onvif] %s", action)
|
||||
|
||||
var res string
|
||||
|
||||
switch action {
|
||||
case onvif.ActionGetCapabilities:
|
||||
// important for Hass: Media section
|
||||
res = onvif.GetCapabilitiesResponse(r.Host)
|
||||
|
||||
case onvif.ActionGetSystemDateAndTime:
|
||||
// important for Hass
|
||||
res = onvif.GetSystemDateAndTimeResponse()
|
||||
|
||||
case onvif.ActionGetNetworkInterfaces:
|
||||
// important for Hass: none
|
||||
res = onvif.GetNetworkInterfacesResponse()
|
||||
|
||||
case onvif.ActionGetDeviceInformation:
|
||||
// important for Hass: SerialNumber (unique server ID)
|
||||
res = onvif.GetDeviceInformationResponse("", "go2rtc", app.Version, r.Host)
|
||||
|
||||
case onvif.ActionGetServiceCapabilities:
|
||||
// important for Hass
|
||||
res = onvif.GetServiceCapabilitiesResponse()
|
||||
|
||||
case onvif.ActionSystemReboot:
|
||||
res = onvif.SystemRebootResponse()
|
||||
|
||||
time.AfterFunc(time.Second, func() {
|
||||
os.Exit(0)
|
||||
})
|
||||
|
||||
case onvif.ActionGetProfiles:
|
||||
// important for Hass: H264 codec, width, height
|
||||
res = onvif.GetProfilesResponse(streams.GetAll())
|
||||
|
||||
case onvif.ActionGetStreamUri:
|
||||
host, _, err := net.SplitHostPort(r.Host)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
uri := "rtsp://" + host + ":" + rtsp.Port + "/" + onvif.FindTagValue(b, "ProfileToken")
|
||||
res = onvif.GetStreamUriResponse(uri)
|
||||
|
||||
default:
|
||||
http.Error(w, "unsupported action", http.StatusBadRequest)
|
||||
log.Debug().Msgf("[onvif] unsupported request:\n%s", b)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/soap+xml; charset=utf-8")
|
||||
if _, err = w.Write([]byte(res)); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
|
||||
func apiOnvif(w http.ResponseWriter, r *http.Request) {
|
||||
src := r.URL.Query().Get("src")
|
||||
|
||||
var items []*api.Source
|
||||
|
||||
if src == "" {
|
||||
urls, err := onvif.DiscoveryStreamingURLs()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
for _, rawURL := range urls {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
log.Warn().Str("url", rawURL).Msg("[onvif] broken")
|
||||
continue
|
||||
}
|
||||
|
||||
if u.Scheme != "http" {
|
||||
log.Warn().Str("url", rawURL).Msg("[onvif] unsupported")
|
||||
continue
|
||||
}
|
||||
|
||||
u.Scheme = "onvif"
|
||||
u.User = url.UserPassword("user", "pass")
|
||||
|
||||
if u.Path == onvif.PathDevice {
|
||||
u.Path = ""
|
||||
}
|
||||
|
||||
items = append(items, &api.Source{Name: u.Host, URL: u.String()})
|
||||
}
|
||||
} else {
|
||||
client, err := onvif.NewClient(src)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if l := log.Trace(); l.Enabled() {
|
||||
b, _ := client.GetProfiles()
|
||||
l.Msgf("[onvif] src=%s profiles:\n%s", src, b)
|
||||
}
|
||||
|
||||
name, err := client.GetName()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := client.GetProfilesTokens()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
for i, token := range tokens {
|
||||
items = append(items, &api.Source{
|
||||
Name: name + " stream" + strconv.Itoa(i),
|
||||
URL: src + "?subtype=" + token,
|
||||
})
|
||||
}
|
||||
|
||||
if len(tokens) > 0 && client.HasSnapshots() {
|
||||
items = append(items, &api.Source{
|
||||
Name: name + " snapshot",
|
||||
URL: src + "?subtype=" + tokens[0] + "&snapshot",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
api.ResponseSources(w, items)
|
||||
}
|
@@ -2,11 +2,12 @@ package roborock
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"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)
|
||||
}
|
206
internal/rtmp/rtmp.go
Normal file
206
internal/rtmp/rtmp.go
Normal file
@@ -0,0 +1,206 @@
|
||||
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/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) {
|
||||
client, err := rtmp.DialPlay(url)
|
||||
if 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 cons, run, nil
|
||||
}
|
||||
|
||||
func apiHandle(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
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 {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
client, err := flv.Open(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
stream.AddProducer(client)
|
||||
|
||||
if err = client.Start(); err != nil && err != io.EOF {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
|
||||
stream.RemoveProducer(client)
|
||||
}
|
@@ -1,16 +1,17 @@
|
||||
package rtsp
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mp4"
|
||||
"github.com/AlexxIT/go2rtc/pkg/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/rs/zerolog"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
@@ -20,11 +21,12 @@ func Init() {
|
||||
Username string `yaml:"username" json:"-"`
|
||||
Password string `yaml:"password" json:"-"`
|
||||
DefaultQuery string `yaml:"default_query" json:"default_query"`
|
||||
PacketSize uint16 `yaml:"pkt_size"`
|
||||
} `yaml:"rtsp"`
|
||||
}
|
||||
|
||||
// default config
|
||||
conf.Mod.Listen = ":8554"
|
||||
conf.Mod.Listen = "0.0.0.0:8554"
|
||||
conf.Mod.DefaultQuery = "video&audio"
|
||||
|
||||
app.LoadConfig(&conf)
|
||||
@@ -54,7 +56,7 @@ func Init() {
|
||||
log.Info().Str("addr", address).Msg("[rtsp] listen")
|
||||
|
||||
if query, err := url.ParseQuery(conf.Mod.DefaultQuery); err == nil {
|
||||
defaultMedias = mp4.ParseQuery(query)
|
||||
defaultMedias = ParseQuery(query)
|
||||
}
|
||||
|
||||
go func() {
|
||||
@@ -65,6 +67,7 @@ func Init() {
|
||||
}
|
||||
|
||||
c := rtsp.NewServer(conn)
|
||||
c.PacketSize = conf.Mod.PacketSize
|
||||
// skip check auth for localhost
|
||||
if conf.Mod.Username != "" && !conn.RemoteAddr().(*net.TCPAddr).IP.IsLoopback() {
|
||||
c.Auth(conf.Mod.Username, conf.Mod.Password)
|
||||
@@ -88,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) {
|
||||
@@ -118,11 +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", conn.Backchannel, err)
|
||||
|
||||
// second try without backchannel, we need to reconnect
|
||||
conn.Backchannel = false
|
||||
@@ -171,13 +176,18 @@ func tcpHandler(conn *rtsp.Conn) {
|
||||
|
||||
conn.SessionName = app.UserAgent
|
||||
|
||||
conn.Medias = mp4.ParseQuery(conn.URL.Query())
|
||||
query := conn.URL.Query()
|
||||
conn.Medias = ParseQuery(query)
|
||||
if conn.Medias == nil {
|
||||
for _, media := range defaultMedias {
|
||||
conn.Medias = append(conn.Medias, media.Clone())
|
||||
}
|
||||
}
|
||||
|
||||
if s := query.Get("pkt_size"); s != "" {
|
||||
conn.PacketSize = uint16(core.Atoi(s))
|
||||
}
|
||||
|
||||
if err := stream.AddConsumer(conn); err != nil {
|
||||
log.Warn().Err(err).Str("stream", name).Msg("[rtsp]")
|
||||
return
|
||||
@@ -211,7 +221,9 @@ func tcpHandler(conn *rtsp.Conn) {
|
||||
})
|
||||
|
||||
if err := conn.Accept(); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
if err != io.EOF {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
if closer != nil {
|
||||
closer()
|
||||
}
|
||||
@@ -237,3 +249,27 @@ func tcpHandler(conn *rtsp.Conn) {
|
||||
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
func ParseQuery(query map[string][]string) []*core.Media {
|
||||
if v := query["mp4"]; v != nil {
|
||||
return []*core.Media{
|
||||
{
|
||||
Kind: core.KindVideo,
|
||||
Direction: core.DirectionSendonly,
|
||||
Codecs: []*core.Codec{
|
||||
{Name: core.CodecH264},
|
||||
{Name: core.CodecH265},
|
||||
},
|
||||
},
|
||||
{
|
||||
Kind: core.KindAudio,
|
||||
Direction: core.DirectionSendonly,
|
||||
Codecs: []*core.Codec{
|
||||
{Name: core.CodecAAC},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return core.ParseQuery(query)
|
||||
}
|
29
internal/srtp/srtp.go
Normal file
29
internal/srtp/srtp.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package srtp
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/pkg/srtp"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var cfg struct {
|
||||
Mod struct {
|
||||
Listen string `yaml:"listen"`
|
||||
} `yaml:"srtp"`
|
||||
}
|
||||
|
||||
// default config
|
||||
cfg.Mod.Listen = "0.0.0.0:8443"
|
||||
|
||||
// load config from YAML
|
||||
app.LoadConfig(&cfg)
|
||||
|
||||
if cfg.Mod.Listen == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// create SRTP server (endpoint) for receiving video from HomeKit cameras
|
||||
Server = srtp.NewServer(cfg.Mod.Listen)
|
||||
}
|
||||
|
||||
var Server *srtp.Server
|
149
internal/streams/add_consumer.go
Normal file
149
internal/streams/add_consumer.go
Normal file
@@ -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
|
||||
}
|
97
internal/streams/handlers.go
Normal file
97
internal/streams/handlers.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
type Handler func(url string) (core.Producer, error)
|
||||
|
||||
var handlers = map[string]Handler{}
|
||||
|
||||
func HandleFunc(scheme string, handler Handler) {
|
||||
handlers[scheme] = handler
|
||||
}
|
||||
|
||||
func HasProducer(url string) bool {
|
||||
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) {
|
||||
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 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)
|
||||
}
|
19
internal/streams/helpers.go
Normal file
19
internal/streams/helpers.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ParseQuery(s string) url.Values {
|
||||
params := url.Values{}
|
||||
for _, key := range strings.Split(s, "#") {
|
||||
var value string
|
||||
i := strings.IndexByte(key, '=')
|
||||
if i > 0 {
|
||||
key, value = key[:i], key[i+1:]
|
||||
}
|
||||
params[key] = append(params[key], value)
|
||||
}
|
||||
return params
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user