mirror of
https://github.com/AlexxIT/go2rtc.git
synced 2025-09-27 04:36:12 +08:00
Compare commits
578 Commits
v0.1-beta.
...
v1.5.0
Author | SHA1 | Date | |
---|---|---|---|
![]() |
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 | ||
![]() |
c44aaebd65 | ||
![]() |
d6259fc0e9 | ||
![]() |
5c657d557a | ||
![]() |
93be5cd92f | ||
![]() |
cf6a35d0c7 | ||
![]() |
af79e6054b | ||
![]() |
9f3d5e7460 | ||
![]() |
abbf180b1b | ||
![]() |
696588e52e | ||
![]() |
3e97ce8b2a | ||
![]() |
722b2827a1 | ||
![]() |
69598b508c | ||
![]() |
f49fcc4f68 | ||
![]() |
59347a409e | ||
![]() |
45b25d29b7 | ||
![]() |
49e861d1b0 | ||
![]() |
b1701e856a | ||
![]() |
a6260d0f56 | ||
![]() |
693d41be87 | ||
![]() |
222dc6a5c2 | ||
![]() |
8fde2b6fe5 | ||
![]() |
15e205cc01 | ||
![]() |
1db9ed4946 | ||
![]() |
fd83d151d2 | ||
![]() |
71051e7dcf | ||
![]() |
cdb3ee45cf | ||
![]() |
ae99c1da03 | ||
![]() |
863cc0c1d7 | ||
![]() |
40494ab87c | ||
![]() |
bffe5f0aa2 | ||
![]() |
8241af8b9d | ||
![]() |
5c164de393 | ||
![]() |
8bf5c85b79 | ||
![]() |
a42c3e21c9 | ||
![]() |
7016289f14 | ||
![]() |
54302d3bda | ||
![]() |
af6b8a400d | ||
![]() |
a1b5eae653 | ||
![]() |
bb3c64598c | ||
![]() |
3002d5f4f1 | ||
![]() |
cca4f0500e | ||
![]() |
b087be9c56 | ||
![]() |
2d5a0e4822 | ||
![]() |
acf5ec5256 | ||
![]() |
e1e8abc334 | ||
![]() |
d84efd1238 | ||
![]() |
7c79c1ff26 | ||
![]() |
43840576ea | ||
![]() |
bd79b24db3 | ||
![]() |
e728643aad | ||
![]() |
12a7b96289 | ||
![]() |
2146ea470b | ||
![]() |
d4d91e4920 | ||
![]() |
a6393da956 | ||
![]() |
d686d4f691 | ||
![]() |
58849fd1e5 | ||
![]() |
31c86272bb | ||
![]() |
0382fbf8a9 | ||
![]() |
0b714a59e5 | ||
![]() |
13c426e2a9 | ||
![]() |
d6d21286c1 | ||
![]() |
ce2898ac3a | ||
![]() |
e0320b8ead | ||
![]() |
a960b9b9ee | ||
![]() |
0b4ebb4e21 | ||
![]() |
a1dd941814 | ||
![]() |
146fb62b8e | ||
![]() |
53e8fed0b0 | ||
![]() |
3d34854387 | ||
![]() |
77842643c8 | ||
![]() |
f9fe22569c | ||
![]() |
e17645ac02 | ||
![]() |
1fc2cf3175 | ||
![]() |
775b1818d1 | ||
![]() |
1e83dc85f7 | ||
![]() |
03a4393ce3 | ||
![]() |
5f2368b0f9 | ||
![]() |
59b35f2501 | ||
![]() |
9ab9412c95 | ||
![]() |
d805d560b9 | ||
![]() |
5aa20f0845 | ||
![]() |
c2cdf60ffc | ||
![]() |
c70c3a58f1 | ||
![]() |
df0ab77791 | ||
![]() |
402df50b65 | ||
![]() |
5c084c9989 | ||
![]() |
1703e0dce8 | ||
![]() |
7301f55e4a | ||
![]() |
06fc9717df | ||
![]() |
4e19c54467 | ||
![]() |
a0e04fb70e | ||
![]() |
218eea6806 | ||
![]() |
ad3c5440fe | ||
![]() |
f5892e4cfc | ||
![]() |
4328d2a573 | ||
![]() |
3fb917f00f | ||
![]() |
eca79f1c0b | ||
![]() |
bd9b69d0d5 | ||
![]() |
676ec25a7f | ||
![]() |
12d10ae14e | ||
![]() |
1912a43679 | ||
![]() |
eb1f423da3 | ||
![]() |
5846cbd989 | ||
![]() |
ab1b3932ac | ||
![]() |
eca311717a | ||
![]() |
d3b2b8fdae | ||
![]() |
3b9a0059df | ||
![]() |
1fe21bb300 | ||
![]() |
41cdcb69c6 | ||
![]() |
6b00134575 | ||
![]() |
5519f3e061 | ||
![]() |
e312d0b46b | ||
![]() |
eff7b27293 | ||
![]() |
e3f6c459c7 | ||
![]() |
91399d3194 | ||
![]() |
338da2a747 | ||
![]() |
bb5df24ecf | ||
![]() |
adb424033f | ||
![]() |
70c415a1d8 | ||
![]() |
9fd783793e | ||
![]() |
665545903c | ||
![]() |
830baafffe | ||
![]() |
a22c33fd4e | ||
![]() |
1ad09f48cc | ||
![]() |
5b1ec08341 | ||
![]() |
3f22c010ce | ||
![]() |
df5f585064 | ||
![]() |
c1b810a5fe | ||
![]() |
e43b1e4ab6 | ||
![]() |
a8612fca43 | ||
![]() |
0d18c23cc2 | ||
![]() |
c22ede2396 | ||
![]() |
6b3a2652b2 | ||
![]() |
4bf5034ce7 | ||
![]() |
b57027441c | ||
![]() |
d3b62d82cf | ||
![]() |
836701cb68 | ||
![]() |
3aee438e37 | ||
![]() |
116e2f739b | ||
![]() |
47371fbdcf | ||
![]() |
6e62c442f8 | ||
![]() |
57b49d735e | ||
![]() |
a72fa7fb23 | ||
![]() |
b2029d1004 | ||
![]() |
3f338c83b7 | ||
![]() |
00b445a170 | ||
![]() |
4cbbb5407c | ||
![]() |
80f77d28c8 | ||
![]() |
f60b55b6fa | ||
![]() |
c42413866d | ||
![]() |
b137eb66d0 | ||
![]() |
6a40039645 | ||
![]() |
2e4b28d871 | ||
![]() |
58146b7e7e | ||
![]() |
23db40220b | ||
![]() |
557aac185d | ||
![]() |
9ed4d4cedb | ||
![]() |
b05cbdf3d3 | ||
![]() |
497594f53f | ||
![]() |
73cdb39335 | ||
![]() |
a388002b12 | ||
![]() |
6d1c0a2459 | ||
![]() |
da3137b6f0 | ||
![]() |
d21ce3d27d | ||
![]() |
8cee4179f2 | ||
![]() |
1153ee3652 | ||
![]() |
3240301f27 | ||
![]() |
2a20251dbd | ||
![]() |
5a2d7de56b | ||
![]() |
38ea8b56b8 | ||
![]() |
08c2174e94 | ||
![]() |
b48f1c1a0b | ||
![]() |
cf58a6f952 | ||
![]() |
350e677838 | ||
![]() |
7b3505f4f4 | ||
![]() |
98af8c3dbf | ||
![]() |
762edf157a | ||
![]() |
4a633cd9b5 | ||
![]() |
f4d2c801f0 | ||
![]() |
fb4b609914 | ||
![]() |
56633229ed | ||
![]() |
2d49cfd4b6 | ||
![]() |
0f934be9b6 | ||
![]() |
c1d6adc189 | ||
![]() |
500b8720d5 | ||
![]() |
b7391f58a5 | ||
![]() |
bef8e6454d | ||
![]() |
5243aca8e9 | ||
![]() |
69dd4d26ec | ||
![]() |
e93d89ec96 | ||
![]() |
ec56227900 | ||
![]() |
decd3af941 | ||
![]() |
e8e43f9d68 | ||
![]() |
a1fec1c6f6 | ||
![]() |
073acdfec9 | ||
![]() |
d05ab79f88 | ||
![]() |
e295bc4eaf | ||
![]() |
2f436bba4e | ||
![]() |
0e28b0c797 | ||
![]() |
3acea1ed5a | ||
![]() |
3fb8d9af66 | ||
![]() |
9bbaf41d54 | ||
![]() |
c43530fbd3 | ||
![]() |
15777a3d94 | ||
![]() |
6e61ac6d2f | ||
![]() |
6d7d5f53d8 | ||
![]() |
d2bca8d461 | ||
![]() |
94b089d1e3 | ||
![]() |
b3d16c9fcc | ||
![]() |
a36359f3dd | ||
![]() |
f0def68482 | ||
![]() |
9ddbb326b4 | ||
![]() |
a2e58d928e | ||
![]() |
3c48fb8bea | ||
![]() |
4b0cbb5a73 | ||
![]() |
e28b49ea86 | ||
![]() |
5c17d8fcb6 | ||
![]() |
e040fb591f | ||
![]() |
140014f2a6 | ||
![]() |
23f72d111e | ||
![]() |
f9d5ab9d0a | ||
![]() |
8628c48db8 | ||
![]() |
6e49d51c33 | ||
![]() |
6a61b5234e | ||
![]() |
7a0091777d | ||
![]() |
d23d2a7eff | ||
![]() |
cecbe4166c | ||
![]() |
dcb457235c | ||
![]() |
bc4e032830 | ||
![]() |
8218cda149 | ||
![]() |
d1e56feeb6 | ||
![]() |
463d05dfd3 | ||
![]() |
a1a73f7b45 | ||
![]() |
39662e10af | ||
![]() |
1c830d6e60 | ||
![]() |
2039aa60b3 | ||
![]() |
b7016e798f | ||
![]() |
0b291f5185 | ||
![]() |
395304654a | ||
![]() |
e472397705 | ||
![]() |
7c1f48e0ad | ||
![]() |
f4346a104f | ||
![]() |
030972b436 | ||
![]() |
efddefa123 | ||
![]() |
3c1bdd0dab | ||
![]() |
7e7e15d7c8 | ||
![]() |
a1a9f77535 | ||
![]() |
a06462729d | ||
![]() |
331c5bbcad | ||
![]() |
58a76efc8a | ||
![]() |
5e0f010885 | ||
![]() |
4ae733aa11 | ||
![]() |
27d8b33b62 | ||
![]() |
ff8b0fbb9c | ||
![]() |
c6ad7ac39f | ||
![]() |
7a3adf17be | ||
![]() |
94f6c07b28 | ||
![]() |
7b326d4753 | ||
![]() |
5407a3bc4b | ||
![]() |
6b24421722 | ||
![]() |
d12775a2d7 | ||
![]() |
6151593c08 | ||
![]() |
dba0989c54 | ||
![]() |
ba0c7d911d | ||
![]() |
09fefca712 | ||
![]() |
b3f177e2ec | ||
![]() |
228abb8fbe | ||
![]() |
eee70c07b7 | ||
![]() |
d92b0f29af | ||
![]() |
fca6c87b2c | ||
![]() |
0601091772 | ||
![]() |
89eb653d67 | ||
![]() |
0e49ffdfff | ||
![]() |
bd2fc1252d | ||
![]() |
78ac88448c | ||
![]() |
4cd9757e53 | ||
![]() |
f9cb6fd670 | ||
![]() |
57fa6a5530 | ||
![]() |
6906b56524 | ||
![]() |
c9b0806c84 | ||
![]() |
a9d1e64f88 | ||
![]() |
9e9f07f3f7 | ||
![]() |
b51aabd3d9 | ||
![]() |
368562c540 | ||
![]() |
6d6e7010b4 | ||
![]() |
4157a53dd8 | ||
![]() |
bdf5654c01 | ||
![]() |
66f729aa0e | ||
![]() |
96d1ef2d2c | ||
![]() |
9739f7f416 | ||
![]() |
654fa32b3a | ||
![]() |
db2263c7fe | ||
![]() |
e6c36f1cf7 | ||
![]() |
110f90cb34 | ||
![]() |
aca3bab238 | ||
![]() |
4df44645d7 | ||
![]() |
097fdfbbb8 | ||
![]() |
dc21a04da7 | ||
![]() |
db255b476a | ||
![]() |
464ea417ef | ||
![]() |
c1fac66329 | ||
![]() |
a6057a2eca | ||
![]() |
7c69ba13b0 | ||
![]() |
2b8bfe8bd9 | ||
![]() |
0bd54da456 | ||
![]() |
9f6af1c9e4 | ||
![]() |
c9dd0e37e4 | ||
![]() |
562872beb8 | ||
![]() |
46a278c067 | ||
![]() |
270fc7c1b6 | ||
![]() |
6feb635522 | ||
![]() |
6f48131e4d | ||
![]() |
f120db71a3 | ||
![]() |
72823af9d0 | ||
![]() |
15d9d4ebf4 | ||
![]() |
b09bbd79c4 | ||
![]() |
1830273f02 | ||
![]() |
07f3972794 | ||
![]() |
4c2ebd20bc | ||
![]() |
440c7bd6e1 | ||
![]() |
74c3510a10 | ||
![]() |
c2748fc77b | ||
![]() |
d334551591 | ||
![]() |
cfe20925ac | ||
![]() |
5b39f78ace | ||
![]() |
b965c191b7 | ||
![]() |
7057b4846f | ||
![]() |
a746b96adc | ||
![]() |
b7718b33b8 | ||
![]() |
69b17230f3 | ||
![]() |
e2ecd909ab | ||
![]() |
ea79da0d53 | ||
![]() |
e64919838c | ||
![]() |
162b11213d | ||
![]() |
d27acbd7e3 | ||
![]() |
a692ecd7c1 | ||
![]() |
98c5366ba9 | ||
![]() |
3eaaa3fcfa | ||
![]() |
7409b32836 | ||
![]() |
e2cfdf8419 | ||
![]() |
4c0929d854 | ||
![]() |
258a0ffb91 | ||
![]() |
999e81c2dd | ||
![]() |
8c6729027b | ||
![]() |
d3bd5eeab5 | ||
![]() |
dbbf2ea310 | ||
![]() |
b8234e0c76 | ||
![]() |
96cd753e27 | ||
![]() |
c522e5bb08 | ||
![]() |
a16d8acc30 | ||
![]() |
684878b4b1 | ||
![]() |
140a742cee | ||
![]() |
1b518b94fd | ||
![]() |
5678121c50 | ||
![]() |
4915f12bde | ||
![]() |
bb91240b95 | ||
![]() |
b1d5d53832 | ||
![]() |
31fbbf91bb | ||
![]() |
a3f72fbab9 | ||
![]() |
fae59c7992 | ||
![]() |
aff34f1d21 | ||
![]() |
65e7efa775 | ||
![]() |
3c3e9d282b | ||
![]() |
bd51069086 | ||
![]() |
1ddf7f1a6c | ||
![]() |
0e281e36d3 | ||
![]() |
3d6472cfb1 | ||
![]() |
7c31fa2ffd | ||
![]() |
0ed9d2410a | ||
![]() |
1c89e7945e | ||
![]() |
48635ae341 | ||
![]() |
fdb316910f | ||
![]() |
e29f2594fa | ||
![]() |
c3da7584b0 | ||
![]() |
1e247cba92 | ||
![]() |
01631d9eb0 | ||
![]() |
4b27d119f0 | ||
![]() |
dd55c03dc2 | ||
![]() |
a4eab1944a | ||
![]() |
eea413a36c | ||
![]() |
cdd42a8ed2 | ||
![]() |
4815ce1baf | ||
![]() |
e6d3939c78 | ||
![]() |
220b9ca318 | ||
![]() |
d625620dfd | ||
![]() |
dd503f3410 | ||
![]() |
3e8e87bfcc | ||
![]() |
64d218886e | ||
![]() |
e91ccc211e | ||
![]() |
9f8a219483 | ||
![]() |
b617796941 | ||
![]() |
77888fe086 | ||
![]() |
7bc3534bcb | ||
![]() |
77bc0630d6 | ||
![]() |
2f68711405 | ||
![]() |
b8cab5db60 | ||
![]() |
eae01be71f | ||
![]() |
0127115180 | ||
![]() |
aef84cef6b | ||
![]() |
d478436758 | ||
![]() |
f77db44529 | ||
![]() |
149d1bf235 | ||
![]() |
b650475b10 | ||
![]() |
16e5406156 | ||
![]() |
49f6233bde | ||
![]() |
78c5c70c73 | ||
![]() |
32651c74ab | ||
![]() |
5c64d1f847 | ||
![]() |
717af29630 | ||
![]() |
ea18475d31 | ||
![]() |
701a9c69ec | ||
![]() |
c06253c8b2 | ||
![]() |
3a07e9fa03 | ||
![]() |
e1bc30fab3 | ||
![]() |
d16ae0972f | ||
![]() |
8b93c97e69 | ||
![]() |
d8158bc1e3 | ||
![]() |
f4f588d2c6 | ||
![]() |
e287b52808 | ||
![]() |
ff96257252 | ||
![]() |
909f21b7e4 | ||
![]() |
7d6a5b44f8 | ||
![]() |
278f7696b6 | ||
![]() |
3cbf2465ae | ||
![]() |
e9ea7a0b1f | ||
![]() |
0231fc3a90 | ||
![]() |
9ef2633840 | ||
![]() |
5a8df3e90a | ||
![]() |
a31cbec3eb | ||
![]() |
54f547977e | ||
![]() |
65d91e02bd | ||
![]() |
7fc3f0f641 | ||
![]() |
7725d5ed31 | ||
![]() |
6c1b9daa8b | ||
![]() |
6d432574bf | ||
![]() |
616f69c88b | ||
![]() |
f72440712b | ||
![]() |
ceed146fb8 | ||
![]() |
f17dadbbbf | ||
![]() |
3d4514eab9 | ||
![]() |
2629dccb81 | ||
![]() |
04f1aa2900 | ||
![]() |
0dacdea1c3 | ||
![]() |
24082b1616 | ||
![]() |
7964b1743b | ||
![]() |
49773a1ece | ||
![]() |
c97a48a73f | ||
![]() |
e03231ebb4 | ||
![]() |
649525a842 | ||
![]() |
d411c1a25c | ||
![]() |
2f0bcf4ae0 | ||
![]() |
831c504cab | ||
![]() |
12925a6bc5 | ||
![]() |
e50e929150 | ||
![]() |
d0c87e0379 | ||
![]() |
247b61790e | ||
![]() |
2ec618334a | ||
![]() |
6f9976c806 | ||
![]() |
17b3a4cf3a | ||
![]() |
ba30f46c02 | ||
![]() |
4134f2a89c | ||
![]() |
a81160bea1 | ||
![]() |
80392acb78 | ||
![]() |
5afac513b4 | ||
![]() |
2243110e08 | ||
![]() |
04a6e64650 | ||
![]() |
62c13f016b | ||
![]() |
9596c6139f | ||
![]() |
34f5b99126 | ||
![]() |
b562392d45 | ||
![]() |
eb8a4919a2 | ||
![]() |
237fbf23a1 | ||
![]() |
12a73b00cb | ||
![]() |
ce0fac959f | ||
![]() |
1b14be7033 | ||
![]() |
bbbade4097 | ||
![]() |
8f43ad2a35 | ||
![]() |
105331d50f | ||
![]() |
a45d0b507b | ||
![]() |
407ccc45bc | ||
![]() |
428628fcce | ||
![]() |
fa23bb6899 | ||
![]() |
71e1c840a7 | ||
![]() |
63b9639e86 | ||
![]() |
ae3e1372c8 | ||
![]() |
800ebb39be | ||
![]() |
3a10cb25bb | ||
![]() |
7784b0e64c | ||
![]() |
945b486fe0 | ||
![]() |
d72d7b089c | ||
![]() |
d339fbe712 | ||
![]() |
3aeb278c47 | ||
![]() |
c92c1fc3e9 | ||
![]() |
def57119f4 | ||
![]() |
b20275d2b5 | ||
![]() |
a11ca1da6e | ||
![]() |
0fb7132947 | ||
![]() |
0f9e3c97c5 | ||
![]() |
e049a17216 | ||
![]() |
217c8c2bf6 | ||
![]() |
9f0153e2a8 | ||
![]() |
b2eaf03914 | ||
![]() |
8b54444c89 | ||
![]() |
76b352d67f | ||
![]() |
e8edb65a31 | ||
![]() |
88a6208912 | ||
![]() |
14b6df68ce | ||
![]() |
77080663ee | ||
![]() |
d25d27a0ee |
59
.github/workflows/builder.yml
vendored
59
.github/workflows/builder.yml
vendored
@@ -1,59 +0,0 @@
|
||||
# https://github.com/home-assistant/builder
|
||||
name: 'Builder'
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [ 'v*' ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
hassio:
|
||||
name: Hassio Addon
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Branch name
|
||||
run: |
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
echo "REPO=alexxit/go2rtc" >> $GITHUB_ENV
|
||||
echo "TAG=${VERSION}" >> $GITHUB_ENV
|
||||
echo "IMAGE=alexxit/go2rtc:${VERSION}" >> $GITHUB_ENV
|
||||
|
||||
- name: Build amd64
|
||||
uses: home-assistant/builder@master
|
||||
with:
|
||||
args: --amd64 --target build/hassio --version $TAG-amd64 --no-latest --docker-hub-check
|
||||
|
||||
- name: Build i386
|
||||
uses: home-assistant/builder@master
|
||||
with:
|
||||
args: --i386 --target build/hassio --version $TAG-i386 --no-latest --docker-hub-check
|
||||
|
||||
- name: Build aarch64
|
||||
uses: home-assistant/builder@master
|
||||
with:
|
||||
args: --aarch64 --target build/hassio --version $TAG-aarch64 --no-latest --docker-hub-check
|
||||
|
||||
- name: Build armv7
|
||||
uses: home-assistant/builder@master
|
||||
with:
|
||||
args: --armv7 --target build/hassio --version $TAG-armv7 --no-latest --docker-hub-check
|
||||
|
||||
- name: Docker manifest
|
||||
run: |
|
||||
# thanks to https://github.com/aler9/rtsp-simple-server/blob/main/Makefile
|
||||
docker manifest create "${IMAGE}" \
|
||||
"${IMAGE}-amd64" "${IMAGE}-i386" "${IMAGE}-aarch64" "${IMAGE}-armv7"
|
||||
docker manifest push "${IMAGE}"
|
||||
|
||||
docker manifest create "${REPO}:latest" \
|
||||
"${IMAGE}-amd64" "${IMAGE}-i386" "${IMAGE}-aarch64" "${IMAGE}-armv7"
|
||||
docker manifest push "${REPO}:latest"
|
78
.github/workflows/docker.yml
vendored
Normal file
78
.github/workflows/docker.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
name: docker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}},enable=false
|
||||
type=match,pattern=v(.*),group=1
|
||||
|
||||
- name: Docker meta Hardware
|
||||
id: meta-hw
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ github.repository }}
|
||||
flavor: |
|
||||
suffix=-hardware
|
||||
latest=false
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}},enable=false
|
||||
type=match,pattern=v(.*),group=1
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/386
|
||||
linux/arm/v7
|
||||
linux/arm64/v8
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- name: Build and push Hardware
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: hardware.Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta-hw.outputs.tags }}
|
||||
labels: ${{ steps.meta-hw.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
37
.github/workflows/gh-pages.yml
vendored
Normal file
37
.github/workflows/gh-pages.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
# Simple workflow for deploying static content to GitHub Pages
|
||||
name: Deploy static content to Pages
|
||||
|
||||
on:
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# Allow one concurrent deployment
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Single deploy job since we're just deploying
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v3
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v1
|
||||
with:
|
||||
path: './website'
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v2
|
99
.github/workflows/release.yml
vendored
Normal file
99
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,99 @@
|
||||
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
|
103
.github/workflows/test.yml
vendored
Normal file
103
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,103 @@
|
||||
name: Test Build and Run
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
pull_request:
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-test:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-latest, macos-latest]
|
||||
arch: [amd64, arm64]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GOARCH: ${{ matrix.arch }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: '1.20'
|
||||
|
||||
- name: Build Go binary
|
||||
run: go build -ldflags "-s -w" -trimpath -o ./go2rtc
|
||||
|
||||
- name: Test Go binary on linux
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
if [ "${{ matrix.arch }}" = "amd64" ]; then
|
||||
./go2rtc -version
|
||||
else
|
||||
sudo apt-get update && sudo apt-get install -y qemu-user-static
|
||||
sudo cp /usr/bin/qemu-aarch64-static .
|
||||
sudo chown $USER:$USER ./qemu-aarch64-static
|
||||
qemu-aarch64-static ./go2rtc -version
|
||||
fi
|
||||
- name: Test Go binary on macos
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: |
|
||||
if [ "${{ matrix.arch }}" = "amd64" ]; then
|
||||
./go2rtc -version
|
||||
else
|
||||
echo "ARM64 architecture is not yet supported on macOS"
|
||||
fi
|
||||
- name: Test Go binary on windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: |
|
||||
if ("${{ matrix.arch }}" -eq "amd64") {
|
||||
.\go2rtc* -version
|
||||
} else {
|
||||
Write-Host "ARM64 architecture is not yet supported on Windows"
|
||||
}
|
||||
docker-test:
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
- amd64
|
||||
- "386"
|
||||
- arm/v7
|
||||
- arm64/v8
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/${{ matrix.platform }}
|
||||
push: false
|
||||
load: true
|
||||
tags: go2rtc-${{ matrix.platform }}
|
||||
- name: test run
|
||||
run: |
|
||||
docker run --platform=linux/${{ matrix.platform }} --rm go2rtc-${{ matrix.platform }} go2rtc -version
|
||||
|
||||
- name: Build and push Hardware
|
||||
if: matrix.platform == 'amd64'
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: hardware.Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
tags: go2rtc-${{ matrix.platform }}-hardware
|
||||
- name: test run
|
||||
if: matrix.platform == 'amd64'
|
||||
run: |
|
||||
docker run --platform=linux/${{ matrix.platform }} --rm go2rtc-${{ matrix.platform }}-hardware go2rtc -version
|
62
Dockerfile
Normal file
62
Dockerfile
Normal file
@@ -0,0 +1,62 @@
|
||||
# syntax=docker/dockerfile:labs
|
||||
|
||||
# 0. Prepare images
|
||||
ARG PYTHON_VERSION="3.11"
|
||||
ARG GO_VERSION="1.20"
|
||||
ARG NGROK_VERSION="3"
|
||||
|
||||
FROM python:${PYTHON_VERSION}-alpine AS base
|
||||
FROM ngrok/ngrok:${NGROK_VERSION}-alpine AS ngrok
|
||||
|
||||
|
||||
# 1. Build go2rtc binary
|
||||
FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS build
|
||||
ARG TARGETPLATFORM
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
ENV GOOS=${TARGETOS}
|
||||
ENV GOARCH=${TARGETARCH}
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Cache dependencies
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build go mod download
|
||||
|
||||
COPY . .
|
||||
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/
|
||||
|
||||
|
||||
# 3. Final image
|
||||
FROM base
|
||||
|
||||
# Install ffmpeg, tini (for signal handling),
|
||||
# and other common tools for the echo source.
|
||||
# alsa-plugins-pulse for ALSA support (+0MB)
|
||||
RUN apk add --no-cache tini ffmpeg bash curl jq alsa-plugins-pulse
|
||||
|
||||
# Hardware Acceleration for Intel CPU (+50MB)
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN if [ "${TARGETARCH}" = "amd64" ]; then apk add --no-cache libva-intel-driver intel-media-driver; fi
|
||||
|
||||
# Hardware: AMD and NVidia VAAPI (not sure about this)
|
||||
# RUN libva-glx mesa-va-gallium
|
||||
# Hardware: AMD and NVidia VDPAU (not sure about this)
|
||||
# RUN libva-vdpau-driver mesa-vdpau-gallium (+150MB total)
|
||||
|
||||
COPY --from=rootfs / /
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "--"]
|
||||
VOLUME /config
|
||||
WORKDIR /config
|
||||
|
||||
CMD ["go2rtc", "-config", "/config/go2rtc.yaml"]
|
@@ -1,24 +0,0 @@
|
||||
ARG BUILD_FROM
|
||||
FROM $BUILD_FROM
|
||||
|
||||
RUN apk add --no-cache git go ffmpeg
|
||||
|
||||
ARG BUILD_ARCH
|
||||
|
||||
RUN git clone https://github.com/AlexxIT/go2rtc \
|
||||
&& cd go2rtc \
|
||||
&& CGO_ENABLED=0 go build -ldflags "-s -w" -trimpath -o /usr/local/bin
|
||||
|
||||
# https://github.com/home-assistant/docker-base/blob/master/alpine/Dockerfile
|
||||
RUN if [ "${BUILD_ARCH}" = "aarch64" ]; then BUILD_ARCH="arm64"; \
|
||||
elif [ "${BUILD_ARCH}" = "armv7" ]; then BUILD_ARCH="arm"; fi \
|
||||
&& cd go2rtc \
|
||||
&& curl $(curl -s "https://raw.githubusercontent.com/ngrok/docker-ngrok/main/releases.json" | jq -r ".${BUILD_ARCH}.url") -o ngrok.zip \
|
||||
&& unzip ngrok -d /usr/local/bin
|
||||
|
||||
RUN rm -r /go2rtc
|
||||
|
||||
COPY run.sh /
|
||||
RUN chmod a+x /run.sh
|
||||
|
||||
CMD [ "/run.sh" ]
|
@@ -1,6 +0,0 @@
|
||||
# https://github.com/home-assistant/builder/blob/master/builder.sh
|
||||
name: go2rtc
|
||||
description: Ultimate camera streaming application
|
||||
url: https://github.com/AlexxIT/go2rtc
|
||||
image: alexxit/go2rtc
|
||||
arch: [ amd64, aarch64, i386, armv7 ]
|
@@ -1,14 +0,0 @@
|
||||
#!/usr/bin/with-contenv bashio
|
||||
|
||||
set +e
|
||||
|
||||
# set cwd for go2rtc (for config file, Hass itegration, etc)
|
||||
cd /config
|
||||
|
||||
# add the feature to override go2rtc binary from Hass config folder
|
||||
export PATH="/config:$PATH"
|
||||
|
||||
while true; do
|
||||
go2rtc
|
||||
sleep 5
|
||||
done
|
@@ -1,4 +0,0 @@
|
||||
**Project layout**
|
||||
|
||||
- https://github.com/golang-standards/project-layout
|
||||
- https://github.com/micro/micro
|
123
cmd/api/api.go
123
cmd/api/api.go
@@ -1,123 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/rs/zerolog"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var cfg struct {
|
||||
Mod struct {
|
||||
Listen string `yaml:"listen"`
|
||||
BasePath string `yaml:"base_path"`
|
||||
StaticDir string `yaml:"static_dir"`
|
||||
} `yaml:"api"`
|
||||
}
|
||||
|
||||
// default config
|
||||
cfg.Mod.Listen = ":1984"
|
||||
|
||||
// load config from YAML
|
||||
app.LoadConfig(&cfg)
|
||||
|
||||
if cfg.Mod.Listen == "" {
|
||||
return
|
||||
}
|
||||
|
||||
basePath = cfg.Mod.BasePath
|
||||
log = app.GetLogger("api")
|
||||
|
||||
initStatic(cfg.Mod.StaticDir)
|
||||
|
||||
HandleFunc("/api/frame.mp4", frameHandler)
|
||||
HandleFunc("/api/frame.raw", frameHandler)
|
||||
HandleFunc("/api/streams", streamsHandler)
|
||||
HandleFunc("/api/ws", apiWS)
|
||||
|
||||
// ensure we can listen without errors
|
||||
listener, err := net.Listen("tcp", cfg.Mod.Listen)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("[api] listen")
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().Str("addr", cfg.Mod.Listen).Msg("[api] listen")
|
||||
|
||||
go func() {
|
||||
s := http.Server{}
|
||||
if err = s.Serve(listener); err != nil {
|
||||
log.Fatal().Err(err).Msg("[api] serve")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func HandleFunc(pattern string, handler http.HandlerFunc) {
|
||||
http.HandleFunc(basePath+pattern, handler)
|
||||
}
|
||||
|
||||
func HandleWS(msgType string, handler WSHandler) {
|
||||
wsHandlers[msgType] = handler
|
||||
}
|
||||
|
||||
var basePath string
|
||||
var log zerolog.Logger
|
||||
var wsHandlers = make(map[string]WSHandler)
|
||||
|
||||
func streamsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
src := r.URL.Query().Get("src")
|
||||
|
||||
switch r.Method {
|
||||
case "PUT":
|
||||
streams.Get(src)
|
||||
return
|
||||
case "DELETE":
|
||||
streams.Delete(src)
|
||||
return
|
||||
}
|
||||
|
||||
var v interface{}
|
||||
if src != "" {
|
||||
v = streams.Get(src)
|
||||
} else {
|
||||
v = streams.All()
|
||||
}
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.streams] marshal")
|
||||
}
|
||||
if _, err = w.Write(data); err != nil {
|
||||
log.Error().Err(err).Msg("[api.streams] write")
|
||||
}
|
||||
}
|
||||
|
||||
func apiWS(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := new(Context)
|
||||
if err := ctx.Upgrade(w, r); err != nil {
|
||||
log.Error().Err(err).Msg("[api.ws] upgrade")
|
||||
return
|
||||
}
|
||||
defer ctx.Close()
|
||||
|
||||
for {
|
||||
msg := new(streamer.Message)
|
||||
if err := ctx.Conn.ReadJSON(msg); err != nil {
|
||||
if websocket.IsUnexpectedCloseError(
|
||||
err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure,
|
||||
) {
|
||||
log.Error().Err(err).Msg("[api.ws] readJSON")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
handler := wsHandlers[msg.Type]
|
||||
if handler != nil {
|
||||
handler(ctx, msg)
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,40 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/keyframe"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func frameHandler(w http.ResponseWriter, r *http.Request) {
|
||||
src := r.URL.Query().Get("src")
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var ch = make(chan []byte)
|
||||
|
||||
cons := new(keyframe.Consumer)
|
||||
cons.IsMP4 = strings.HasSuffix(r.URL.Path, ".mp4")
|
||||
cons.Listen(func(msg interface{}) {
|
||||
switch msg.(type) {
|
||||
case []byte:
|
||||
ch <- msg.([]byte)
|
||||
}
|
||||
})
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Warn().Err(err).Msg("[api.frame] add consumer")
|
||||
return
|
||||
}
|
||||
|
||||
data := <-ch
|
||||
|
||||
stream.RemoveConsumer(cons)
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
log.Error().Err(err).Msg("[api.frame] write")
|
||||
}
|
||||
}
|
@@ -1,67 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/gorilla/websocket"
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type WSHandler func(ctx *Context, msg *streamer.Message)
|
||||
|
||||
var apiWsUp = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 512000,
|
||||
}
|
||||
|
||||
type Context struct {
|
||||
Conn *websocket.Conn
|
||||
Request *http.Request
|
||||
Consumer interface{} // TODO: rewrite
|
||||
|
||||
onClose []func()
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (ctx *Context) Upgrade(w http.ResponseWriter, r *http.Request) (err error) {
|
||||
ctx.Conn, err = apiWsUp.Upgrade(w, r, nil)
|
||||
ctx.Request = r
|
||||
return
|
||||
}
|
||||
|
||||
func (ctx *Context) Close() {
|
||||
for _, f := range ctx.onClose {
|
||||
f()
|
||||
}
|
||||
_ = ctx.Conn.Close()
|
||||
}
|
||||
|
||||
func (ctx *Context) Write(msg interface{}) {
|
||||
ctx.mu.Lock()
|
||||
defer ctx.mu.Unlock()
|
||||
|
||||
var err error
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case *streamer.Message:
|
||||
err = ctx.Conn.WriteJSON(msg)
|
||||
case []byte:
|
||||
err = ctx.Conn.WriteMessage(websocket.BinaryMessage, msg)
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
//panic(err) // TODO: fix panic
|
||||
}
|
||||
}
|
||||
|
||||
func (ctx *Context) Error(err error) {
|
||||
ctx.Write(&streamer.Message{
|
||||
Type: "error", Value: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
func (ctx *Context) OnClose(f func()) {
|
||||
ctx.onClose = append(ctx.onClose, f)
|
||||
}
|
@@ -1,91 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"github.com/rs/zerolog"
|
||||
"gopkg.in/yaml.v3"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
config := flag.String(
|
||||
"config",
|
||||
"go2rtc.yaml",
|
||||
"Path to go2rtc configuration file",
|
||||
)
|
||||
|
||||
flag.Parse()
|
||||
|
||||
data, _ = os.ReadFile(*config)
|
||||
|
||||
var cfg struct {
|
||||
Mod map[string]string `yaml:"log"`
|
||||
}
|
||||
|
||||
if data != nil {
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
println("ERROR: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
var writer io.Writer = os.Stdout
|
||||
|
||||
// styles
|
||||
format := cfg.Mod["format"]
|
||||
if format != "json" {
|
||||
writer = zerolog.ConsoleWriter{
|
||||
Out: writer, TimeFormat: "15:04:05.000",
|
||||
NoColor: writer != os.Stdout || format == "text",
|
||||
}
|
||||
}
|
||||
|
||||
zerolog.TimeFieldFormat = zerolog.TimeFormatUnixMs
|
||||
|
||||
lvl, err := zerolog.ParseLevel(cfg.Mod["level"])
|
||||
if err != nil || lvl == zerolog.NoLevel {
|
||||
lvl = zerolog.InfoLevel
|
||||
}
|
||||
|
||||
log = zerolog.New(writer).With().Timestamp().Logger().Level(lvl)
|
||||
|
||||
modules = cfg.Mod
|
||||
|
||||
path, _ := os.Getwd()
|
||||
log.Debug().Str("os", runtime.GOOS).Str("arch", runtime.GOARCH).
|
||||
Str("cwd", path).Int("conf_size", len(data)).Msgf("[app]")
|
||||
}
|
||||
|
||||
func LoadConfig(v interface{}) {
|
||||
if data != nil {
|
||||
if err := yaml.Unmarshal(data, v); err != nil {
|
||||
log.Warn().Err(err).Msg("[app] read config")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GetLogger(module string) zerolog.Logger {
|
||||
if s, ok := modules[module]; ok {
|
||||
lvl, err := zerolog.ParseLevel(s)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("[log]")
|
||||
return log
|
||||
}
|
||||
|
||||
return log.Level(lvl)
|
||||
}
|
||||
|
||||
return log
|
||||
}
|
||||
|
||||
// internal
|
||||
|
||||
// data - config content
|
||||
var data []byte
|
||||
|
||||
// log - main logger
|
||||
var log zerolog.Logger
|
||||
|
||||
// modules log levels
|
||||
var modules map[string]string
|
@@ -1,27 +0,0 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
api.HandleFunc("/api/stack", stackHandler)
|
||||
api.HandleFunc("/api/exit", exitHandler)
|
||||
|
||||
streams.HandleFunc("null", nullHandler)
|
||||
}
|
||||
|
||||
func exitHandler(_ http.ResponseWriter, r *http.Request) {
|
||||
s := r.URL.Query().Get("code")
|
||||
code, _ := strconv.Atoi(s)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func nullHandler(string) (streamer.Producer, error) {
|
||||
return nil, nil
|
||||
}
|
121
cmd/exec/exec.go
121
cmd/exec/exec.go
@@ -1,121 +0,0 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
pkg "github.com/AlexxIT/go2rtc/pkg/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/rs/zerolog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
// depends on RTSP server
|
||||
if rtsp.Port == "" {
|
||||
return
|
||||
}
|
||||
|
||||
rtsp.OnProducer = func(prod streamer.Producer) bool {
|
||||
if conn := prod.(*pkg.Conn); conn != nil {
|
||||
if waiter := waiters[conn.URL.Path]; waiter != nil {
|
||||
waiter <- prod
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
streams.HandleFunc("exec", Handle)
|
||||
|
||||
log = app.GetLogger("exec")
|
||||
|
||||
// TODO: add sync.Mutex
|
||||
waiters = map[string]chan streamer.Producer{}
|
||||
}
|
||||
|
||||
func Handle(url string) (streamer.Producer, error) {
|
||||
sum := md5.Sum([]byte(url))
|
||||
path := "/" + hex.EncodeToString(sum[:])
|
||||
|
||||
url = strings.Replace(
|
||||
url, "{output}", "rtsp://localhost:"+rtsp.Port+path, 1,
|
||||
)
|
||||
|
||||
// remove `exec:`
|
||||
args := QuoteSplit(url[5:])
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
|
||||
if log.Trace().Enabled() {
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
}
|
||||
|
||||
ch := make(chan streamer.Producer)
|
||||
|
||||
waiters[path] = ch
|
||||
defer delete(waiters, path)
|
||||
|
||||
log.Debug().Str("url", url).Msg("[exec] run")
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Error().Err(err).Str("url", url).Msg("[exec]")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
select {
|
||||
case <-time.After(time.Second * 15):
|
||||
_ = cmd.Process.Kill()
|
||||
log.Error().Str("url", url).Msg("[exec] timeout")
|
||||
return nil, errors.New("timeout")
|
||||
case prod := <-ch:
|
||||
return prod, nil
|
||||
}
|
||||
}
|
||||
|
||||
// internal
|
||||
|
||||
var log zerolog.Logger
|
||||
var waiters map[string]chan streamer.Producer
|
||||
|
||||
func QuoteSplit(s string) []string {
|
||||
var a []string
|
||||
|
||||
for len(s) > 0 {
|
||||
is := strings.IndexByte(s, ' ')
|
||||
if is >= 0 {
|
||||
// skip prefix and double spaces
|
||||
if is == 0 {
|
||||
// goto next symbol
|
||||
s = s[1:]
|
||||
continue
|
||||
}
|
||||
|
||||
// check if quote in word
|
||||
if i := strings.IndexByte(s[:is], '"'); i >= 0 {
|
||||
// search quote end
|
||||
if is = strings.Index(s, `" `); is > 0 {
|
||||
is += 1
|
||||
} else {
|
||||
is = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if is >= 0 {
|
||||
a = append(a, strings.ReplaceAll(s[:is], `"`, ""))
|
||||
s = s[is+1:]
|
||||
} else {
|
||||
//add last word
|
||||
a = append(a, s)
|
||||
break
|
||||
}
|
||||
}
|
||||
return a
|
||||
}
|
@@ -1,63 +0,0 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// https://trac.ffmpeg.org/wiki/Capture/Webcam
|
||||
const deviceInputPrefix = "-f avfoundation"
|
||||
|
||||
func deviceInputSuffix(videoIdx, audioIdx int) string {
|
||||
video := findMedia(streamer.KindVideo, videoIdx)
|
||||
audio := findMedia(streamer.KindAudio, audioIdx)
|
||||
switch {
|
||||
case video != nil && audio != nil:
|
||||
return `"` + video.Title + `:` + audio.Title + `"`
|
||||
case video != nil:
|
||||
return `"` + video.Title + `"`
|
||||
case audio != nil:
|
||||
return `"` + audio.Title + `"`
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func loadMedias() {
|
||||
cmd := exec.Command(
|
||||
Bin, "-hide_banner", "-list_devices", "true", "-f", "avfoundation", "-i", "dummy",
|
||||
)
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.Stderr = &buf
|
||||
_ = cmd.Run()
|
||||
|
||||
var kind string
|
||||
|
||||
lines := strings.Split(buf.String(), "\n")
|
||||
process:
|
||||
for _, line := range lines {
|
||||
switch {
|
||||
case strings.HasSuffix(line, "video devices:"):
|
||||
kind = streamer.KindVideo
|
||||
continue
|
||||
case strings.HasSuffix(line, "audio devices:"):
|
||||
kind = streamer.KindAudio
|
||||
continue
|
||||
case strings.HasPrefix(line, "dummy"):
|
||||
break process
|
||||
}
|
||||
|
||||
// [AVFoundation indev @ 0x7fad54604380] [0] FaceTime HD Camera
|
||||
name := line[42:]
|
||||
media := loadMedia(kind, name)
|
||||
medias = append(medias, media)
|
||||
}
|
||||
}
|
||||
|
||||
func loadMedia(kind, name string) *streamer.Media {
|
||||
return &streamer.Media{
|
||||
Kind: kind, Title: name,
|
||||
}
|
||||
}
|
@@ -1,50 +0,0 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"io/ioutil"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// https://trac.ffmpeg.org/wiki/Capture/Webcam
|
||||
const deviceInputPrefix = "-f v4l2"
|
||||
|
||||
func deviceInputSuffix(videoIdx, audioIdx int) string {
|
||||
video := findMedia(streamer.KindVideo, videoIdx)
|
||||
return video.Title
|
||||
}
|
||||
|
||||
func loadMedias() {
|
||||
files, err := ioutil.ReadDir("/dev")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, file := range files {
|
||||
log.Trace().Msg("[ffmpeg] " + file.Name())
|
||||
if strings.HasPrefix(file.Name(), streamer.KindVideo) {
|
||||
media := loadMedia(streamer.KindVideo, "/dev/"+file.Name())
|
||||
if media != nil {
|
||||
medias = append(medias, media)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadMedia(kind, name string) *streamer.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 &streamer.Media{
|
||||
Kind: kind, Title: name,
|
||||
}
|
||||
}
|
@@ -1,59 +0,0 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// https://trac.ffmpeg.org/wiki/DirectShow
|
||||
const deviceInputPrefix = "-f dshow"
|
||||
|
||||
func deviceInputSuffix(videoIdx, audioIdx int) string {
|
||||
video := findMedia(streamer.KindVideo, videoIdx)
|
||||
audio := findMedia(streamer.KindAudio, audioIdx)
|
||||
switch {
|
||||
case video != nil && audio != nil:
|
||||
return `video="` + video.Title + `":audio=` + audio.Title + `"`
|
||||
case video != nil:
|
||||
return `video="` + video.Title + `"`
|
||||
case audio != nil:
|
||||
return `audio="` + audio.Title + `"`
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func loadMedias() {
|
||||
cmd := exec.Command(
|
||||
Bin, "-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "",
|
||||
)
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.Stderr = &buf
|
||||
_ = cmd.Run()
|
||||
|
||||
lines := strings.Split(buf.String(), "\r\n")
|
||||
for _, line := range lines {
|
||||
var kind string
|
||||
if strings.HasSuffix(line, "(video)") {
|
||||
kind = streamer.KindVideo
|
||||
} else if strings.HasSuffix(line, "(audio)") {
|
||||
kind = streamer.KindAudio
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
// hope we have constant prefix and suffix sizes
|
||||
// [dshow @ 00000181e8d028c0] "VMware Virtual USB Video Device" (video)
|
||||
name := line[28 : len(line)-9]
|
||||
media := loadMedia(kind, name)
|
||||
medias = append(medias, media)
|
||||
}
|
||||
}
|
||||
|
||||
func loadMedia(kind, name string) *streamer.Media {
|
||||
return &streamer.Media{
|
||||
Kind: kind, Title: name,
|
||||
}
|
||||
}
|
@@ -1,83 +0,0 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"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 []*streamer.Media
|
||||
|
||||
func findMedia(kind string, index int) *streamer.Media {
|
||||
for _, media := range medias {
|
||||
if media.Kind != kind {
|
||||
continue
|
||||
}
|
||||
if index == 0 {
|
||||
return media
|
||||
}
|
||||
index--
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handle(w http.ResponseWriter, r *http.Request) {
|
||||
if medias == nil {
|
||||
loadMedias()
|
||||
}
|
||||
|
||||
data, err := json.Marshal(medias)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.ffmpeg]")
|
||||
return
|
||||
}
|
||||
if _, err = w.Write(data); err != nil {
|
||||
log.Error().Err(err).Msg("[api.ffmpeg]")
|
||||
}
|
||||
}
|
@@ -1,141 +0,0 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/exec"
|
||||
"github.com/AlexxIT/go2rtc/cmd/ffmpeg/device"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var cfg struct {
|
||||
Mod map[string]string `yaml:"ffmpeg"`
|
||||
}
|
||||
|
||||
// defaults
|
||||
|
||||
cfg.Mod = map[string]string{
|
||||
"bin": "ffmpeg",
|
||||
|
||||
// inputs
|
||||
"file": "-re -stream_loop -1 -i {input}",
|
||||
"http": "-fflags nobuffer -flags low_delay -i {input}",
|
||||
"rtsp": "-fflags nobuffer -flags low_delay -rtsp_transport tcp -i {input}",
|
||||
|
||||
// output
|
||||
"out": "-rtsp_transport tcp -f rtsp {output}",
|
||||
|
||||
// `-g 30` - group of picture, GOP, keyframe interval
|
||||
// `-preset superfast` - we can't use ultrafast because it doesn't support `-profile main -level 4.1`
|
||||
// `-tune zerolatency` - for minimal latency
|
||||
// `-profile main -level 4.1` - most used streaming profile
|
||||
// `-pix_fmt yuv420p` - if input pix format 4:2:2
|
||||
"h264": "-codec:v libx264 -g 30 -preset superfast -tune zerolatency -profile main -level 4.1 -pix_fmt yuv420p",
|
||||
"h264/ultra": "-codec:v libx264 -g 30 -preset ultrafast -tune zerolatency",
|
||||
"h264/high": "-codec:v libx264 -g 30 -preset superfast -tune zerolatency",
|
||||
"h265": "-codec:v libx265 -g 30 -preset ultrafast -tune zerolatency",
|
||||
"opus": "-codec:a libopus -ar 48000 -ac 2",
|
||||
"pcmu": "-codec:a pcm_mulaw -ar 8000 -ac 1",
|
||||
"pcmu/16000": "-codec:a pcm_mulaw -ar 16000 -ac 1",
|
||||
"pcmu/48000": "-codec:a pcm_mulaw -ar 48000 -ac 1",
|
||||
"pcma": "-codec:a pcm_alaw -ar 8000 -ac 1",
|
||||
"pcma/16000": "-codec:a pcm_alaw -ar 16000 -ac 1",
|
||||
"pcma/48000": "-codec:a pcm_alaw -ar 48000 -ac 1",
|
||||
"aac/16000": "-codec:a aac -ar 16000 -ac 1",
|
||||
}
|
||||
|
||||
app.LoadConfig(&cfg)
|
||||
|
||||
tpl := cfg.Mod
|
||||
|
||||
streams.HandleFunc("ffmpeg", func(s string) (streamer.Producer, error) {
|
||||
s = s[7:] // remove `ffmpeg:`
|
||||
|
||||
var query url.Values
|
||||
if i := strings.IndexByte(s, '#'); i > 0 {
|
||||
query = parseQuery(s[i+1:])
|
||||
s = s[:i]
|
||||
}
|
||||
|
||||
var input string
|
||||
if i := strings.IndexByte(s, ':'); i > 0 {
|
||||
switch s[:i] {
|
||||
case "http", "https":
|
||||
input = strings.Replace(tpl["http"], "{input}", s, 1)
|
||||
case "rtsp", "rtsps":
|
||||
input = strings.Replace(tpl["rtsp"], "{input}", s, 1)
|
||||
}
|
||||
}
|
||||
|
||||
if input == "" {
|
||||
if strings.HasPrefix(s, "device?") {
|
||||
var err error
|
||||
input, err = device.GetInput(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
input = strings.Replace(tpl["file"], "{input}", s, 1)
|
||||
}
|
||||
}
|
||||
|
||||
s = "exec:" + tpl["bin"] + " -hide_banner " + input
|
||||
|
||||
if query != nil {
|
||||
for _, raw := range query["raw"] {
|
||||
s += " " + raw
|
||||
}
|
||||
|
||||
// TODO: multiple codecs via -map
|
||||
// s += fmt.Sprintf(" -map 0:v:0 -c:v:%d copy", i)
|
||||
|
||||
for _, video := range query["video"] {
|
||||
if video == "copy" {
|
||||
s += " -codec:v copy"
|
||||
} else {
|
||||
s += " " + tpl[video]
|
||||
}
|
||||
}
|
||||
|
||||
for _, audio := range query["audio"] {
|
||||
if audio == "copy" {
|
||||
s += " -codec:v copy"
|
||||
} else {
|
||||
s += " " + tpl[audio]
|
||||
}
|
||||
}
|
||||
|
||||
if query["video"] == nil {
|
||||
s += " -vn"
|
||||
}
|
||||
if query["audio"] == nil {
|
||||
s += " -an"
|
||||
}
|
||||
} else {
|
||||
s += " -c copy"
|
||||
}
|
||||
|
||||
s += " " + tpl["out"]
|
||||
|
||||
return exec.Handle(s)
|
||||
})
|
||||
|
||||
device.Bin = cfg.Mod["bin"]
|
||||
device.Init()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
23
cmd/go2rtc_rtsp/main.go
Normal file
23
cmd/go2rtc_rtsp/main.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app.Init()
|
||||
streams.Init()
|
||||
|
||||
rtsp.Init()
|
||||
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigs
|
||||
|
||||
println("exit OK")
|
||||
}
|
167
cmd/hass/hass.go
167
cmd/hass/hass.go
@@ -1,167 +0,0 @@
|
||||
package hass
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/cmd/webrtc"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/rs/zerolog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var conf struct {
|
||||
Mod struct {
|
||||
Config string `yaml:"config"`
|
||||
} `yaml:"hass"`
|
||||
}
|
||||
|
||||
app.LoadConfig(&conf)
|
||||
|
||||
log = app.GetLogger("hass")
|
||||
|
||||
// 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("/stream", handler)
|
||||
|
||||
// support load cameras from Hass config file
|
||||
filename := path.Join(conf.Mod.Config, ".storage/core.config_entries")
|
||||
data, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
storage := new(entries)
|
||||
if err = json.Unmarshal(data, storage); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
urls := map[string]string{}
|
||||
|
||||
streams.HandleFunc("hass", func(url string) (streamer.Producer, error) {
|
||||
if hurl := urls[url[5:]]; hurl != "" {
|
||||
return streams.GetProducer(hurl)
|
||||
}
|
||||
return nil, fmt.Errorf("can't get url: %s", url)
|
||||
})
|
||||
|
||||
for _, entrie := range storage.Data.Entries {
|
||||
switch entrie.Domain {
|
||||
case "generic":
|
||||
if entrie.Options.StreamSource == "" {
|
||||
continue
|
||||
}
|
||||
urls[entrie.Title] = entrie.Options.StreamSource
|
||||
|
||||
case "homekit_controller":
|
||||
if entrie.Data.ClientID == "" {
|
||||
continue
|
||||
}
|
||||
urls[entrie.Title] = fmt.Sprintf(
|
||||
"homekit://%s:%d?client_id=%s&client_private=%s%s&device_id=%s&device_public=%s",
|
||||
entrie.Data.DeviceHost, entrie.Data.DevicePort,
|
||||
entrie.Data.ClientID, entrie.Data.ClientPrivate, entrie.Data.ClientPublic,
|
||||
entrie.Data.DeviceID, entrie.Data.DevicePublic,
|
||||
)
|
||||
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
log.Info().Str("url", "hass:" + entrie.Title).Msg("[hass] load stream")
|
||||
//streams.Get("hass:" + entrie.Title)
|
||||
}
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
|
||||
func handler(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] parse form")
|
||||
return
|
||||
}
|
||||
|
||||
src := r.FormValue("url")
|
||||
src, err := url.QueryUnescape(src)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] query unescape")
|
||||
return
|
||||
}
|
||||
|
||||
str := r.FormValue("sdp64")
|
||||
|
||||
offer, err := base64.StdEncoding.DecodeString(str)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] sdp64 decode")
|
||||
return
|
||||
}
|
||||
|
||||
// check if stream links to our rtsp server
|
||||
if strings.HasPrefix(src, "rtsp://") {
|
||||
i := strings.IndexByte(src[7:], '/')
|
||||
if i > 0 && streams.Has(src[8+i:]) {
|
||||
src = src[8+i:]
|
||||
}
|
||||
}
|
||||
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
log.Error().Str("url", src).Msg("[api.hass] unsupported source")
|
||||
return
|
||||
}
|
||||
|
||||
str, err = webrtc.ExchangeSDP(stream, string(offer), r.UserAgent())
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] exchange SDP")
|
||||
return
|
||||
}
|
||||
|
||||
resp := struct {
|
||||
Answer string `json:"sdp64"`
|
||||
}{
|
||||
Answer: base64.StdEncoding.EncodeToString([]byte(str)),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] marshal JSON")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if _, err = w.Write(data); err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] write")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type entries struct {
|
||||
Data struct {
|
||||
Entries []struct {
|
||||
Title string `json:"title"`
|
||||
Domain string `json:"domain"`
|
||||
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"`
|
||||
} `json:"data"`
|
||||
Options struct {
|
||||
StreamSource string `json:"stream_source"`
|
||||
}
|
||||
} `json:"entries"`
|
||||
} `json:"data"`
|
||||
}
|
@@ -1,157 +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/homekit"
|
||||
"github.com/AlexxIT/go2rtc/pkg/homekit/mdns"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func apiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
items := make([]interface{}, 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)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.homekit]")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = w.Write(data); err != nil {
|
||||
log.Error().Err(err).Msg("[api.homekit]")
|
||||
}
|
||||
|
||||
case "POST":
|
||||
// TODO: post params...
|
||||
|
||||
id := r.URL.Query().Get("id")
|
||||
pin := r.URL.Query().Get("pin")
|
||||
|
||||
client, err := homekit.Pair(id, pin)
|
||||
if err != nil {
|
||||
// log error
|
||||
log.Error().Err(err).Msg("[api.homekit] pair")
|
||||
// response error
|
||||
_, err = w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
name := r.URL.Query().Get("name")
|
||||
dict := store.GetDict("streams")
|
||||
dict[name] = client.URL()
|
||||
if err = store.Set("streams", dict); err != nil {
|
||||
// log error
|
||||
log.Error().Err(err).Msg("[api.homekit] save to store")
|
||||
// response error
|
||||
_, err = w.Write([]byte(err.Error()))
|
||||
}
|
||||
|
||||
streams.New(name, client.URL())
|
||||
|
||||
case "DELETE":
|
||||
src := r.URL.Query().Get("src")
|
||||
dict := store.GetDict("streams")
|
||||
for name, rawURL := range dict {
|
||||
if name != src {
|
||||
continue
|
||||
}
|
||||
|
||||
client, err := homekit.NewClient(rawURL.(string))
|
||||
if err != nil {
|
||||
// log error
|
||||
log.Error().Err(err).Msg("[api.homekit] new client")
|
||||
// response error
|
||||
_, err = w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if err = client.Dial(); err != nil {
|
||||
// log error
|
||||
log.Error().Err(err).Msg("[api.homekit] client dial")
|
||||
// response error
|
||||
_, err = w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
go client.Handle()
|
||||
|
||||
if err = client.ListPairings(); err != nil {
|
||||
// log error
|
||||
log.Error().Err(err).Msg("[api.homekit] unpair")
|
||||
// response error
|
||||
_, err = w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if err = client.DeletePairing(client.ClientID); err != nil {
|
||||
// log error
|
||||
log.Error().Err(err).Msg("[api.homekit] unpair")
|
||||
// response error
|
||||
_, err = w.Write([]byte(err.Error()))
|
||||
}
|
||||
|
||||
delete(dict, name)
|
||||
|
||||
if err = store.Set("streams", dict); err != nil {
|
||||
// log error
|
||||
log.Error().Err(err).Msg("[api.homekit] store set")
|
||||
// response error
|
||||
_, err = w.Write([]byte(err.Error()))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,39 +0,0 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/homekit"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"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) (streamer.Producer, error) {
|
||||
client, err := homekit.NewClient(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = client.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// start gorutine for reading responses from camera
|
||||
go func() {
|
||||
if err = client.Handle(); err != nil {
|
||||
log.Warn().Err(err).Msg("[homekit] client")
|
||||
}
|
||||
}()
|
||||
|
||||
return &Producer{client: client}, nil
|
||||
}
|
@@ -1,189 +0,0 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/cmd/srtp"
|
||||
"github.com/AlexxIT/go2rtc/pkg/homekit"
|
||||
"github.com/AlexxIT/go2rtc/pkg/homekit/camera"
|
||||
pkg "github.com/AlexxIT/go2rtc/pkg/srtp"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/brutella/hap/characteristic"
|
||||
"github.com/brutella/hap/rtp"
|
||||
"net"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Producer struct {
|
||||
streamer.Element
|
||||
|
||||
client *homekit.Client
|
||||
medias []*streamer.Media
|
||||
tracks []*streamer.Track
|
||||
|
||||
sessions []*pkg.Session
|
||||
}
|
||||
|
||||
func (c *Producer) GetMedias() []*streamer.Media {
|
||||
if c.medias == nil {
|
||||
c.medias = c.getMedias()
|
||||
}
|
||||
|
||||
return c.medias
|
||||
}
|
||||
|
||||
func (c *Producer) GetTrack(media *streamer.Media, codec *streamer.Codec) *streamer.Track {
|
||||
for _, track := range c.tracks {
|
||||
if track.Codec == codec {
|
||||
return track
|
||||
}
|
||||
}
|
||||
|
||||
track := &streamer.Track{Codec: codec, Direction: media.Direction}
|
||||
c.tracks = append(c.tracks, track)
|
||||
return track
|
||||
}
|
||||
|
||||
func (c *Producer) Start() error {
|
||||
if c.tracks == nil {
|
||||
return errors.New("producer without tracks")
|
||||
}
|
||||
|
||||
// get our server local IP-address
|
||||
host, _, err := net.SplitHostPort(c.client.LocalAddr())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get our server SRTP port
|
||||
port, err := strconv.Atoi(srtp.Port)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// setup HomeKit stream session
|
||||
hkSession := camera.NewSession()
|
||||
hkSession.SetLocalEndpoint(host, uint16(port))
|
||||
|
||||
// create client for processing camera accessory
|
||||
cam := camera.NewClient(c.client)
|
||||
// try to start HomeKit stream
|
||||
if err = cam.StartStream2(hkSession); err != nil {
|
||||
panic(err) // TODO: fixme
|
||||
}
|
||||
|
||||
// SRTP Video Session
|
||||
vs := &pkg.Session{
|
||||
LocalSSRC: hkSession.Config.Video.RTP.Ssrc,
|
||||
RemoteSSRC: hkSession.Answer.SsrcVideo,
|
||||
Track: c.tracks[0],
|
||||
}
|
||||
if err = vs.SetKeys(
|
||||
hkSession.Offer.Video.MasterKey, hkSession.Offer.Video.MasterSalt,
|
||||
hkSession.Answer.Video.MasterKey, hkSession.Answer.Video.MasterSalt,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// SRTP Audio Session
|
||||
as := &pkg.Session{
|
||||
LocalSSRC: hkSession.Config.Audio.RTP.Ssrc,
|
||||
RemoteSSRC: hkSession.Answer.SsrcAudio,
|
||||
Track: &streamer.Track{},
|
||||
}
|
||||
if err = as.SetKeys(
|
||||
hkSession.Offer.Audio.MasterKey, hkSession.Offer.Audio.MasterSalt,
|
||||
hkSession.Answer.Audio.MasterKey, hkSession.Answer.Audio.MasterSalt,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srtp.AddSession(vs)
|
||||
srtp.AddSession(as)
|
||||
|
||||
c.sessions = []*pkg.Session{vs, as}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Producer) Stop() error {
|
||||
err := c.client.Close()
|
||||
|
||||
for _, session := range c.sessions {
|
||||
srtp.RemoveSession(session)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Producer) getMedias() []*streamer.Media {
|
||||
var medias []*streamer.Media
|
||||
|
||||
accs, err := c.client.GetAccessories()
|
||||
acc := accs[0]
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// get supported video config (not really necessary)
|
||||
char := acc.GetCharacter(characteristic.TypeSupportedVideoStreamConfiguration)
|
||||
v1 := &rtp.VideoStreamConfiguration{}
|
||||
if err = char.ReadTLV8(v1); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
for _, hkCodec := range v1.Codecs {
|
||||
codec := &streamer.Codec{ClockRate: 90000}
|
||||
|
||||
switch hkCodec.Type {
|
||||
case rtp.VideoCodecType_H264:
|
||||
codec.Name = streamer.CodecH264
|
||||
default:
|
||||
panic(fmt.Sprintf("unknown codec: %d", hkCodec.Type))
|
||||
}
|
||||
|
||||
media := &streamer.Media{
|
||||
Kind: streamer.KindVideo, Direction: streamer.DirectionSendonly,
|
||||
Codecs: []*streamer.Codec{codec},
|
||||
}
|
||||
medias = append(medias, media)
|
||||
}
|
||||
|
||||
char = acc.GetCharacter(characteristic.TypeSupportedAudioStreamConfiguration)
|
||||
v2 := &rtp.AudioStreamConfiguration{}
|
||||
if err = char.ReadTLV8(v2); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
for _, hkCodec := range v2.Codecs {
|
||||
codec := &streamer.Codec{
|
||||
Channels: uint16(hkCodec.Parameters.Channels),
|
||||
}
|
||||
|
||||
switch hkCodec.Type {
|
||||
case rtp.AudioCodecType_AAC_ELD:
|
||||
codec.Name = streamer.CodecAAC
|
||||
default:
|
||||
panic(fmt.Sprintf("unknown codec: %d", hkCodec.Type))
|
||||
}
|
||||
|
||||
switch hkCodec.Parameters.Samplerate {
|
||||
case rtp.AudioCodecSampleRate8Khz:
|
||||
codec.ClockRate = 8000
|
||||
case rtp.AudioCodecSampleRate16Khz:
|
||||
codec.ClockRate = 16000
|
||||
case rtp.AudioCodecSampleRate24Khz:
|
||||
codec.ClockRate = 24000
|
||||
default:
|
||||
panic(fmt.Sprintf("unknown clockrate: %d", hkCodec.Parameters.Samplerate))
|
||||
}
|
||||
|
||||
media := &streamer.Media{
|
||||
Kind: streamer.KindAudio, Direction: streamer.DirectionSendonly,
|
||||
Codecs: []*streamer.Codec{codec},
|
||||
}
|
||||
medias = append(medias, media)
|
||||
}
|
||||
|
||||
return medias
|
||||
}
|
@@ -1,42 +0,0 @@
|
||||
package mse
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mse"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
api.HandleWS("mse", handler)
|
||||
}
|
||||
|
||||
func handler(ctx *api.Context, msg *streamer.Message) {
|
||||
src := ctx.Request.URL.Query().Get("src")
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
|
||||
cons := new(mse.Consumer)
|
||||
cons.UserAgent = ctx.Request.UserAgent()
|
||||
cons.RemoteAddr = ctx.Request.RemoteAddr
|
||||
cons.Listen(func(msg interface{}) {
|
||||
switch msg.(type) {
|
||||
case *streamer.Message, []byte:
|
||||
ctx.Write(msg)
|
||||
}
|
||||
})
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Warn().Err(err).Msg("[api.mse] Add consumer")
|
||||
ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.OnClose(func() {
|
||||
stream.RemoveConsumer(cons)
|
||||
})
|
||||
|
||||
cons.Init()
|
||||
}
|
@@ -1,19 +0,0 @@
|
||||
package rtmp
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/rtmp"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("rtmp", handle)
|
||||
}
|
||||
|
||||
func handle(url string) (streamer.Producer, error) {
|
||||
conn := rtmp.NewClient(url)
|
||||
if err := conn.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
211
cmd/rtsp/rtsp.go
211
cmd/rtsp/rtsp.go
@@ -1,211 +0,0 @@
|
||||
package rtsp
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/rs/zerolog"
|
||||
"net"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var conf struct {
|
||||
Mod struct {
|
||||
Listen string `yaml:"listen"`
|
||||
} `yaml:"rtsp"`
|
||||
}
|
||||
|
||||
// default config
|
||||
conf.Mod.Listen = ":8554"
|
||||
|
||||
app.LoadConfig(&conf)
|
||||
|
||||
log = app.GetLogger("rtsp")
|
||||
|
||||
// RTSP client support
|
||||
streams.HandleFunc("rtsp", rtspHandler)
|
||||
streams.HandleFunc("rtsps", rtspHandler)
|
||||
streams.HandleFunc("rtspx", rtspHandler)
|
||||
|
||||
// RTSP server support
|
||||
address := conf.Mod.Listen
|
||||
if address != "" {
|
||||
_, Port, _ = net.SplitHostPort(address)
|
||||
|
||||
go worker(address)
|
||||
}
|
||||
}
|
||||
|
||||
var Port string
|
||||
|
||||
var OnProducer func(conn streamer.Producer) bool // TODO: maybe rewrite...
|
||||
|
||||
// internal
|
||||
|
||||
var log zerolog.Logger
|
||||
|
||||
func rtspHandler(url string) (streamer.Producer, error) {
|
||||
conn, err := rtsp.NewClient(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if log.Trace().Enabled() {
|
||||
conn.Listen(func(msg interface{}) {
|
||||
switch msg := msg.(type) {
|
||||
case *tcp.Request:
|
||||
log.Trace().Msgf("[rtsp] client request:\n%s", msg)
|
||||
case *tcp.Response:
|
||||
log.Trace().Msgf("[rtsp] client response:\n%s", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if err = conn.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn.Backchannel = true
|
||||
if err = conn.Describe(); err != nil {
|
||||
// second try without backchannel, we need to reconnect
|
||||
if err = conn.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn.Backchannel = false
|
||||
if err = conn.Describe(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func worker(address string) {
|
||||
srv, err := tcp.NewServer(address)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[rtsp] listen")
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().Str("addr", address).Msg("[rtsp] listen")
|
||||
|
||||
srv.Listen(func(msg interface{}) {
|
||||
switch msg.(type) {
|
||||
case net.Conn:
|
||||
var name string
|
||||
var onDisconnect func()
|
||||
|
||||
trace := log.Trace().Enabled()
|
||||
|
||||
conn := rtsp.NewServer(msg.(net.Conn))
|
||||
conn.Listen(func(msg interface{}) {
|
||||
if trace {
|
||||
switch msg := msg.(type) {
|
||||
case *tcp.Request:
|
||||
log.Trace().Msgf("[rtsp] server request:\n%s", msg)
|
||||
case *tcp.Response:
|
||||
log.Trace().Msgf("[rtsp] server response:\n%s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
switch msg {
|
||||
case rtsp.MethodDescribe:
|
||||
name = conn.URL.Path[1:]
|
||||
|
||||
log.Debug().Str("stream", name).Msg("[rtsp] new consumer")
|
||||
|
||||
stream := streams.Get(name) // TODO: rewrite
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
|
||||
initMedias(conn)
|
||||
|
||||
if err = stream.AddConsumer(conn); err != nil {
|
||||
log.Warn().Err(err).Str("stream", name).Msg("[rtsp]")
|
||||
return
|
||||
}
|
||||
|
||||
onDisconnect = func() {
|
||||
stream.RemoveConsumer(conn)
|
||||
}
|
||||
|
||||
case rtsp.MethodAnnounce:
|
||||
if OnProducer != nil {
|
||||
if OnProducer(conn) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
name = conn.URL.Path[1:]
|
||||
|
||||
log.Debug().Str("stream", name).Msg("[rtsp] new producer")
|
||||
|
||||
str := streams.Get(conn.URL.Path[1:])
|
||||
if str == nil {
|
||||
return
|
||||
}
|
||||
|
||||
str.AddProducer(conn)
|
||||
|
||||
onDisconnect = func() {
|
||||
str.RemoveProducer(conn)
|
||||
}
|
||||
|
||||
case streamer.StatePlaying:
|
||||
log.Debug().Str("stream", name).Msg("[rtsp] start")
|
||||
}
|
||||
})
|
||||
|
||||
if err = conn.Accept(); err != nil {
|
||||
log.Warn().Err(err).Msg("[rtsp] accept")
|
||||
return
|
||||
}
|
||||
|
||||
if err = conn.Handle(); err != nil {
|
||||
//log.Warn().Err(err).Msg("[rtsp] handle server")
|
||||
}
|
||||
|
||||
if onDisconnect != nil {
|
||||
onDisconnect()
|
||||
}
|
||||
|
||||
log.Debug().Str("stream", name).Msg("[rtsp] disconnect")
|
||||
}
|
||||
})
|
||||
|
||||
srv.Serve()
|
||||
}
|
||||
|
||||
func initMedias(conn *rtsp.Conn) {
|
||||
// set media candidates from query list
|
||||
for key, value := range conn.URL.Query() {
|
||||
switch key {
|
||||
case streamer.KindVideo, streamer.KindAudio:
|
||||
for _, value := range value {
|
||||
media := &streamer.Media{
|
||||
Kind: key, Direction: streamer.DirectionRecvonly,
|
||||
}
|
||||
|
||||
switch value {
|
||||
case "", "copy": // pass empty codecs list
|
||||
default:
|
||||
codec := streamer.NewCodec(value)
|
||||
media.Codecs = append(media.Codecs, codec)
|
||||
}
|
||||
|
||||
conn.Medias = append(conn.Medias, media)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// set default media candidates if query is empty
|
||||
if conn.Medias == nil {
|
||||
conn.Medias = []*streamer.Media{
|
||||
{Kind: streamer.KindVideo, Direction: streamer.DirectionRecvonly},
|
||||
{Kind: streamer.KindAudio, Direction: streamer.DirectionRecvonly},
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,35 +0,0 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Handler func(url string) (streamer.Producer, error)
|
||||
|
||||
var handlers map[string]Handler
|
||||
|
||||
func HandleFunc(scheme string, handler Handler) {
|
||||
if handlers == nil {
|
||||
handlers = make(map[string]Handler)
|
||||
}
|
||||
handlers[scheme] = handler
|
||||
}
|
||||
|
||||
func HasProducer(url string) bool {
|
||||
i := strings.IndexByte(url, ':')
|
||||
if i <= 0 { // TODO: i < 4 ?
|
||||
return false
|
||||
}
|
||||
return handlers[url[:i]] != nil
|
||||
}
|
||||
|
||||
func GetProducer(url string) (streamer.Producer, error) {
|
||||
i := strings.IndexByte(url, ':')
|
||||
handler := handlers[url[:i]]
|
||||
if handler == nil {
|
||||
return nil, fmt.Errorf("unsupported scheme: %s", url)
|
||||
}
|
||||
return handler(url)
|
||||
}
|
@@ -1,100 +0,0 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type state byte
|
||||
|
||||
const (
|
||||
stateNone state = iota
|
||||
stateMedias
|
||||
stateTracks
|
||||
stateStart
|
||||
)
|
||||
|
||||
type Producer struct {
|
||||
streamer.Element
|
||||
|
||||
url string
|
||||
element streamer.Producer
|
||||
tracks []*streamer.Track
|
||||
|
||||
state state
|
||||
mx sync.Mutex
|
||||
}
|
||||
|
||||
func (p *Producer) GetMedias() []*streamer.Media {
|
||||
p.mx.Lock()
|
||||
defer p.mx.Unlock()
|
||||
|
||||
if p.state == stateNone {
|
||||
log.Debug().Str("url", p.url).Msg("[streams] probe producer")
|
||||
|
||||
var err error
|
||||
p.element, err = GetProducer(p.url)
|
||||
if err != nil || p.element == nil {
|
||||
log.Error().Err(err).Str("url", p.url).Msg("[streams] probe producer")
|
||||
return nil
|
||||
}
|
||||
|
||||
p.state = stateMedias
|
||||
}
|
||||
|
||||
return p.element.GetMedias()
|
||||
}
|
||||
|
||||
func (p *Producer) GetTrack(media *streamer.Media, codec *streamer.Codec) *streamer.Track {
|
||||
p.mx.Lock()
|
||||
defer p.mx.Unlock()
|
||||
|
||||
if p.state == stateMedias {
|
||||
p.state = stateTracks
|
||||
}
|
||||
|
||||
track := p.element.GetTrack(media, codec)
|
||||
|
||||
for _, t := range p.tracks {
|
||||
if track == t {
|
||||
return track
|
||||
}
|
||||
}
|
||||
|
||||
p.tracks = append(p.tracks, track)
|
||||
|
||||
return track
|
||||
}
|
||||
|
||||
// internals
|
||||
|
||||
func (p *Producer) start() {
|
||||
p.mx.Lock()
|
||||
defer p.mx.Unlock()
|
||||
|
||||
if p.state != stateTracks {
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().Str("url", p.url).Msg("[streams] start producer")
|
||||
|
||||
p.state = stateStart
|
||||
go p.element.Start()
|
||||
}
|
||||
|
||||
func (p *Producer) stop() {
|
||||
p.mx.Lock()
|
||||
|
||||
log.Debug().Str("url", p.url).Msg("[streams] stop producer")
|
||||
|
||||
if p.element != nil {
|
||||
_ = p.element.Stop()
|
||||
p.element = nil
|
||||
} else {
|
||||
log.Warn().Str("url", p.url).Msg("[streams] stop empty producer")
|
||||
}
|
||||
p.tracks = nil
|
||||
p.state = stateNone
|
||||
|
||||
p.mx.Unlock()
|
||||
}
|
@@ -1,190 +0,0 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
)
|
||||
|
||||
type Consumer struct {
|
||||
element streamer.Consumer
|
||||
tracks []*streamer.Track
|
||||
}
|
||||
|
||||
type Stream struct {
|
||||
producers []*Producer
|
||||
consumers []*Consumer
|
||||
}
|
||||
|
||||
func NewStream(source interface{}) *Stream {
|
||||
s := new(Stream)
|
||||
|
||||
switch source := source.(type) {
|
||||
case string:
|
||||
prod := &Producer{url: source}
|
||||
s.producers = append(s.producers, prod)
|
||||
case []interface{}:
|
||||
for _, source := range source {
|
||||
prod := &Producer{url: source.(string)}
|
||||
s.producers = append(s.producers, prod)
|
||||
}
|
||||
case map[string]interface{}:
|
||||
return NewStream(source["url"])
|
||||
case nil:
|
||||
default:
|
||||
panic("wrong source type")
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Stream) AddConsumer(cons streamer.Consumer) (err error) {
|
||||
ic := len(s.consumers)
|
||||
|
||||
consumer := &Consumer{element: cons}
|
||||
|
||||
// Step 1. Get consumer medias
|
||||
for icc, consMedia := range cons.GetMedias() {
|
||||
log.Trace().Stringer("media", consMedia).
|
||||
Msgf("[streams] consumer:%d:%d candidate", ic, icc)
|
||||
|
||||
producers:
|
||||
for ip, prod := range s.producers {
|
||||
// Step 2. Get producer medias (not tracks yet)
|
||||
for ipc, prodMedia := range prod.GetMedias() {
|
||||
log.Trace().Stringer("media", prodMedia).
|
||||
Msgf("[streams] producer:%d:%d candidate", ip, ipc)
|
||||
|
||||
// Step 3. Match consumer/producer codecs list
|
||||
prodCodec := prodMedia.MatchMedia(consMedia)
|
||||
if prodCodec != nil {
|
||||
log.Trace().Stringer("codec", prodCodec).
|
||||
Msgf("[streams] match producer:%d:%d => consumer:%d:%d", ip, ipc, ic, icc)
|
||||
|
||||
// Step 4. Get producer track
|
||||
prodTrack := prod.GetTrack(prodMedia, prodCodec)
|
||||
if prodTrack == nil {
|
||||
log.Warn().Msg("[stream] can't get track")
|
||||
continue
|
||||
}
|
||||
|
||||
// Step 5. Add track to consumer and get new track
|
||||
consTrack := consumer.element.AddTrack(consMedia, prodTrack)
|
||||
|
||||
consumer.tracks = append(consumer.tracks, consTrack)
|
||||
break producers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// can't match tracks for consumer
|
||||
if len(consumer.tracks) == 0 {
|
||||
return errors.New("couldn't find the matching tracks")
|
||||
}
|
||||
|
||||
s.consumers = append(s.consumers, consumer)
|
||||
|
||||
for _, prod := range s.producers {
|
||||
prod.start()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Stream) RemoveConsumer(cons streamer.Consumer) {
|
||||
for i, consumer := range s.consumers {
|
||||
if consumer.element == cons {
|
||||
// remove consumer pads from all producers
|
||||
for _, track := range consumer.tracks {
|
||||
track.Unbind()
|
||||
}
|
||||
// remove consumer from slice
|
||||
s.removeConsumer(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for _, producer := range s.producers {
|
||||
var sink bool
|
||||
for _, track := range producer.tracks {
|
||||
if len(track.Sink) > 0 {
|
||||
sink = true
|
||||
}
|
||||
}
|
||||
if !sink {
|
||||
producer.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Stream) AddProducer(prod streamer.Producer) {
|
||||
producer := &Producer{element: prod, state: stateTracks}
|
||||
s.producers = append(s.producers, producer)
|
||||
}
|
||||
|
||||
func (s *Stream) RemoveProducer(prod streamer.Producer) {
|
||||
for i, producer := range s.producers {
|
||||
if producer.element == prod {
|
||||
s.removeProducer(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Stream) Active() bool {
|
||||
if len(s.consumers) > 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, prod := range s.producers {
|
||||
if prod.element != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Stream) MarshalJSON() ([]byte, error) {
|
||||
var v []interface{}
|
||||
for _, prod := range s.producers {
|
||||
if prod.element != nil {
|
||||
v = append(v, prod.element)
|
||||
}
|
||||
}
|
||||
for _, cons := range s.consumers {
|
||||
// cons.element always not nil
|
||||
v = append(v, cons.element)
|
||||
}
|
||||
if len(v) == 0 {
|
||||
v = nil
|
||||
}
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
func (s *Stream) removeConsumer(i int) {
|
||||
switch {
|
||||
case len(s.consumers) == 1: // only one element
|
||||
s.consumers = nil
|
||||
case i == 0: // first element
|
||||
s.consumers = s.consumers[1:]
|
||||
case i == len(s.consumers)-1: // last element
|
||||
s.consumers = s.consumers[:i]
|
||||
default: // middle element
|
||||
s.consumers = append(s.consumers[:i], s.consumers[i+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Stream) removeProducer(i int) {
|
||||
switch {
|
||||
case len(s.producers) == 1: // only one element
|
||||
s.producers = nil
|
||||
case i == 0: // first element
|
||||
s.producers = s.producers[1:]
|
||||
case i == len(s.producers)-1: // last element
|
||||
s.producers = s.producers[:i]
|
||||
default: // middle element
|
||||
s.producers = append(s.producers[:i], s.producers[i+1:]...)
|
||||
}
|
||||
}
|
@@ -1,135 +0,0 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/fake"
|
||||
"github.com/AlexxIT/go2rtc/pkg/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Google Chrome 104.0.5112.79
|
||||
const chrome = `v=0
|
||||
o=- 0 0 IN IP4 0.0.0.0
|
||||
s=-
|
||||
t=0 0
|
||||
m=audio 9 UDP/TLS/RTP/SAVPF 111 63 103 104 9 0 8 110 112 113 126
|
||||
a=sendrecv
|
||||
a=rtpmap:111 opus/48000/2
|
||||
a=rtpmap:63 red/48000/2
|
||||
a=rtpmap:103 ISAC/16000
|
||||
a=rtpmap:104 ISAC/32000
|
||||
a=rtpmap:9 G722/8000
|
||||
a=rtpmap:0 PCMU/8000
|
||||
a=rtpmap:8 PCMA/8000
|
||||
a=rtpmap:110 telephone-event/48000
|
||||
a=rtpmap:112 telephone-event/32000
|
||||
a=rtpmap:113 telephone-event/16000
|
||||
a=rtpmap:126 telephone-event/8000
|
||||
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 102 122 127 121 125 107 108 109 124 120 123 119 35 36 37 38 39 40 41 42 114 115 116 117 118 43
|
||||
a=recvonly
|
||||
a=rtpmap:96 VP8/90000
|
||||
a=rtpmap:97 rtx/90000
|
||||
a=rtpmap:98 VP9/90000
|
||||
a=rtpmap:99 rtx/90000
|
||||
a=rtpmap:100 VP9/90000
|
||||
a=rtpmap:101 rtx/90000
|
||||
a=rtpmap:102 VP9/90000
|
||||
a=rtpmap:122 rtx/90000
|
||||
a=rtpmap:127 H264/90000
|
||||
a=fmtp:127 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f
|
||||
a=rtpmap:121 rtx/90000
|
||||
a=rtpmap:125 H264/90000
|
||||
a=fmtp:125 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f
|
||||
a=rtpmap:107 rtx/90000
|
||||
a=rtpmap:108 H264/90000
|
||||
a=fmtp:108 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f
|
||||
a=rtpmap:109 rtx/90000
|
||||
a=rtpmap:124 H264/90000
|
||||
a=fmtp:124 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f
|
||||
a=rtpmap:120 rtx/90000
|
||||
a=rtpmap:123 H264/90000
|
||||
a=fmtp:123 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f
|
||||
a=rtpmap:119 rtx/90000
|
||||
a=rtpmap:35 H264/90000
|
||||
a=fmtp:35 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=4d001f
|
||||
a=rtpmap:36 rtx/90000
|
||||
a=rtpmap:37 H264/90000
|
||||
a=fmtp:37 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=f4001f
|
||||
a=rtpmap:38 rtx/90000
|
||||
a=rtpmap:39 H264/90000
|
||||
a=fmtp:39 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=f4001f
|
||||
a=rtpmap:40 rtx/90000
|
||||
a=rtpmap:41 AV1/90000
|
||||
a=rtpmap:42 rtx/90000
|
||||
a=rtpmap:114 H264/90000
|
||||
a=fmtp:114 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=64001f
|
||||
a=rtpmap:115 rtx/90000
|
||||
a=rtpmap:116 red/90000
|
||||
a=rtpmap:117 rtx/90000
|
||||
a=rtpmap:118 ulpfec/90000
|
||||
a=rtpmap:43 flexfec-03/90000
|
||||
`
|
||||
|
||||
const dahuaSimple = `v=0
|
||||
o=- 0 0 IN IP4 0.0.0.0
|
||||
s=-
|
||||
t=0 0
|
||||
m=video 0 RTP/AVP 96
|
||||
a=control:trackID=0
|
||||
a=rtpmap:96 H264/90000
|
||||
a=fmtp:96 packetization-mode=1;profile-level-id=42401E;sprop-parameter-sets=Z0JAHqaAoD2QAA==,aM48gAA=
|
||||
a=recvonly
|
||||
m=audio 0 RTP/AVP 97
|
||||
a=control:trackID=1
|
||||
a=rtpmap:97 MPEG4-GENERIC/16000
|
||||
a=fmtp:97 streamtype=5;profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=1408
|
||||
a=recvonly
|
||||
m=audio 0 RTP/AVP 8
|
||||
a=control:trackID=5
|
||||
a=rtpmap:8 PCMA/8000
|
||||
a=sendonly
|
||||
`
|
||||
|
||||
const ffmpegPCMU48000 = `v=0
|
||||
o=- 0 0 IN IP4 127.0.0.1
|
||||
s=-
|
||||
t=0 0
|
||||
m=audio 0 RTP/AVP 96
|
||||
b=AS:384
|
||||
a=rtpmap:96 PCMU/48000/1
|
||||
a=control:streamid=0
|
||||
`
|
||||
|
||||
func TestRouting(t *testing.T) {
|
||||
prod := &fake.Producer{}
|
||||
prod.Medias, _ = rtsp.UnmarshalSDP([]byte(dahuaSimple))
|
||||
assert.Len(t, prod.Medias, 3)
|
||||
|
||||
HandleFunc("fake", func(url string) (streamer.Producer, error) {
|
||||
return prod, nil
|
||||
})
|
||||
|
||||
cons := &fake.Consumer{}
|
||||
cons.Medias, _ = streamer.UnmarshalSDP([]byte(chrome))
|
||||
assert.Len(t, cons.Medias, 3)
|
||||
|
||||
// setup stream with one producer
|
||||
stream := NewStream("fake:")
|
||||
|
||||
// main check:
|
||||
err := stream.AddConsumer(cons)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Len(t, prod.Tracks, 2)
|
||||
assert.Len(t, cons.Tracks, 2)
|
||||
|
||||
time.Sleep(time.Second)
|
||||
|
||||
assert.Greater(t, prod.SendPackets,0)
|
||||
assert.Greater(t, cons.RecvPackets,0)
|
||||
|
||||
assert.Greater(t, prod.RecvPackets,0)
|
||||
assert.Greater(t, cons.SendPackets,0)
|
||||
}
|
@@ -1,67 +0,0 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app/store"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var cfg struct {
|
||||
Mod map[string]interface{} `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)
|
||||
}
|
||||
}
|
||||
|
||||
func Get(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")
|
||||
stream := NewStream(src)
|
||||
streams[src] = stream
|
||||
return stream
|
||||
}
|
||||
|
||||
func Has(src string) bool {
|
||||
return streams[src] != nil
|
||||
}
|
||||
|
||||
func New(name string, source interface{}) {
|
||||
streams[name] = NewStream(source)
|
||||
}
|
||||
|
||||
func Delete(name string) {
|
||||
delete(streams, name)
|
||||
}
|
||||
|
||||
func All() map[string]interface{} {
|
||||
all := map[string]interface{}{}
|
||||
for name, stream := range streams {
|
||||
all[name] = stream
|
||||
//if stream.Active() {
|
||||
// all[name] = stream
|
||||
//}
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
var streams = map[string]*Stream{}
|
@@ -1,75 +0,0 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
"github.com/pion/sdp/v3"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var candidates []string
|
||||
|
||||
func AddCandidate(address string) {
|
||||
candidates = append(candidates, address)
|
||||
}
|
||||
|
||||
func addCanditates(answer string) (string, error) {
|
||||
if len(candidates) == 0 {
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
sd := &sdp.SessionDescription{}
|
||||
if err := sd.Unmarshal([]byte(answer)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
md := sd.MediaDescriptions[0]
|
||||
|
||||
_, end := md.Attribute("end-of-candidates")
|
||||
if end {
|
||||
md.Attributes = md.Attributes[:len(md.Attributes)-1]
|
||||
}
|
||||
|
||||
for _, address := range candidates {
|
||||
if strings.HasPrefix(address, "stun:") {
|
||||
ip, err := webrtc.GetPublicIP()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("[webrtc] public IP")
|
||||
continue
|
||||
}
|
||||
address = ip.String() + address[4:]
|
||||
|
||||
log.Debug().Str("addr", address).Msg("[webrtc] stun public address")
|
||||
}
|
||||
|
||||
cand, err := webrtc.NewCandidate(address)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("[webrtc] candidate")
|
||||
continue
|
||||
}
|
||||
|
||||
md.WithPropertyAttribute(cand)
|
||||
}
|
||||
|
||||
if end {
|
||||
md.WithPropertyAttribute("end-of-candidates")
|
||||
}
|
||||
|
||||
data, err := sd.Marshal()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func candidateHandler(ctx *api.Context, msg *streamer.Message) {
|
||||
if ctx.Consumer == nil {
|
||||
return
|
||||
}
|
||||
if conn := ctx.Consumer.(*webrtc.Conn); conn != nil {
|
||||
log.Trace().Str("candidate", msg.Value.(string)).Msg("[webrtc] remote")
|
||||
conn.Push(msg)
|
||||
}
|
||||
}
|
@@ -1,191 +0,0 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/streamer"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
pion "github.com/pion/webrtc/v3"
|
||||
"github.com/rs/zerolog"
|
||||
"net"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var cfg struct {
|
||||
Mod struct {
|
||||
Listen string `yaml:"listen"`
|
||||
Candidates []string `yaml:"candidates"`
|
||||
IceServers []pion.ICEServer `yaml:"ice_servers"`
|
||||
} `yaml:"webrtc"`
|
||||
}
|
||||
|
||||
cfg.Mod.IceServers = []pion.ICEServer{
|
||||
{URLs: []string{"stun:stun.l.google.com:19302"}},
|
||||
}
|
||||
|
||||
app.LoadConfig(&cfg)
|
||||
|
||||
log = app.GetLogger("webrtc")
|
||||
|
||||
address := cfg.Mod.Listen
|
||||
pionAPI, err := webrtc.NewAPI(address)
|
||||
if pionAPI == nil {
|
||||
log.Error().Err(err).Msg("[webrtc] init API")
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("[webrtc] listen")
|
||||
} else if address != "" {
|
||||
log.Info().Str("addr", address).Msg("[webrtc] listen")
|
||||
_, Port, _ = net.SplitHostPort(address)
|
||||
}
|
||||
|
||||
pionConf := pion.Configuration{
|
||||
ICEServers: cfg.Mod.IceServers,
|
||||
SDPSemantics: pion.SDPSemanticsUnifiedPlanWithFallback,
|
||||
}
|
||||
|
||||
NewPConn = func() (*pion.PeerConnection, error) {
|
||||
return pionAPI.NewPeerConnection(pionConf)
|
||||
}
|
||||
|
||||
candidates = cfg.Mod.Candidates
|
||||
|
||||
api.HandleWS(webrtc.MsgTypeOffer, offerHandler)
|
||||
api.HandleWS(webrtc.MsgTypeCandidate, candidateHandler)
|
||||
}
|
||||
|
||||
var Port string
|
||||
var log zerolog.Logger
|
||||
|
||||
var NewPConn func() (*pion.PeerConnection, error)
|
||||
|
||||
func offerHandler(ctx *api.Context, msg *streamer.Message) {
|
||||
src := ctx.Request.URL.Query().Get("src")
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().Str("url", src).Msg("[webrtc] new consumer")
|
||||
|
||||
var err error
|
||||
|
||||
// create new webrtc instance
|
||||
conn := new(webrtc.Conn)
|
||||
conn.Conn, err = NewPConn()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[webrtc] new conn")
|
||||
return
|
||||
}
|
||||
|
||||
conn.UserAgent = ctx.Request.UserAgent()
|
||||
conn.Listen(func(msg interface{}) {
|
||||
switch msg := msg.(type) {
|
||||
case pion.PeerConnectionState:
|
||||
if msg == pion.PeerConnectionStateClosed {
|
||||
stream.RemoveConsumer(conn)
|
||||
}
|
||||
case *streamer.Message:
|
||||
// subscribe on webrtc server candidates
|
||||
log.Trace().Str("candidate", msg.Value.(string)).Msg("[webrtc] local")
|
||||
ctx.Write(msg)
|
||||
}
|
||||
})
|
||||
|
||||
// 1. SetOffer, so we can get remote client codecs
|
||||
offer := msg.Value.(string)
|
||||
log.Trace().Msgf("[webrtc] offer:\n%s", offer)
|
||||
|
||||
if err = conn.SetOffer(offer); err != nil {
|
||||
log.Warn().Err(err).Msg("[api.webrtc] set offer")
|
||||
ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. AddConsumer, so we get new tracks
|
||||
if err = stream.AddConsumer(conn); err != nil {
|
||||
log.Warn().Err(err).Msg("[api.webrtc] add consumer")
|
||||
_ = conn.Conn.Close()
|
||||
ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
conn.Init()
|
||||
|
||||
// exchange sdp without waiting all candidates
|
||||
//answer, err := conn.ExchangeSDP(offer, false)
|
||||
//answer, err := conn.GetAnswer()
|
||||
answer, err := conn.GetCompleteAnswer()
|
||||
if err == nil {
|
||||
answer, err = addCanditates(answer)
|
||||
}
|
||||
log.Trace().Msgf("[webrtc] answer\n%s", answer)
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[webrtc] get answer")
|
||||
ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Write(&streamer.Message{
|
||||
Type: webrtc.MsgTypeAnswer, Value: answer,
|
||||
})
|
||||
|
||||
ctx.Consumer = conn
|
||||
}
|
||||
|
||||
func ExchangeSDP(
|
||||
stream *streams.Stream, offer string, userAgent string,
|
||||
) (answer string, err error) {
|
||||
// create new webrtc instance
|
||||
conn := new(webrtc.Conn)
|
||||
conn.Conn, err = NewPConn()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[webrtc] new conn")
|
||||
return
|
||||
}
|
||||
|
||||
conn.UserAgent = userAgent
|
||||
conn.Listen(func(msg interface{}) {
|
||||
switch msg := msg.(type) {
|
||||
case pion.PeerConnectionState:
|
||||
if msg == pion.PeerConnectionStateClosed {
|
||||
stream.RemoveConsumer(conn)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 1. SetOffer, so we can get remote client codecs
|
||||
log.Trace().Msgf("[webrtc] offer:\n%s", offer)
|
||||
|
||||
if err = conn.SetOffer(offer); err != nil {
|
||||
log.Warn().Err(err).Msg("[api.webrtc] set offer")
|
||||
return
|
||||
}
|
||||
|
||||
// 2. AddConsumer, so we get new tracks
|
||||
if err = stream.AddConsumer(conn); err != nil {
|
||||
log.Warn().Err(err).Msg("[api.webrtc] add consumer")
|
||||
_ = conn.Conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
conn.Init()
|
||||
|
||||
// exchange sdp without waiting all candidates
|
||||
//answer, err := conn.ExchangeSDP(offer, false)
|
||||
answer, err = conn.GetCompleteAnswer()
|
||||
if err == nil {
|
||||
answer, err = addCanditates(answer)
|
||||
}
|
||||
log.Trace().Msgf("[webrtc] answer\n%s", answer)
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[webrtc] get answer")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
@@ -1,58 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/rtsp"
|
||||
"github.com/pion/rtp"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
client, err := rtsp.NewClient(os.Args[1])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err = client.Dial(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err = client.Describe(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
for _, media := range client.GetMedias() {
|
||||
fmt.Printf("Media: %v\n", media)
|
||||
|
||||
if media.AV() {
|
||||
track := client.GetTrack(media, media.Codecs[0])
|
||||
fmt.Printf("Track: %v, %v\n", track, track.Codec)
|
||||
|
||||
track.Bind(func(packet *rtp.Packet) error {
|
||||
nalUnitType := packet.Payload[0] & 0x1F
|
||||
fmt.Printf(
|
||||
"[RTP] codec: %s, nalu: %2d, size: %6d, ts: %10d, pt: %2d, ssrc: %d\n",
|
||||
track.Codec.Name, nalUnitType, len(packet.Payload), packet.Timestamp,
|
||||
packet.PayloadType, packet.SSRC,
|
||||
)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if err = client.Play(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
time.AfterFunc(time.Second*5, func() {
|
||||
if err = client.Close(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
})
|
||||
|
||||
if err = client.Handle(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Println("The End")
|
||||
}
|
66
go.mod
66
go.mod
@@ -1,61 +1,61 @@
|
||||
module github.com/AlexxIT/go2rtc
|
||||
|
||||
go 1.17
|
||||
go 1.20
|
||||
|
||||
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.2.6
|
||||
github.com/pion/interceptor v0.1.11
|
||||
github.com/pion/rtcp v1.2.9
|
||||
github.com/pion/ice/v2 v2.3.1
|
||||
github.com/pion/interceptor v0.1.12
|
||||
github.com/pion/rtcp v1.2.10
|
||||
github.com/pion/rtp v1.7.13
|
||||
github.com/pion/sdp/v3 v3.0.5
|
||||
github.com/pion/srtp/v2 v2.0.10
|
||||
github.com/pion/stun v0.3.5
|
||||
github.com/pion/webrtc/v3 v3.1.43
|
||||
github.com/rs/zerolog v1.27.0
|
||||
github.com/stretchr/testify v1.7.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/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3
|
||||
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f
|
||||
github.com/stretchr/testify v1.8.2
|
||||
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/brutella/dnssd v1.2.3 // indirect
|
||||
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/mattn/go-colorable v0.1.12 // indirect
|
||||
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||
github.com/miekg/dns v1.1.50 // indirect
|
||||
github.com/pion/datachannel v1.5.2 // indirect
|
||||
github.com/pion/dtls/v2 v2.1.5 // 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/pion/datachannel v1.5.5 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.6 // indirect
|
||||
github.com/pion/logging v0.2.2 // indirect
|
||||
github.com/pion/mdns v0.0.5 // indirect
|
||||
github.com/pion/mdns v0.0.7 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/sctp v1.8.2 // indirect
|
||||
github.com/pion/transport v0.13.1 // indirect
|
||||
github.com/pion/turn/v2 v2.0.8 // indirect
|
||||
github.com/pion/udp v0.1.1 // 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/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/xiam/to v0.0.0-20200126224905-d60d31e03561 // indirect
|
||||
golang.org/x/crypto v0.0.0-20220516162934-403b01795ae8 // indirect
|
||||
golang.org/x/mod v0.4.2 // indirect
|
||||
golang.org/x/net v0.0.0-20220630215102-69896b714898 // indirect
|
||||
golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664 // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // 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.20220823033740-ce7d1564e657
|
||||
// MSE update
|
||||
github.com/deepch/vdk v0.0.19 => github.com/AlexxIT/vdk v0.0.18-0.20220616041030-b0d122807b2e
|
||||
// AES_256_CM_HMAC_SHA1_80 support
|
||||
github.com/pion/srtp/v2 v2.0.10 => github.com/AlexxIT/srtp/v2 v2.0.10-0.20220608200505-3191d4f19c10
|
||||
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
|
||||
)
|
||||
|
209
go.sum
209
go.sum
@@ -1,12 +1,10 @@
|
||||
github.com/AlexxIT/hap v0.0.15-0.20220823033740-ce7d1564e657 h1:FUzXAJfm6sRLJ8T6vfzvy/Hm3aioX8+fbxgx2VZoI78=
|
||||
github.com/AlexxIT/hap v0.0.15-0.20220823033740-ce7d1564e657/go.mod h1:c2vEL5pzjRWEx07sa32kTVjzI9bBVlstrwBwKe3DlJ0=
|
||||
github.com/AlexxIT/srtp/v2 v2.0.10-0.20220608200505-3191d4f19c10 h1:4aKRthhmkYcStKuk1hcyvkeNJ/BDx5BTIvYmDO9ZJvg=
|
||||
github.com/AlexxIT/srtp/v2 v2.0.10-0.20220608200505-3191d4f19c10/go.mod h1:5TtM9yw6lsH0ppNCehB/EjEUli7VkUgKSPJqWVqbhQ4=
|
||||
github.com/AlexxIT/vdk v0.0.18-0.20220616041030-b0d122807b2e h1:NAgHHZB+JUN3/J4/yq1q1EAc8xwJ8bb/Qp0AcjkfzAA=
|
||||
github.com/AlexxIT/vdk v0.0.18-0.20220616041030-b0d122807b2e/go.mod h1:KqQ/KU3hOc4a62l/jPRH5Hiz5fhTq5cGCl8IqeCxWQI=
|
||||
github.com/brutella/dnssd v1.2.3 h1:4fBLjZjPH7SbcHhEcIJhZcC9nOhIDZ0m3rn9bjl1/i0=
|
||||
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/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ=
|
||||
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/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=
|
||||
@@ -17,7 +15,6 @@ 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/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
@@ -31,7 +28,6 @@ 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.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
@@ -39,164 +35,143 @@ github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
|
||||
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 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/lucas-clemente/quic-go v0.7.1-0.20190401152353-907071221cf9/go.mod h1:PpMmPfPKO9nKJ/psF49ESTAGQSdfXxlg1otPbEB2nOw=
|
||||
github.com/marten-seemann/qtls v0.2.3/go.mod h1:xzjG7avBwGGbdZ8dTGxlBnLArsVKLvwmjgmPuiQEcYk=
|
||||
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
|
||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
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 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA=
|
||||
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/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=
|
||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
||||
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||
github.com/pion/datachannel v1.4.21/go.mod h1:oiNyP4gHx2DIwRzX/MFyH0Rz/Gz05OgBlayAI2hAWjg=
|
||||
github.com/pion/datachannel v1.5.2 h1:piB93s8LGmbECrpO84DnkIVWasRMk3IimbcXkTQLE6E=
|
||||
github.com/pion/datachannel v1.5.2/go.mod h1:FTGQWaHrdCwIJ1rw6xBIfZVkslikjShim5yr05XFuCQ=
|
||||
github.com/pion/dtls/v2 v2.0.1/go.mod h1:uMQkz2W0cSqY00xav7WByQ4Hb+18xeQh2oH2fRezr5U=
|
||||
github.com/pion/dtls/v2 v2.0.2/go.mod h1:27PEO3MDdaCfo21heT59/vsdmZc0zMt9wQPcSlLu/1I=
|
||||
github.com/pion/dtls/v2 v2.1.3/go.mod h1:o6+WvyLDAlXF7YiPB/RlskRoeK+/JtuaZa5emwQcWus=
|
||||
github.com/pion/dtls/v2 v2.1.5 h1:jlh2vtIyUBShchoTDqpCCqiYCyRFJ/lvf/gQ8TALs+c=
|
||||
github.com/pion/dtls/v2 v2.1.5/go.mod h1:BqCE7xPZbPSubGasRoDFJeTsyJtdD1FanJYL0JGheqY=
|
||||
github.com/pion/ice v0.7.18 h1:KbAWlzWRUdX9SmehBh3gYpIFsirjhSQsCw6K2MjYMK0=
|
||||
github.com/pion/ice v0.7.18/go.mod h1:+Bvnm3nYC6Nnp7VV6glUkuOfToB/AtMRZpOU8ihuf4c=
|
||||
github.com/pion/ice/v2 v2.2.6 h1:R/vaLlI1J2gCx141L5PEwtuGAGcyS6e7E0hDeJFq5Ig=
|
||||
github.com/pion/ice/v2 v2.2.6/go.mod h1:SWuHiOGP17lGromHTFadUe1EuPgFh/oCU6FCMZHooVE=
|
||||
github.com/pion/interceptor v0.1.11 h1:00U6OlqxA3FFB50HSg25J/8cWi7P6FbSzw4eFn24Bvs=
|
||||
github.com/pion/interceptor v0.1.11/go.mod h1:tbtKjZY14awXd7Bq0mmWvgtHB5MDaRN7HV3OZ/uy7s8=
|
||||
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/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.4/go.mod h1:R1sL0p50l42S5lJs91oNdUL58nm0QHrhxnSegr++qC0=
|
||||
github.com/pion/mdns v0.0.5 h1:Q2oj/JB3NqfzY9xGZ1fPzZzK7sDSD8rZPOvcIQ10BCw=
|
||||
github.com/pion/mdns v0.0.5/go.mod h1:UgssrvdD3mxpi8tMxAXbsppL3vJ4Jipw1mTCW+al01g=
|
||||
github.com/pion/quic v0.1.1/go.mod h1:zEU51v7ru8Mp4AUBJvj6psrSth5eEFNnVQK5K48oV3k=
|
||||
github.com/pion/randutil v0.0.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
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/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.3/go.mod h1:zGhIv0RPRF0Z1Wiij22pUt5W/c9fevqSzT4jje/oK7I=
|
||||
github.com/pion/rtcp v1.2.9 h1:1ujStwg++IOLIEoOiIQ2s+qBuJ1VN81KW+9pMPsif+U=
|
||||
github.com/pion/rtcp v1.2.9/go.mod h1:qVPhiCzAm4D/rxb6XzKeyZiQK69yJpbUDJSF7TgrqNo=
|
||||
github.com/pion/rtp v1.6.0/go.mod h1:QgfogHsMBVE/RFNno467U/KBqfUywEH+HK+0rtnwsdI=
|
||||
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/sctp v1.7.10/go.mod h1:EhpTUQu1/lcK3xI+eriS6/96fWetHGCvBi9MSsnaBN0=
|
||||
github.com/pion/sctp v1.8.0/go.mod h1:xFe9cLMZ5Vj6eOzpyiKjT9SwGM4KpK/8Jbw5//jc+0s=
|
||||
github.com/pion/sctp v1.8.2 h1:yBBCIrUMJ4yFICL3RIvR4eh/H2BTTvlligmSTy+3kiA=
|
||||
github.com/pion/sctp v1.8.2/go.mod h1:xFe9cLMZ5Vj6eOzpyiKjT9SwGM4KpK/8Jbw5//jc+0s=
|
||||
github.com/pion/sdp/v2 v2.4.0/go.mod h1:L2LxrOpSTJbAns244vfPChbciR/ReU1KWfG04OpkR7E=
|
||||
github.com/pion/sdp/v3 v3.0.5 h1:ouvI7IgGl+V4CrqskVtr3AaTrPvPisEOxwgpdktctkU=
|
||||
github.com/pion/sdp/v3 v3.0.5/go.mod h1:iiFWFpQO8Fy3S5ldclBkpXqmWy02ns78NOKoLLL0YQw=
|
||||
github.com/pion/srtp v1.5.1 h1:9Q3jAfslYZBt+C69SI/ZcONJh9049JUHZWYRRf5KEKw=
|
||||
github.com/pion/srtp v1.5.1/go.mod h1:B+QgX5xPeQTNc1CJStJPHzOlHK66ViMDWTT0HZTCkcA=
|
||||
github.com/pion/srtp/v2 v2.0.9/go.mod h1:5TtM9yw6lsH0ppNCehB/EjEUli7VkUgKSPJqWVqbhQ4=
|
||||
github.com/pion/stun v0.3.5 h1:uLUCBCkQby4S1cf6CGuR9QrVOKcvUwFeemaC865QHDg=
|
||||
github.com/pion/stun v0.3.5/go.mod h1:gDMim+47EeEtfWogA37n6qXZS88L5V6LqFcf+DZA2UA=
|
||||
github.com/pion/transport v0.6.0/go.mod h1:iWZ07doqOosSLMhZ+FXUTq+TamDoXSllxpbGcfkCmbE=
|
||||
github.com/pion/transport v0.8.10/go.mod h1:tBmha/UCjpum5hqTWhfAEs3CO4/tHSg0MYRhSzR+CZ8=
|
||||
github.com/pion/transport v0.10.0/go.mod h1:BnHnUipd0rZQyTVB2SBGojFHT9CBt5C5TcsJSQGkvSE=
|
||||
github.com/pion/transport v0.10.1/go.mod h1:PBis1stIILMiis0PewDw91WJeLJkyIMcEk+DwKOzf4A=
|
||||
github.com/pion/transport v0.12.2/go.mod h1:N3+vZQD9HlDP5GWkZ85LohxNsDcNgofQmyL6ojX5d8Q=
|
||||
github.com/pion/transport v0.12.3/go.mod h1:OViWW9SP2peE/HbwBvARicmAVnesphkNkCVZIWJ6q9A=
|
||||
github.com/pion/transport v0.13.0/go.mod h1:yxm9uXpK9bpBBWkITk13cLo1y5/ur5VQpG22ny6EP7g=
|
||||
github.com/pion/transport v0.13.1 h1:/UH5yLeQtwm2VZIPjxwnNFxjS4DFhyLfS4GlfuKUzfA=
|
||||
github.com/pion/transport v0.13.1/go.mod h1:EBxbqzyv+ZrmDb82XswEE0BjfQFtuw1Nu6sjnjWCsGg=
|
||||
github.com/pion/turn/v2 v2.0.4/go.mod h1:1812p4DcGVbYVBTiraUmP50XoKye++AMkbfp+N27mog=
|
||||
github.com/pion/turn/v2 v2.0.8 h1:KEstL92OUN3k5k8qxsXHpr7WWfrdp7iJZHx99ud8muw=
|
||||
github.com/pion/turn/v2 v2.0.8/go.mod h1:+y7xl719J8bAEVpSXBXvTxStjJv3hbz9YFflvkpcGPw=
|
||||
github.com/pion/udp v0.1.0/go.mod h1:BPELIjbwE9PRbd/zxI/KYBnbo7B6+oA6YuEaNE8lths=
|
||||
github.com/pion/udp v0.1.1 h1:8UAPvyqmsxK8oOjloDk4wUt63TzFe9WEJkg5lChlj7o=
|
||||
github.com/pion/udp v0.1.1/go.mod h1:6AFo+CMdKQm7UiA0eUPA8/eVCTx8jBIITLZHc9DWX5M=
|
||||
github.com/pion/webrtc/v2 v2.2.26/go.mod h1:XMZbZRNHyPDe1gzTIHFcQu02283YO45CbiwFgKvXnmc=
|
||||
github.com/pion/webrtc/v3 v3.1.41/go.mod h1:sUcW9SFPEWerDqGOBmdYEMfRvbdd7rgwo4bNzfsXww4=
|
||||
github.com/pion/webrtc/v3 v3.1.43 h1:YT3ZTO94UT4kSBvZnRAH82+0jJPUruiKr9CEstdlQzk=
|
||||
github.com/pion/webrtc/v3 v3.1.43/go.mod h1:G/J8k0+grVsjC/rjCZ24AKoCCxcFFODgh7zThNZGs0M=
|
||||
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/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/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/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.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.27.0 h1:1T7qCieN22GVc8S4Q2yuexzBb1EqjbgjSH9RohbMjKs=
|
||||
github.com/rs/zerolog v1.27.0/go.mod h1:7frBqO0oezxmnO7GF86FY++uy8I0Tk/If5ni1G9Qc0U=
|
||||
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/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/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
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.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
|
||||
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/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=
|
||||
golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
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-20200602180216-279210d13fed/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/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.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220516162934-403b01795ae8 h1:y+mHpWoQJNAHt26Nhh6JP7hvM71IRZureyvZhoVALIs=
|
||||
golang.org/x/crypto v0.0.0-20220516162934-403b01795ae8/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/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo=
|
||||
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/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-20191126235420-ef20fe5d7933/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/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-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201201195509-5d6afe98e0b7/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
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-20211201190559-0a0e4e1bb54c/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220401154927-543a649e0bdd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220531201128-c960675eff93/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.0.0-20220630215102-69896b714898 h1:K7wO6V1IrczY9QOQ2WkVpw4JQSwCd52UsxVEirZUfiw=
|
||||
golang.org/x/net v0.0.0-20220630215102-69896b714898/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
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/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 h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
|
||||
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/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-20190228124157-a34e9553db1e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200724161237-0e2f3a69832c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
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=
|
||||
@@ -207,27 +182,39 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
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=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220608164250-635b8c9b7f68/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664 h1:wEZYwx+kK+KlZ0hpvP2Ls1Xr4+RWnlzGFwPP0aiDjIU=
|
||||
golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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/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/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 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||
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/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 h1:BonxutuHCTL0rBDnZlKjpGIQFTjyUVTexFOdWkB6Fg0=
|
||||
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/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=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
@@ -242,9 +229,9 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogR
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
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=
|
||||
|
60
hardware.Dockerfile
Normal file
60
hardware.Dockerfile
Normal file
@@ -0,0 +1,60 @@
|
||||
# syntax=docker/dockerfile:labs
|
||||
|
||||
# 0. Prepare images
|
||||
# only debian 12 (bookworm) has latest ffmpeg
|
||||
ARG DEBIAN_VERSION="bookworm-slim"
|
||||
ARG GO_VERSION="1.20-buster"
|
||||
ARG NGROK_VERSION="3"
|
||||
|
||||
FROM debian:${DEBIAN_VERSION} AS base
|
||||
FROM golang:${GO_VERSION} AS go
|
||||
FROM ngrok/ngrok:${NGROK_VERSION} AS ngrok
|
||||
|
||||
|
||||
# 1. Build go2rtc binary
|
||||
FROM go AS build
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Cache dependencies
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build go mod download
|
||||
|
||||
COPY . .
|
||||
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 --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)
|
||||
# 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 / /
|
||||
|
||||
|
||||
|
||||
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 ["go2rtc", "-config", "/config/go2rtc.yaml"]
|
11
internal/README.md
Normal file
11
internal/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
## Go
|
||||
|
||||
```
|
||||
go mod why github.com/pion/rtcp
|
||||
go list -deps .\cmd\go2rtc_rtsp\
|
||||
```
|
||||
|
||||
## Useful links
|
||||
|
||||
- https://github.com/golang-standards/project-layout
|
||||
- https://github.com/micro/micro
|
170
internal/api/api.go
Normal file
170
internal/api/api.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/rs/zerolog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var cfg struct {
|
||||
Mod struct {
|
||||
Listen string `yaml:"listen"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
BasePath string `yaml:"base_path"`
|
||||
StaticDir string `yaml:"static_dir"`
|
||||
Origin string `yaml:"origin"`
|
||||
} `yaml:"api"`
|
||||
}
|
||||
|
||||
// default config
|
||||
cfg.Mod.Listen = ":1984"
|
||||
|
||||
// load config from YAML
|
||||
app.LoadConfig(&cfg)
|
||||
|
||||
if cfg.Mod.Listen == "" {
|
||||
return
|
||||
}
|
||||
|
||||
basePath = cfg.Mod.BasePath
|
||||
log = app.GetLogger("api")
|
||||
|
||||
initStatic(cfg.Mod.StaticDir)
|
||||
initWS(cfg.Mod.Origin)
|
||||
|
||||
HandleFunc("api", apiHandler)
|
||||
HandleFunc("api/config", configHandler)
|
||||
HandleFunc("api/exit", exitHandler)
|
||||
HandleFunc("api/ws", apiWS)
|
||||
|
||||
// ensure we can listen without errors
|
||||
listener, err := net.Listen("tcp", cfg.Mod.Listen)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("[api] listen")
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().Str("addr", cfg.Mod.Listen).Msg("[api] listen")
|
||||
|
||||
Handler = http.DefaultServeMux // 4th
|
||||
|
||||
if cfg.Mod.Origin == "*" {
|
||||
Handler = middlewareCORS(Handler) // 3rd
|
||||
}
|
||||
|
||||
if cfg.Mod.Username != "" {
|
||||
Handler = middlewareAuth(cfg.Mod.Username, cfg.Mod.Password, Handler) // 2nd
|
||||
}
|
||||
|
||||
if log.Trace().Enabled() {
|
||||
Handler = middlewareLog(Handler) // 1st
|
||||
}
|
||||
|
||||
go func() {
|
||||
s := http.Server{}
|
||||
s.Handler = Handler
|
||||
if err = s.Serve(listener); err != nil {
|
||||
log.Fatal().Err(err).Msg("[api] serve")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
var Handler http.Handler
|
||||
|
||||
// HandleFunc handle pattern with relative path:
|
||||
// - "api/streams" => "{basepath}/api/streams"
|
||||
// - "/streams" => "/streams"
|
||||
func HandleFunc(pattern string, handler http.HandlerFunc) {
|
||||
if len(pattern) == 0 || pattern[0] != '/' {
|
||||
pattern = basePath + "/" + pattern
|
||||
}
|
||||
log.Trace().Str("path", pattern).Msg("[api] register path")
|
||||
http.HandleFunc(pattern, handler)
|
||||
}
|
||||
|
||||
const StreamNotFound = "stream not found"
|
||||
|
||||
var basePath string
|
||||
var log zerolog.Logger
|
||||
|
||||
func middlewareLog(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Trace().Msgf("[api] %s %s %s", r.Method, r.URL, r.RemoteAddr)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func middlewareAuth(username, password string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.RemoteAddr, "127.") && !strings.HasPrefix(r.RemoteAddr, "[::1]") {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || user != username || pass != password {
|
||||
w.Header().Set("Www-Authenticate", `Basic realm="go2rtc"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
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 mu sync.Mutex
|
||||
|
||||
func apiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
app.Info["host"] = r.Host
|
||||
mu.Unlock()
|
||||
|
||||
if err := json.NewEncoder(w).Encode(app.Info); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
|
||||
func exitHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s := r.URL.Query().Get("code")
|
||||
code, _ := strconv.Atoi(s)
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
102
internal/api/config.go
Normal file
102
internal/api/config.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"gopkg.in/yaml.v3"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func configHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if app.ConfigPath == "" {
|
||||
http.Error(w, "", http.StatusGone)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
data, err := os.ReadFile(app.ConfigPath)
|
||||
if err != nil {
|
||||
http.Error(w, "", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if _, err = w.Write(data); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
|
||||
case "POST", "PATCH":
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == "PATCH" {
|
||||
// no need to validate after merge
|
||||
data, err = mergeYAML(app.ConfigPath, data)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// validate config
|
||||
var tmp struct{}
|
||||
if err = yaml.Unmarshal(data, &tmp); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err = os.WriteFile(app.ConfigPath, data, 0644); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mergeYAML(file1 string, yaml2 []byte) ([]byte, error) {
|
||||
// Read the contents of the first YAML file
|
||||
data1, err := os.ReadFile(file1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Unmarshal the first YAML file into a map
|
||||
var config1 map[string]any
|
||||
if err = yaml.Unmarshal(data1, &config1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Unmarshal the second YAML document into a map
|
||||
var config2 map[string]any
|
||||
if err = yaml.Unmarshal(yaml2, &config2); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Merge the two maps
|
||||
config1 = merge(config1, config2)
|
||||
|
||||
// Marshal the merged map into YAML
|
||||
return yaml.Marshal(&config1)
|
||||
}
|
||||
|
||||
func merge(dst, src map[string]any) map[string]any {
|
||||
for k, v := range src {
|
||||
if vv, ok := dst[k]; ok {
|
||||
switch vv := vv.(type) {
|
||||
case map[string]any:
|
||||
v := v.(map[string]any)
|
||||
dst[k] = merge(vv, v)
|
||||
case []any:
|
||||
v := v.([]any)
|
||||
dst[k] = v
|
||||
default:
|
||||
dst[k] = v
|
||||
}
|
||||
} else {
|
||||
dst[k] = v
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
@@ -14,13 +14,13 @@ func initStatic(staticDir string) {
|
||||
root = http.FS(www.Static)
|
||||
}
|
||||
|
||||
base := len(basePath)
|
||||
fileServer := http.FileServer(root)
|
||||
|
||||
HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if basePath != "" {
|
||||
r.URL.Path = r.URL.Path[len(basePath):]
|
||||
HandleFunc("", func(w http.ResponseWriter, r *http.Request) {
|
||||
if base > 0 {
|
||||
r.URL.Path = r.URL.Path[base:]
|
||||
}
|
||||
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
185
internal/api/ws.go
Normal file
185
internal/api/ws.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gorilla/websocket"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Message - struct for data exchange in Web API
|
||||
type Message struct {
|
||||
Type string `json:"type"`
|
||||
Value any `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
func (m *Message) String() string {
|
||||
if s, ok := m.Value.(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *Message) GetString(key string) string {
|
||||
if v, ok := m.Value.(map[string]any); ok {
|
||||
if s, ok := v[key].(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type WSHandler func(tr *Transport, msg *Message) error
|
||||
|
||||
func HandleWS(msgType string, handler WSHandler) {
|
||||
wsHandlers[msgType] = handler
|
||||
}
|
||||
|
||||
var wsHandlers = make(map[string]WSHandler)
|
||||
|
||||
func initWS(origin string) {
|
||||
wsUp = &websocket.Upgrader{
|
||||
ReadBufferSize: 4096, // for SDP
|
||||
WriteBufferSize: 512 * 1024, // 512K
|
||||
}
|
||||
|
||||
switch origin {
|
||||
case "":
|
||||
// same origin + ignore port
|
||||
wsUp.CheckOrigin = func(r *http.Request) bool {
|
||||
origin := r.Header["Origin"]
|
||||
if len(origin) == 0 {
|
||||
return true
|
||||
}
|
||||
o, err := url.Parse(origin[0])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if o.Host == r.Host {
|
||||
return true
|
||||
}
|
||||
log.Trace().Msgf("[api.ws] origin=%s, host=%s", o.Host, r.Host)
|
||||
// https://github.com/AlexxIT/go2rtc/issues/118
|
||||
if i := strings.IndexByte(o.Host, ':'); i > 0 {
|
||||
return o.Host[:i] == r.Host
|
||||
}
|
||||
return false
|
||||
}
|
||||
case "*":
|
||||
// any origin
|
||||
wsUp.CheckOrigin = func(r *http.Request) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func apiWS(w http.ResponseWriter, r *http.Request) {
|
||||
ws, err := wsUp.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
origin := r.Header.Get("Origin")
|
||||
log.Error().Err(err).Caller().Msgf("host=%s origin=%s", r.Host, origin)
|
||||
return
|
||||
}
|
||||
|
||||
tr := &Transport{Request: r}
|
||||
tr.OnWrite(func(msg any) {
|
||||
_ = ws.SetWriteDeadline(time.Now().Add(time.Second * 5))
|
||||
|
||||
if data, ok := msg.([]byte); ok {
|
||||
_ = ws.WriteMessage(websocket.BinaryMessage, data)
|
||||
} else {
|
||||
_ = ws.WriteJSON(msg)
|
||||
}
|
||||
})
|
||||
|
||||
for {
|
||||
msg := new(Message)
|
||||
if err = ws.ReadJSON(msg); err != nil {
|
||||
if !websocket.IsCloseError(err, websocket.CloseNoStatusReceived) {
|
||||
log.Trace().Err(err).Caller().Send()
|
||||
}
|
||||
_ = ws.Close()
|
||||
break
|
||||
}
|
||||
|
||||
log.Trace().Str("type", msg.Type).Msg("[api.ws] msg")
|
||||
|
||||
if handler := wsHandlers[msg.Type]; handler != nil {
|
||||
go func() {
|
||||
if err = handler(tr, msg); err != nil {
|
||||
tr.Write(&Message{Type: "error", Value: msg.Type + ": " + err.Error()})
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
tr.Close()
|
||||
}
|
||||
|
||||
var wsUp *websocket.Upgrader
|
||||
|
||||
type Transport struct {
|
||||
Request *http.Request
|
||||
|
||||
ctx map[any]any
|
||||
|
||||
closed bool
|
||||
mx sync.Mutex
|
||||
wrmx sync.Mutex
|
||||
|
||||
onChange func()
|
||||
onWrite func(msg any)
|
||||
onClose []func()
|
||||
}
|
||||
|
||||
func (t *Transport) OnWrite(f func(msg any)) {
|
||||
t.mx.Lock()
|
||||
if t.onChange != nil {
|
||||
t.onChange()
|
||||
}
|
||||
t.onWrite = f
|
||||
t.mx.Unlock()
|
||||
}
|
||||
|
||||
func (t *Transport) Write(msg any) {
|
||||
t.wrmx.Lock()
|
||||
t.onWrite(msg)
|
||||
t.wrmx.Unlock()
|
||||
}
|
||||
|
||||
func (t *Transport) Close() {
|
||||
t.mx.Lock()
|
||||
for _, f := range t.onClose {
|
||||
f()
|
||||
}
|
||||
t.closed = true
|
||||
t.mx.Unlock()
|
||||
}
|
||||
|
||||
func (t *Transport) OnChange(f func()) {
|
||||
t.mx.Lock()
|
||||
t.onChange = f
|
||||
t.mx.Unlock()
|
||||
}
|
||||
|
||||
func (t *Transport) OnClose(f func()) {
|
||||
t.mx.Lock()
|
||||
if t.closed {
|
||||
f()
|
||||
} else {
|
||||
t.onClose = append(t.onClose, f)
|
||||
}
|
||||
t.mx.Unlock()
|
||||
}
|
||||
|
||||
// WithContext - run function with Context variable
|
||||
func (t *Transport) WithContext(f func(ctx map[any]any)) {
|
||||
t.mx.Lock()
|
||||
if t.ctx == nil {
|
||||
t.ctx = map[any]any{}
|
||||
}
|
||||
f(t.ctx)
|
||||
t.mx.Unlock()
|
||||
}
|
142
internal/app/app.go
Normal file
142
internal/app/app.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var Version = "1.5.0"
|
||||
var UserAgent = "go2rtc/" + Version
|
||||
|
||||
var ConfigPath string
|
||||
var Info = map[string]any{
|
||||
"version": Version,
|
||||
}
|
||||
|
||||
func Init() {
|
||||
var confs Config
|
||||
var version bool
|
||||
|
||||
flag.Var(&confs, "config", "go2rtc config (path to file or raw text), support multiple")
|
||||
flag.BoolVar(&version, "version", false, "Print the version of the application and exit")
|
||||
flag.Parse()
|
||||
|
||||
if version {
|
||||
fmt.Println("Current version: ", Version)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if confs == nil {
|
||||
confs = []string{"go2rtc.yaml"}
|
||||
}
|
||||
|
||||
for _, conf := range confs {
|
||||
if conf[0] != '{' {
|
||||
// config as file
|
||||
if ConfigPath == "" {
|
||||
ConfigPath = conf
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(conf)
|
||||
if data == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
data = []byte(shell.ReplaceEnvVars(string(data)))
|
||||
configs = append(configs, data)
|
||||
} else {
|
||||
// config as raw YAML
|
||||
configs = append(configs, []byte(conf))
|
||||
}
|
||||
}
|
||||
|
||||
if ConfigPath != "" {
|
||||
if !filepath.IsAbs(ConfigPath) {
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
ConfigPath = filepath.Join(cwd, ConfigPath)
|
||||
}
|
||||
}
|
||||
Info["config_path"] = ConfigPath
|
||||
}
|
||||
|
||||
var cfg struct {
|
||||
Mod map[string]string `yaml:"log"`
|
||||
}
|
||||
|
||||
LoadConfig(&cfg)
|
||||
|
||||
log.Logger = NewLogger(cfg.Mod["format"], cfg.Mod["level"])
|
||||
|
||||
modules = cfg.Mod
|
||||
|
||||
log.Info().Msgf("go2rtc version %s %s/%s", Version, runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
|
||||
func NewLogger(format string, level string) zerolog.Logger {
|
||||
var writer io.Writer = os.Stdout
|
||||
|
||||
if format != "json" {
|
||||
writer = zerolog.ConsoleWriter{
|
||||
Out: writer, TimeFormat: "15:04:05.000",
|
||||
NoColor: writer != os.Stdout || format == "text",
|
||||
}
|
||||
}
|
||||
|
||||
zerolog.TimeFieldFormat = time.RFC3339Nano
|
||||
|
||||
lvl, err := zerolog.ParseLevel(level)
|
||||
if err != nil || lvl == zerolog.NoLevel {
|
||||
lvl = zerolog.InfoLevel
|
||||
}
|
||||
|
||||
return zerolog.New(writer).With().Timestamp().Logger().Level(lvl)
|
||||
}
|
||||
|
||||
func LoadConfig(v any) {
|
||||
for _, data := range configs {
|
||||
if err := yaml.Unmarshal(data, v); err != nil {
|
||||
log.Warn().Err(err).Msg("[app] read config")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GetLogger(module string) zerolog.Logger {
|
||||
if s, ok := modules[module]; ok {
|
||||
lvl, err := zerolog.ParseLevel(s)
|
||||
if err == nil {
|
||||
return log.Level(lvl)
|
||||
}
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
|
||||
return log.Logger
|
||||
}
|
||||
|
||||
// internal
|
||||
|
||||
type Config []string
|
||||
|
||||
func (c *Config) String() string {
|
||||
return strings.Join(*c, " ")
|
||||
}
|
||||
|
||||
func (c *Config) Set(value string) error {
|
||||
*c = append(*c, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
var configs [][]byte
|
||||
|
||||
// modules log levels
|
||||
var modules map[string]string
|
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
const name = "go2rtc.json"
|
||||
|
||||
var store map[string]interface{}
|
||||
var store map[string]any
|
||||
|
||||
func load() {
|
||||
data, _ := os.ReadFile(name)
|
||||
@@ -20,7 +20,7 @@ func load() {
|
||||
}
|
||||
|
||||
if store == nil {
|
||||
store = make(map[string]interface{})
|
||||
store = make(map[string]any)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func save() error {
|
||||
return os.WriteFile(name, data, 0644)
|
||||
}
|
||||
|
||||
func GetRaw(key string) interface{} {
|
||||
func GetRaw(key string) any {
|
||||
if store == nil {
|
||||
load()
|
||||
}
|
||||
@@ -41,16 +41,16 @@ func GetRaw(key string) interface{} {
|
||||
return store[key]
|
||||
}
|
||||
|
||||
func GetDict(key string) map[string]interface{} {
|
||||
func GetDict(key string) map[string]any {
|
||||
raw := GetRaw(key)
|
||||
if raw != nil {
|
||||
return raw.(map[string]interface{})
|
||||
return raw.(map[string]any)
|
||||
}
|
||||
|
||||
return make(map[string]interface{})
|
||||
return make(map[string]any)
|
||||
}
|
||||
|
||||
func Set(key string, v interface{}) error {
|
||||
func Set(key string, v any) error {
|
||||
if store == nil {
|
||||
load()
|
||||
}
|
17
internal/debug/debug.go
Normal file
17
internal/debug/debug.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
api.HandleFunc("api/stack", stackHandler)
|
||||
|
||||
streams.HandleFunc("null", nullHandler)
|
||||
}
|
||||
|
||||
func nullHandler(string) (core.Producer, error) {
|
||||
return nil, nil
|
||||
}
|
@@ -13,17 +13,19 @@ 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/internal/rtsp.Init"),
|
||||
[]byte("created by github.com/AlexxIT/go2rtc/internal/srtp.Init"),
|
||||
|
||||
// webrtc/api.go
|
||||
[]byte("created by github.com/pion/ice/v2.NewTCPMuxDefault"),
|
||||
[]byte("created by github.com/pion/ice/v2.NewUDPMuxDefault"),
|
||||
}
|
||||
|
||||
func stackHandler(w http.ResponseWriter, r *http.Request) {
|
25
internal/dvrip/dvrip.go
Normal file
25
internal/dvrip/dvrip.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package dvrip
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/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
|
||||
}
|
29
internal/echo/echo.go
Normal file
29
internal/echo/echo.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package echo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/shell"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
log := app.GetLogger("echo")
|
||||
|
||||
streams.HandleFunc("echo", func(url string) (core.Producer, error) {
|
||||
args := shell.QuoteSplit(url[5:])
|
||||
|
||||
b, err := exec.Command(args[0], args[1:]...).Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b = bytes.TrimSpace(b)
|
||||
|
||||
log.Debug().Str("url", url).Msgf("[echo] %s", b)
|
||||
|
||||
return streams.GetProducer(string(b))
|
||||
})
|
||||
}
|
151
internal/exec/exec.go
Normal file
151
internal/exec/exec.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
rtsp.HandleFunc(func(conn *pkg.Conn) bool {
|
||||
waitersMu.Lock()
|
||||
waiter := waiters[conn.URL.Path]
|
||||
waitersMu.Unlock()
|
||||
|
||||
if waiter == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// unblocking write to channel
|
||||
select {
|
||||
case waiter <- conn:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
streams.HandleFunc("exec", execHandle)
|
||||
|
||||
log = app.GetLogger("exec")
|
||||
}
|
||||
|
||||
func execHandle(url string) (core.Producer, error) {
|
||||
var path string
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
client := magic.NewClient(r)
|
||||
if err = client.Probe(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client.Desc = "exec active producer"
|
||||
client.URL = url
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
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()
|
||||
waiters[path] = ch
|
||||
waitersMu.Unlock()
|
||||
|
||||
defer func() {
|
||||
waitersMu.Lock()
|
||||
delete(waiters, path)
|
||||
waitersMu.Unlock()
|
||||
}()
|
||||
|
||||
log.Debug().Str("url", url).Msg("[exec] run")
|
||||
|
||||
ts := time.Now()
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Error().Err(err).Str("url", url).Msg("[exec]")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
chErr := make(chan error)
|
||||
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
// unblocking write to channel
|
||||
select {
|
||||
case chErr <- err:
|
||||
default:
|
||||
log.Trace().Str("url", url).Msg("[exec] close")
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-time.After(time.Second * 60):
|
||||
_ = cmd.Process.Kill()
|
||||
log.Error().Str("url", url).Msg("[exec] timeout")
|
||||
return nil, errors.New("timeout")
|
||||
case err := <-chErr:
|
||||
return nil, fmt.Errorf("exec: %s", err)
|
||||
case prod := <-ch:
|
||||
log.Debug().Stringer("launch", time.Since(ts)).Msg("[exec] run")
|
||||
return prod, nil
|
||||
}
|
||||
}
|
||||
|
||||
// internal
|
||||
|
||||
var log zerolog.Logger
|
||||
var waiters = map[string]chan core.Producer{}
|
||||
var waitersMu sync.Mutex
|
26
internal/exec/pipe.go
Normal file
26
internal/exec/pipe.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"io"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
return pipeCloser{stdout, cmd}, nil
|
||||
}
|
||||
|
||||
type pipeCloser struct {
|
||||
io.ReadCloser
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
func (p pipeCloser) Close() error {
|
||||
return core.Any(p.ReadCloser.Close(), p.cmd.Process.Kill(), p.cmd.Wait())
|
||||
}
|
@@ -1,3 +1,17 @@
|
||||
## FFplay output
|
||||
|
||||
[FFplay](https://stackoverflow.com/questions/27778678/what-are-mv-fd-aq-vq-sq-and-f-in-a-video-stream) `7.11 A-V: 0.003 fd= 1 aq= 21KB vq= 321KB sq= 0B f=0/0`:
|
||||
|
||||
- `7.11` - master clock, is the time from start of the stream/video
|
||||
- `A-V` - av_diff, difference between audio and video timestamps
|
||||
- `fd` - frames dropped
|
||||
- `aq` - audio queue (0 - no delay)
|
||||
- `vq` - video queue (0 - no delay)
|
||||
- `sq` - subtitle queue
|
||||
- `f` - timestamp error correction rate (Not 100% sure)
|
||||
|
||||
`M-V`, `M-A` means video stream only, audio stream only respectively.
|
||||
|
||||
## Devices Windows
|
||||
|
||||
```
|
||||
@@ -39,3 +53,9 @@
|
||||
- https://html5test.com/
|
||||
- https://trac.ffmpeg.org/wiki/Capture/Webcam
|
||||
- https://trac.ffmpeg.org/wiki/DirectShow
|
||||
- https://stackoverflow.com/questions/53207692/libav-mjpeg-encoding-and-huffman-table
|
||||
- https://github.com/tuupola/esp_video/blob/master/README.md
|
||||
- https://github.com/leandromoreira/ffmpeg-libav-tutorial
|
||||
- https://www.reddit.com/user/VeritablePornocopium/comments/okw130/ffmpeg_with_libfdk_aac_for_windows_x64/
|
||||
- https://slhck.info/video/2017/02/24/vbr-settings.html
|
||||
- [HomeKit audio samples problem](https://superuser.com/questions/1290996/non-monotonous-dts-with-igndts-flag)
|
68
internal/ffmpeg/device/device_darwin.go
Normal file
68
internal/ffmpeg/device/device_darwin.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// https://trac.ffmpeg.org/wiki/Capture/Webcam
|
||||
const deviceInputPrefix = "-f avfoundation"
|
||||
|
||||
func deviceInputSuffix(video, audio string) string {
|
||||
switch {
|
||||
case video != "" && audio != "":
|
||||
return `"` + video + `:` + audio + `"`
|
||||
case video != "":
|
||||
return `"` + video + `"`
|
||||
case audio != "":
|
||||
return `":` + audio + `"`
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
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.Stream{
|
||||
Name: name, URL: "ffmpeg:device?" + kind + "=" + name,
|
||||
})
|
||||
}
|
||||
}
|
60
internal/ffmpeg/device/device_linux.go
Normal file
60
internal/ffmpeg/device/device_linux.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// https://trac.ffmpeg.org/wiki/Capture/Webcam
|
||||
const deviceInputPrefix = "-f v4l2"
|
||||
|
||||
func deviceInputSuffix(video, audio string) string {
|
||||
if video != "" {
|
||||
return video
|
||||
}
|
||||
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.Stream{
|
||||
Name: i[3] + " | " + 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)
|
||||
}
|
||||
}
|
||||
}
|
50
internal/ffmpeg/device/device_windows.go
Normal file
50
internal/ffmpeg/device/device_windows.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// https://trac.ffmpeg.org/wiki/DirectShow
|
||||
const deviceInputPrefix = "-f dshow"
|
||||
|
||||
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.Stream{
|
||||
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)
|
||||
}
|
||||
|
||||
streams = append(streams, stream)
|
||||
}
|
||||
}
|
70
internal/ffmpeg/device/devices.go
Normal file
70
internal/ffmpeg/device/devices.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func Init(bin string) {
|
||||
Bin = bin
|
||||
|
||||
api.HandleFunc("api/ffmpeg/devices", apiDevices)
|
||||
}
|
||||
|
||||
func GetInput(src string) (string, error) {
|
||||
runonce.Do(initDevices)
|
||||
|
||||
input := deviceInputPrefix
|
||||
|
||||
var video, audio string
|
||||
|
||||
if i := strings.IndexByte(src, '?'); i > 0 {
|
||||
query, err := url.ParseQuery(src[i+1:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for key, value := range query {
|
||||
switch key {
|
||||
case "video":
|
||||
video = value[0]
|
||||
case "audio":
|
||||
audio = value[0]
|
||||
case "resolution":
|
||||
input += " -video_size " + value[0]
|
||||
default: // "input_format", "framerate", "video_size"
|
||||
input += " -" + key + " " + value[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if video != "" {
|
||||
if i, err := strconv.Atoi(video); err == nil && i < len(videos) {
|
||||
video = videos[i]
|
||||
}
|
||||
}
|
||||
if audio != "" {
|
||||
if i, err := strconv.Atoi(audio); err == nil && i < len(audios) {
|
||||
audio = audios[i]
|
||||
}
|
||||
}
|
||||
|
||||
input += " -i " + deviceInputSuffix(video, audio)
|
||||
|
||||
return input, nil
|
||||
}
|
||||
|
||||
var Bin string
|
||||
|
||||
var videos, audios []string
|
||||
var streams []api.Stream
|
||||
var runonce sync.Once
|
||||
|
||||
func apiDevices(w http.ResponseWriter, r *http.Request) {
|
||||
runonce.Do(initDevices)
|
||||
|
||||
api.ResponseStreams(w, streams)
|
||||
}
|
293
internal/ffmpeg/ffmpeg.go
Normal file
293
internal/ffmpeg/ffmpeg.go
Normal file
@@ -0,0 +1,293 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/ffmpeg/device"
|
||||
"github.com/AlexxIT/go2rtc/internal/ffmpeg/hardware"
|
||||
"github.com/AlexxIT/go2rtc/internal/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/ffmpeg"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var cfg struct {
|
||||
Mod map[string]string `yaml:"ffmpeg"`
|
||||
}
|
||||
|
||||
cfg.Mod = defaults // will be overriden from yaml
|
||||
|
||||
app.LoadConfig(&cfg)
|
||||
|
||||
if app.GetLogger("exec").GetLevel() >= 0 {
|
||||
defaults["global"] += " -v error"
|
||||
}
|
||||
|
||||
streams.HandleFunc("ffmpeg", func(url string) (core.Producer, error) {
|
||||
args := parseArgs(url[7:]) // remove `ffmpeg:`
|
||||
if args == nil {
|
||||
return nil, errors.New("can't generate ffmpeg command")
|
||||
}
|
||||
return streams.GetProducer("exec:" + args.String())
|
||||
})
|
||||
|
||||
device.Init(defaults["bin"])
|
||||
hardware.Init(defaults["bin"])
|
||||
}
|
||||
|
||||
var defaults = map[string]string{
|
||||
"bin": "ffmpeg",
|
||||
"global": "-hide_banner",
|
||||
|
||||
// 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/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/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 -pix_fmt:v yuvj420p",
|
||||
"h265": "-c:v libx265 -g 50 -profile:v main -level:v 5.1 -preset:v superfast -tune:v zerolatency",
|
||||
"mjpeg": "-c:v mjpeg",
|
||||
//"mjpeg": "-c:v mjpeg -force_duplicated_matrix:v 1 -huffman:v 0 -pix_fmt:v yuvj420p",
|
||||
|
||||
// https://ffmpeg.org/ffmpeg-codecs.html#libopus-1
|
||||
"opus": "-c:a libopus -ar:a 48000 -ac:a 2 -application:a voip -compression_level:a 0",
|
||||
"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",
|
||||
"pcma": "-c:a pcm_alaw -ar:a 8000 -ac:a 1",
|
||||
"pcma/16000": "-c:a pcm_alaw -ar:a 16000 -ac:a 1",
|
||||
"pcma/48000": "-c:a pcm_alaw -ar:a 48000 -ac:a 1",
|
||||
"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 -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",
|
||||
|
||||
// hardware Intel and AMD on Linux
|
||||
// better not to set `-async_depth:v 1` like for QSV, because framedrops
|
||||
// `-bf 0` - disable B-frames is very important
|
||||
"h264/vaapi": "-c:v h264_vaapi -g 50 -bf 0 -profile:v high -level:v 4.1 -sei:v 0",
|
||||
"h265/vaapi": "-c:v hevc_vaapi -g 50 -bf 0 -profile:v high -level:v 5.1 -sei:v 0",
|
||||
"mjpeg/vaapi": "-c:v mjpeg_vaapi",
|
||||
|
||||
// hardware Raspberry
|
||||
"h264/v4l2m2m": "-c:v h264_v4l2m2m -g 50 -bf 0",
|
||||
"h265/v4l2m2m": "-c:v hevc_v4l2m2m -g 50 -bf 0",
|
||||
|
||||
// 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",
|
||||
|
||||
// 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",
|
||||
"h265/dxva2": "-c:v hevc_qsv -g 50 -bf 0 -profile:v high -level:v 5.1 -async_depth:v 1",
|
||||
"mjpeg/dxva2": "-c:v mjpeg_qsv -profile:v high -level:v 5.1",
|
||||
|
||||
// hardware macOS
|
||||
"h264/videotoolbox": "-c:v h264_videotoolbox -g 50 -bf 0 -profile:v high -level:v 4.1",
|
||||
"h265/videotoolbox": "-c:v hevc_videotoolbox -g 50 -bf 0 -profile:v high -level:v 5.1",
|
||||
}
|
||||
|
||||
// inputTemplate - select input template from YAML config by template name
|
||||
// if query has input param - select another tempalte 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
|
||||
}
|
||||
} else {
|
||||
template = defaults[name]
|
||||
}
|
||||
return strings.Replace(template, "{input}", s, 1)
|
||||
}
|
||||
|
||||
func parseArgs(s string) *ffmpeg.Args {
|
||||
// init FFmpeg arguments
|
||||
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"])
|
||||
s = s[:i]
|
||||
}
|
||||
|
||||
// Parse input:
|
||||
// 1. Input as xxxx:// link (http or rtsp or any other)
|
||||
// 2. Input as stream name
|
||||
// 3. Input as FFmpeg device (local USB camera)
|
||||
if i := strings.Index(s, "://"); i > 0 {
|
||||
switch s[:i] {
|
||||
case "http", "https", "rtmp":
|
||||
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 "
|
||||
}
|
||||
|
||||
args.Input += inputTemplate("rtsp", s, query)
|
||||
default:
|
||||
args.Input = "-i " + s
|
||||
}
|
||||
} else if streams.Get(s) != nil {
|
||||
s = "rtsp://127.0.0.1:" + rtsp.Port + "/" + s
|
||||
switch {
|
||||
case args.Video > 0 && args.Audio == 0:
|
||||
s += "?video"
|
||||
case args.Audio > 0 && args.Video == 0:
|
||||
s += "?audio"
|
||||
default:
|
||||
s += "?video&audio"
|
||||
}
|
||||
args.Input = inputTemplate("rtsp", s, query)
|
||||
} else if strings.HasPrefix(s, "device?") {
|
||||
var err error
|
||||
args.Input, err = device.GetInput(s)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
args.Input = inputTemplate("file", s, query)
|
||||
}
|
||||
|
||||
if query["async"] != nil {
|
||||
args.Input = "-use_wallclock_as_timestamps 1 -async 1 " + args.Input
|
||||
}
|
||||
|
||||
// Parse query params:
|
||||
// 1. `width`/`height` params
|
||||
// 2. `rotate` param
|
||||
// 3. `video` params (support multiple)
|
||||
// 4. `audio` params (support multiple)
|
||||
// 5. `hardware` param
|
||||
if query != nil {
|
||||
// 1. Process raw params for FFmpeg
|
||||
for _, raw := range query["raw"] {
|
||||
args.AddCodec(raw)
|
||||
}
|
||||
|
||||
// 2. Process video filters (resize and rotation)
|
||||
if query["width"] != nil || query["height"] != nil {
|
||||
filter := "scale="
|
||||
if query["width"] != nil {
|
||||
filter += query["width"][0]
|
||||
} else {
|
||||
filter += "-1"
|
||||
}
|
||||
filter += ":"
|
||||
if query["height"] != nil {
|
||||
filter += query["height"][0]
|
||||
} else {
|
||||
filter += "-1"
|
||||
}
|
||||
args.AddFilter(filter)
|
||||
}
|
||||
|
||||
if query["rotate"] != nil {
|
||||
var filter string
|
||||
switch query["rotate"][0] {
|
||||
case "90":
|
||||
filter = "transpose=1" // 90 degrees clockwise
|
||||
case "180":
|
||||
filter = "transpose=1,transpose=1"
|
||||
case "-90", "270":
|
||||
filter = "transpose=2" // 90 degrees counterclockwise
|
||||
}
|
||||
if filter != "" {
|
||||
args.AddFilter(filter)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Process video codecs
|
||||
if args.Video > 0 {
|
||||
for _, video := range query["video"] {
|
||||
if video != "copy" {
|
||||
if codec := defaults[video]; codec != "" {
|
||||
args.AddCodec(codec)
|
||||
} else {
|
||||
args.AddCodec(video)
|
||||
}
|
||||
} else {
|
||||
args.AddCodec("-c:v copy")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
args.AddCodec("-vn")
|
||||
}
|
||||
|
||||
// 4. Process audio codecs
|
||||
if args.Audio > 0 {
|
||||
for _, audio := range query["audio"] {
|
||||
if audio != "copy" {
|
||||
if codec := defaults[audio]; codec != "" {
|
||||
args.AddCodec(codec)
|
||||
} else {
|
||||
args.AddCodec(audio)
|
||||
}
|
||||
} else {
|
||||
args.AddCodec("-c:a copy")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
args.AddCodec("-an")
|
||||
}
|
||||
|
||||
if query["hardware"] != nil {
|
||||
hardware.MakeHardware(args, query["hardware"][0], defaults)
|
||||
}
|
||||
}
|
||||
|
||||
if args.Codecs == nil {
|
||||
args.AddCodec("-c copy")
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
23
internal/ffmpeg/ffmpeg_test.go
Normal file
23
internal/ffmpeg/ffmpeg_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
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 -pix_fmt:v yuvj420p -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())
|
||||
|
||||
args = parseArgs("/media/bbb.mp4#video=mjpeg")
|
||||
assert.Equal(t, "ffmpeg -hide_banner -re -i /media/bbb.mp4 -c:v mjpeg -an -f mjpeg -", args.String())
|
||||
|
||||
args = parseArgs("/media/bbb.mp4#video=mjpeg#hardware=vaapi")
|
||||
assert.Equal(t, "ffmpeg -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -re -i /media/bbb.mp4 -c:v mjpeg_vaapi -an -vf format=vaapi|nv12,hwupload -f mjpeg -", args.String())
|
||||
|
||||
args = parseArgs("device?video=0&input_format=mjpeg&video_size=1920x1080")
|
||||
assert.Equal(t, `ffmpeg -hide_banner -f dshow -input_format mjpeg -video_size 1920x1080 -i video="0" -c copy -f mjpeg -`, args.String())
|
||||
}
|
137
internal/ffmpeg/hardware/hardware.go
Normal file
137
internal/ffmpeg/hardware/hardware.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package hardware
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/pkg/ffmpeg"
|
||||
"net/http"
|
||||
"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
|
||||
)
|
||||
|
||||
func Init(bin string) {
|
||||
api.HandleFunc("api/ffmpeg/hardware", func(w http.ResponseWriter, r *http.Request) {
|
||||
api.ResponseStreams(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.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]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
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 -t 1 -c h264_videotoolbox -f null -"
|
||||
const ProbeVideoToolboxH265 = "-f lavfi -i testsrc2 -t 1 -c hevc_videotoolbox -f null -"
|
||||
|
||||
func ProbeAll(bin string) []api.Stream {
|
||||
return []api.Stream{
|
||||
{
|
||||
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
|
||||
}
|
94
internal/ffmpeg/hardware/hardware_linux.go
Normal file
94
internal/ffmpeg/hardware/hardware_linux.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package hardware
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
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.Stream {
|
||||
if runtime.GOARCH == "arm64" || runtime.GOARCH == "arm" {
|
||||
return []api.Stream{
|
||||
{
|
||||
Name: runToString(bin, ProbeV4L2M2MH264),
|
||||
URL: "ffmpeg:...#video=h264#hardware=" + EngineV4L2M2M,
|
||||
},
|
||||
{
|
||||
Name: runToString(bin, ProbeV4L2M2MH265),
|
||||
URL: "ffmpeg:...#video=h265#hardware=" + EngineV4L2M2M,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return []api.Stream{
|
||||
{
|
||||
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.Stream {
|
||||
return []api.Stream{
|
||||
{
|
||||
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
|
||||
}
|
12
internal/ffmpeg/helpers.go
Normal file
12
internal/ffmpeg/helpers.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func TranscodeToJPEG(b []byte) ([]byte, error) {
|
||||
cmd := exec.Command("ffmpeg", "-hide_banner", "-i", "-", "-f", "mjpeg", "-")
|
||||
cmd.Stdin = bytes.NewBuffer(b)
|
||||
return cmd.Output()
|
||||
}
|
116
internal/hass/api.go
Normal file
116
internal/hass/api.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package hass
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/internal/webrtc"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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 from RTSPtoWeb
|
||||
api.HandleFunc("/stream/", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
// /stream/{id}/add
|
||||
case strings.HasSuffix(r.RequestURI, "/add"):
|
||||
var v addJSON
|
||||
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// we can get three types of links:
|
||||
// 1. link to go2rtc stream: rtsp://...:8554/{stream_name}
|
||||
// 2. static link to Hass camera
|
||||
// 3. dynamic link to Hass camera
|
||||
stream := streams.Get(v.Name)
|
||||
if stream == nil {
|
||||
stream = streams.NewTemplate(v.Name, v.Channels.First.Url)
|
||||
}
|
||||
|
||||
stream.SetSource(v.Channels.First.Url)
|
||||
|
||||
ok(w, r)
|
||||
|
||||
// /stream/{id}/channel/0/webrtc
|
||||
default:
|
||||
i := strings.IndexByte(r.RequestURI[8:], '/')
|
||||
if i <= 0 {
|
||||
log.Warn().Msgf("wrong request: %s", r.RequestURI)
|
||||
return
|
||||
}
|
||||
name := r.RequestURI[8 : 8+i]
|
||||
|
||||
stream := streams.Get(name)
|
||||
if stream == nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] parse form")
|
||||
return
|
||||
}
|
||||
|
||||
s := r.FormValue("data")
|
||||
offer, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] sdp64 decode")
|
||||
return
|
||||
}
|
||||
|
||||
s, err = webrtc.ExchangeSDP(stream, string(offer), "WebRTC/Hass sync", r.UserAgent())
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api.hass] exchange SDP")
|
||||
return
|
||||
}
|
||||
|
||||
s = base64.StdEncoding.EncodeToString([]byte(s))
|
||||
_, _ = w.Write([]byte(s))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func 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"`
|
||||
}
|
164
internal/hass/hass.go
Normal file
164
internal/hass/hass.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package hass
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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/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)
|
||||
|
||||
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 != "" {
|
||||
urls[entrie.Title] = fmt.Sprintf(
|
||||
"onvif://%s:%s@%s:%d", data.Username, data.Password, data.Host, data.Port,
|
||||
)
|
||||
} else {
|
||||
urls[entrie.Title] = fmt.Sprintf("onvif://%s:%d", data.Host, data.Port)
|
||||
}
|
||||
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
log.Info().Str("url", "hass:"+entrie.Title).Msg("[hass] load stream")
|
||||
//streams.Get("hass:" + entrie.Title)
|
||||
}
|
||||
|
||||
return urls
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
279
internal/hls/hls.go
Normal file
279
internal/hls/hls.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package hls
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mp4"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mpegts"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/rs/zerolog/log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
api.HandleFunc("api/stream.m3u8", handlerStream)
|
||||
api.HandleFunc("api/hls/playlist.m3u8", handlerPlaylist)
|
||||
|
||||
// HLS (TS)
|
||||
api.HandleFunc("api/hls/segment.ts", handlerSegmentTS)
|
||||
|
||||
// HLS (fMP4)
|
||||
api.HandleFunc("api/hls/init.mp4", handlerInit)
|
||||
api.HandleFunc("api/hls/segment.m4s", handlerSegmentMP4)
|
||||
}
|
||||
|
||||
type Consumer interface {
|
||||
core.Consumer
|
||||
Listen(f core.EventFunc)
|
||||
Init() ([]byte, error)
|
||||
MimeCodecs() string
|
||||
Start()
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
cons Consumer
|
||||
playlist string
|
||||
init []byte
|
||||
segment []byte
|
||||
seq int
|
||||
alive *time.Timer
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
const keepalive = 5 * time.Second
|
||||
|
||||
var sessions = map[string]*Session{}
|
||||
|
||||
// once I saw 404 on MP4 segment, so better to use mutex
|
||||
var sessionsMu sync.RWMutex
|
||||
|
||||
func handlerStream(w http.ResponseWriter, r *http.Request) {
|
||||
// CORS important for Chromecast
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET")
|
||||
return
|
||||
}
|
||||
|
||||
src := r.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var cons 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,
|
||||
}
|
||||
} else {
|
||||
cons = &mpegts.Consumer{
|
||||
RemoteAddr: tcp.RemoteAddr(r),
|
||||
UserAgent: r.UserAgent(),
|
||||
}
|
||||
}
|
||||
|
||||
session := &Session{cons: cons}
|
||||
|
||||
cons.Listen(func(msg any) {
|
||||
if data, ok := msg.([]byte); ok {
|
||||
session.mu.Lock()
|
||||
session.segment = append(session.segment, data...)
|
||||
session.mu.Unlock()
|
||||
}
|
||||
})
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
session.alive = time.AfterFunc(keepalive, func() {
|
||||
stream.RemoveConsumer(cons)
|
||||
})
|
||||
session.init, _ = cons.Init()
|
||||
|
||||
cons.Start()
|
||||
|
||||
sid := core.RandString(8, 62)
|
||||
|
||||
// two segments important for Chromecast
|
||||
if medias != nil {
|
||||
session.playlist = `#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:1
|
||||
#EXT-X-MEDIA-SEQUENCE:%d
|
||||
#EXT-X-MAP:URI="init.mp4?id=` + sid + `"
|
||||
#EXTINF:0.500,
|
||||
segment.m4s?id=` + sid + `&n=%d
|
||||
#EXTINF:0.500,
|
||||
segment.m4s?id=` + sid + `&n=%d`
|
||||
} else {
|
||||
session.playlist = `#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:1
|
||||
#EXT-X-MEDIA-SEQUENCE:%d
|
||||
#EXTINF:0.500,
|
||||
segment.ts?id=` + sid + `&n=%d
|
||||
#EXTINF:0.500,
|
||||
segment.ts?id=` + sid + `&n=%d`
|
||||
}
|
||||
|
||||
sessionsMu.Lock()
|
||||
sessions[sid] = session
|
||||
sessionsMu.Unlock()
|
||||
|
||||
// Apple Safari can play FLAC codec, but fail it it in m3u8 playlist
|
||||
codecs := strings.Replace(cons.MimeCodecs(), mp4.MimeFlac, mp4.MimeAAC, 1)
|
||||
|
||||
// bandwidth important for Safari, codecs useful for smooth playback
|
||||
data := []byte(`#EXTM3U
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS="` + codecs + `"
|
||||
hls/playlist.m3u8?id=` + sid)
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
|
||||
func handlerPlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET")
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
|
||||
func handlerSegmentTS(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Content-Type", "video/mp2t")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET")
|
||||
return
|
||||
}
|
||||
|
||||
sid := r.URL.Query().Get("id")
|
||||
sessionsMu.RLock()
|
||||
session := sessions[sid]
|
||||
sessionsMu.RUnlock()
|
||||
if session == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
func handlerInit(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Add("Content-Type", "video/mp4")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET")
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
|
||||
func handlerSegmentMP4(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Add("Content-Type", "video/iso.segment")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
return
|
||||
}
|
||||
|
||||
sid := r.URL.Query().Get("id")
|
||||
sessionsMu.RLock()
|
||||
session := sessions[sid]
|
||||
sessionsMu.RUnlock()
|
||||
if session == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
140
internal/homekit/api.go
Normal file
140
internal/homekit/api.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/internal/app/store"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/mdns"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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"`
|
||||
}
|
32
internal/homekit/homekit.go
Normal file
32
internal/homekit/homekit.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"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/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
|
||||
}
|
95
internal/http/http.go
Normal file
95
internal/http/http.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/magic"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mjpeg"
|
||||
"github.com/AlexxIT/go2rtc/pkg/rtmp"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("http", handleHTTP)
|
||||
streams.HandleFunc("https", handleHTTP)
|
||||
streams.HandleFunc("httpx", handleHTTP)
|
||||
|
||||
streams.HandleFunc("tcp", handleTCP)
|
||||
}
|
||||
|
||||
func handleHTTP(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
|
||||
|
||||
default: // "video/mpeg":
|
||||
}
|
||||
|
||||
client := magic.NewClient(res.Body)
|
||||
if err = client.Probe(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client.Desc = "HTTP active producer"
|
||||
client.URL = url
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
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, time.Second*3)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := magic.NewClient(conn)
|
||||
if err = client.Probe(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client.Desc = "TCP active producer"
|
||||
client.URL = rawURL
|
||||
|
||||
return client, nil
|
||||
}
|
22
internal/isapi/init.go
Normal file
22
internal/isapi/init.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package isapi
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/isapi"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("isapi", handle)
|
||||
}
|
||||
|
||||
func handle(url string) (core.Producer, error) {
|
||||
conn, err := isapi.NewClient(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = conn.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
19
internal/ivideon/ivideon.go
Normal file
19
internal/ivideon/ivideon.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package ivideon
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/ivideon"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("ivideon", func(url string) (core.Producer, error) {
|
||||
id := strings.Replace(url[8:], "/", ":", 1)
|
||||
prod := ivideon.NewClient(id)
|
||||
if err := prod.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return prod, nil
|
||||
})
|
||||
}
|
188
internal/mjpeg/init.go
Normal file
188
internal/mjpeg/init.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package mjpeg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"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"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
api.HandleFunc("api/frame.jpeg", handlerKeyframe)
|
||||
api.HandleFunc("api/stream.mjpeg", handlerStream)
|
||||
|
||||
api.HandleWS("mjpeg", handlerWS)
|
||||
}
|
||||
|
||||
func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
|
||||
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 := &magic.Keyframe{
|
||||
RemoteAddr: tcp.RemoteAddr(r),
|
||||
UserAgent: r.UserAgent(),
|
||||
}
|
||||
cons.Listen(func(msg any) {
|
||||
if b, ok := msg.([]byte); ok {
|
||||
select {
|
||||
case exit <- b:
|
||||
default:
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
data := <-exit
|
||||
|
||||
stream.RemoveConsumer(cons)
|
||||
|
||||
switch cons.CodecName() {
|
||||
case core.CodecH264, core.CodecH265:
|
||||
ts := time.Now()
|
||||
var err error
|
||||
if data, err = ffmpeg.TranscodeToJPEG(data); 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("Cache-Control", "no-cache")
|
||||
h.Set("Connection", "close")
|
||||
h.Set("Pragma", "no-cache")
|
||||
|
||||
if _, err := w.Write(data); 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)
|
||||
} else {
|
||||
inputMjpeg(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func outputMjpeg(w http.ResponseWriter, r *http.Request) {
|
||||
src := r.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(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()
|
||||
}
|
||||
})
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Msg("[api.mjpeg] add consumer")
|
||||
return
|
||||
}
|
||||
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
||||
h.Set("Cache-Control", "no-cache")
|
||||
h.Set("Connection", "close")
|
||||
h.Set("Pragma", "no-cache")
|
||||
|
||||
<-r.Context().Done()
|
||||
|
||||
stream.RemoveConsumer(cons)
|
||||
|
||||
//log.Trace().Msg("[api.mjpeg] close")
|
||||
}
|
||||
|
||||
func inputMjpeg(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, Header: r.Header, Request: r}
|
||||
res.Header.Set("Content-Type", "multipart/mixed;boundary=")
|
||||
|
||||
client := mjpeg.NewClient(res)
|
||||
stream.AddProducer(client)
|
||||
|
||||
if err := client.Start(); err != nil && err != io.EOF {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
|
||||
stream.RemoveProducer(client)
|
||||
}
|
||||
|
||||
func handlerWS(tr *api.Transport, _ *api.Message) error {
|
||||
src := tr.Request.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return err
|
||||
}
|
||||
|
||||
tr.Write(&api.Message{Type: "mjpeg"})
|
||||
|
||||
tr.OnClose(func() {
|
||||
stream.RemoveConsumer(cons)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
174
internal/mp4/mp4.go
Normal file
174
internal/mp4/mp4.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package mp4
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mp4"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
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, 1)
|
||||
|
||||
cons := &mp4.Segment{OnlyKeyframe: true}
|
||||
cons.Listen(func(msg any) {
|
||||
if data, ok := msg.([]byte); ok && exit != nil {
|
||||
select {
|
||||
case exit <- data:
|
||||
default:
|
||||
}
|
||||
exit = nil
|
||||
}
|
||||
})
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
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)
|
||||
|
||||
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.GetOrNew(src)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
exit := make(chan error, 1) // Add buffer to prevent blocking
|
||||
|
||||
cons := &mp4.Consumer{
|
||||
RemoteAddr: tcp.RemoteAddr(r),
|
||||
UserAgent: r.UserAgent(),
|
||||
Medias: mp4.ParseQuery(r.URL.Query()),
|
||||
}
|
||||
|
||||
cons.Listen(func(msg any) {
|
||||
if exit == nil {
|
||||
return
|
||||
}
|
||||
if data, ok := msg.([]byte); ok {
|
||||
if _, err := w.Write(data); err != nil {
|
||||
select {
|
||||
case exit <- err:
|
||||
default:
|
||||
}
|
||||
exit = nil
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
defer stream.RemoveConsumer(cons)
|
||||
|
||||
w.Header().Set("Content-Type", cons.MimeType())
|
||||
|
||||
data, err := cons.Init()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = w.Write(data); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
cons.Start()
|
||||
|
||||
var duration *time.Timer
|
||||
if s := query.Get("duration"); s != "" {
|
||||
if i, _ := strconv.Atoi(s); i > 0 {
|
||||
duration = time.AfterFunc(time.Second*time.Duration(i), func() {
|
||||
if exit != nil {
|
||||
select {
|
||||
case exit <- nil:
|
||||
default:
|
||||
}
|
||||
exit = nil
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
err = <-exit
|
||||
exit = nil
|
||||
|
||||
log.Trace().Err(err).Caller().Send()
|
||||
|
||||
if duration != nil {
|
||||
duration.Stop()
|
||||
}
|
||||
}
|
144
internal/mp4/ws.go
Normal file
144
internal/mp4/ws.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package mp4
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mp4"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func handlerWSMSE(tr *api.Transport, msg *api.Message) error {
|
||||
src := tr.Request.URL.Query().Get("src")
|
||||
stream := streams.GetOrNew(src)
|
||||
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.MimeFlac:
|
||||
audios = append(audios,
|
||||
&core.Codec{Name: core.CodecPCMA},
|
||||
&core.Codec{Name: core.CodecPCMU},
|
||||
&core.Codec{Name: core.CodecPCM},
|
||||
)
|
||||
case mp4.MimeOpus:
|
||||
codec := &core.Codec{Name: core.CodecOpus}
|
||||
audios = append(audios, codec)
|
||||
}
|
||||
}
|
||||
|
||||
if videos != nil {
|
||||
media := &core.Media{
|
||||
Kind: core.KindVideo,
|
||||
Direction: core.DirectionSendonly,
|
||||
Codecs: videos,
|
||||
}
|
||||
medias = append(medias, media)
|
||||
}
|
||||
|
||||
if audios != nil && parseAudio {
|
||||
media := &core.Media{
|
||||
Kind: core.KindAudio,
|
||||
Direction: core.DirectionSendonly,
|
||||
Codecs: audios,
|
||||
}
|
||||
medias = append(medias, media)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
43
internal/mpegts/mpegts.go
Normal file
43
internal/mpegts/mpegts.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package mpegts
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/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)
|
||||
}
|
@@ -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"
|
||||
@@ -30,7 +30,7 @@ func Init() {
|
||||
log.Error().Err(err).Msg("[ngrok] start")
|
||||
}
|
||||
|
||||
ngr.Listen(func(msg interface{}) {
|
||||
ngr.Listen(func(msg any) {
|
||||
if msg := msg.(*ngrok.Message); msg != nil {
|
||||
if strings.HasPrefix(msg.Line, "ERROR:") {
|
||||
log.Warn().Msg("[ngrok] " + msg.Line)
|
194
internal/onvif/init.go
Normal file
194
internal/onvif/init.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/onvif"
|
||||
"github.com/rs/zerolog"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
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.Stream
|
||||
|
||||
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.Stream{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.Stream{
|
||||
Name: name + " stream" + strconv.Itoa(i),
|
||||
URL: src + "?subtype=" + token,
|
||||
})
|
||||
}
|
||||
|
||||
if len(tokens) > 0 && client.HasSnapshots() {
|
||||
items = append(items, api.Stream{
|
||||
Name: name + " snapshot",
|
||||
URL: src + "?subtype=" + tokens[0] + "&snapshot",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
api.ResponseStreams(w, items)
|
||||
}
|
100
internal/roborock/roborock.go
Normal file
100
internal/roborock/roborock.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package roborock
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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() {
|
||||
streams.HandleFunc("roborock", handle)
|
||||
|
||||
api.HandleFunc("api/roborock", apiHandle)
|
||||
}
|
||||
|
||||
func handle(url string) (core.Producer, error) {
|
||||
conn := roborock.NewClient(url)
|
||||
if err := conn.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := conn.Connect(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
var Auth struct {
|
||||
UserData *roborock.UserInfo `json:"user_data"`
|
||||
BaseURL string `json:"base_url"`
|
||||
}
|
||||
|
||||
func apiHandle(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
if Auth.UserData == nil {
|
||||
http.Error(w, "no auth", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
case "POST":
|
||||
if err := r.ParseMultipartForm(1024); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
username := r.Form.Get("username")
|
||||
password := r.Form.Get("password")
|
||||
if username == "" || password == "" {
|
||||
http.Error(w, "empty username or password", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
base, err := roborock.GetBaseURL(username)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ui, err := roborock.Login(base, username, password)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
Auth.BaseURL = base
|
||||
Auth.UserData = ui
|
||||
|
||||
default:
|
||||
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
homeID, err := roborock.GetHomeID(Auth.BaseURL, Auth.UserData.Token)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
devices, err := roborock.GetDevices(Auth.UserData, homeID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var items []api.Stream
|
||||
|
||||
for _, device := range devices {
|
||||
source := fmt.Sprintf(
|
||||
"roborock://%s?u=%s&s=%s&k=%s&did=%s&key=%s&pin=",
|
||||
Auth.UserData.IoT.URL.MQTT[6:],
|
||||
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})
|
||||
}
|
||||
|
||||
api.ResponseStreams(w, items)
|
||||
}
|
62
internal/rtmp/rtmp.go
Normal file
62
internal/rtmp/rtmp.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package rtmp
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/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)
|
||||
}
|
274
internal/rtsp/rtsp.go
Normal file
274
internal/rtsp/rtsp.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package rtsp
|
||||
|
||||
import (
|
||||
"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() {
|
||||
var conf struct {
|
||||
Mod struct {
|
||||
Listen string `yaml:"listen" json:"listen"`
|
||||
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.DefaultQuery = "video&audio"
|
||||
|
||||
app.LoadConfig(&conf)
|
||||
app.Info["rtsp"] = conf.Mod
|
||||
|
||||
log = app.GetLogger("rtsp")
|
||||
|
||||
// RTSP client support
|
||||
streams.HandleFunc("rtsp", rtspHandler)
|
||||
streams.HandleFunc("rtsps", rtspHandler)
|
||||
streams.HandleFunc("rtspx", rtspHandler)
|
||||
|
||||
// RTSP server support
|
||||
address := conf.Mod.Listen
|
||||
if address == "" {
|
||||
return
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", address)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[rtsp] listen")
|
||||
return
|
||||
}
|
||||
|
||||
_, Port, _ = net.SplitHostPort(address)
|
||||
|
||||
log.Info().Str("addr", address).Msg("[rtsp] listen")
|
||||
|
||||
if query, err := url.ParseQuery(conf.Mod.DefaultQuery); err == nil {
|
||||
defaultMedias = ParseQuery(query)
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
go tcpHandler(c)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
type Handler func(conn *rtsp.Conn) bool
|
||||
|
||||
func HandleFunc(handler Handler) {
|
||||
handlers = append(handlers, handler)
|
||||
}
|
||||
|
||||
var Port string
|
||||
|
||||
// internal
|
||||
|
||||
var log zerolog.Logger
|
||||
var handlers []Handler
|
||||
var defaultMedias []*core.Media
|
||||
|
||||
func rtspHandler(url string) (core.Producer, error) {
|
||||
backchannel := true
|
||||
|
||||
if i := strings.IndexByte(url, '#'); i > 0 {
|
||||
if url[i+1:] == "backchannel=0" {
|
||||
backchannel = false
|
||||
}
|
||||
url = url[:i]
|
||||
}
|
||||
|
||||
conn := rtsp.NewClient(url)
|
||||
conn.UserAgent = app.UserAgent
|
||||
|
||||
if log.Trace().Enabled() {
|
||||
conn.Listen(func(msg any) {
|
||||
switch msg := msg.(type) {
|
||||
case *tcp.Request:
|
||||
log.Trace().Msgf("[rtsp] client request:\n%s", msg)
|
||||
case *tcp.Response:
|
||||
log.Trace().Msgf("[rtsp] client response:\n%s", msg)
|
||||
case string:
|
||||
log.Trace().Msgf("[rtsp] client msg: %s", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if err := conn.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn.Backchannel = backchannel
|
||||
if err := conn.Describe(); err != nil {
|
||||
if !backchannel {
|
||||
return nil, err
|
||||
}
|
||||
log.Trace().Msgf("[rtsp] describe (backchannel=%t) err: %v", backchannel, err)
|
||||
|
||||
// second try without backchannel, we need to reconnect
|
||||
conn.Backchannel = false
|
||||
if err = conn.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = conn.Describe(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func tcpHandler(conn *rtsp.Conn) {
|
||||
var name string
|
||||
var closer func()
|
||||
|
||||
trace := log.Trace().Enabled()
|
||||
|
||||
conn.Listen(func(msg any) {
|
||||
if trace {
|
||||
switch msg := msg.(type) {
|
||||
case *tcp.Request:
|
||||
log.Trace().Msgf("[rtsp] server request:\n%s", msg)
|
||||
case *tcp.Response:
|
||||
log.Trace().Msgf("[rtsp] server response:\n%s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
switch msg {
|
||||
case rtsp.MethodDescribe:
|
||||
if len(conn.URL.Path) == 0 {
|
||||
log.Warn().Msg("[rtsp] server empty URL on DESCRIBE")
|
||||
return
|
||||
}
|
||||
|
||||
name = conn.URL.Path[1:]
|
||||
|
||||
stream := streams.Get(name)
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().Str("stream", name).Msg("[rtsp] new consumer")
|
||||
|
||||
conn.SessionName = app.UserAgent
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
closer = func() {
|
||||
stream.RemoveConsumer(conn)
|
||||
}
|
||||
|
||||
case rtsp.MethodAnnounce:
|
||||
if len(conn.URL.Path) == 0 {
|
||||
log.Warn().Msg("[rtsp] server empty URL on ANNOUNCE")
|
||||
return
|
||||
}
|
||||
|
||||
name = conn.URL.Path[1:]
|
||||
|
||||
stream := streams.Get(name)
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().Str("stream", name).Msg("[rtsp] new producer")
|
||||
|
||||
stream.AddProducer(conn)
|
||||
|
||||
closer = func() {
|
||||
stream.RemoveProducer(conn)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if err := conn.Accept(); err != nil {
|
||||
if err != io.EOF {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
if closer != nil {
|
||||
closer()
|
||||
}
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
for _, handler := range handlers {
|
||||
if handler(conn) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if closer != nil {
|
||||
if err := conn.Handle(); err != nil {
|
||||
log.Debug().Msgf("[rtsp] handle=%s", err)
|
||||
}
|
||||
|
||||
closer()
|
||||
|
||||
log.Debug().Str("stream", name).Msg("[rtsp] disconnect")
|
||||
}
|
||||
|
||||
_ = 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)
|
||||
}
|
@@ -1,9 +1,8 @@
|
||||
package srtp
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/pkg/srtp"
|
||||
"github.com/rs/zerolog"
|
||||
"net"
|
||||
)
|
||||
|
||||
@@ -24,36 +23,23 @@ func Init() {
|
||||
return
|
||||
}
|
||||
|
||||
log = app.GetLogger("srtp")
|
||||
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).Msg("[srtp] listen")
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
|
||||
log.Info().Str("addr", cfg.Mod.Listen).Msg("[srtp] listen")
|
||||
|
||||
_, Port, _ = net.SplitHostPort(cfg.Mod.Listen)
|
||||
|
||||
// run server
|
||||
go func() {
|
||||
server = &srtp.Server{}
|
||||
if err = server.Serve(conn); err != nil {
|
||||
log.Warn().Err(err).Msg("[srtp] serve")
|
||||
Server = &srtp.Server{}
|
||||
if err = Server.Serve(conn); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
var server *srtp.Server
|
||||
|
||||
var Port string
|
||||
|
||||
func AddSession(session *srtp.Session) {
|
||||
server.AddSession(session)
|
||||
}
|
||||
|
||||
func RemoveSession(session *srtp.Session) {
|
||||
server.RemoveSession(session)
|
||||
}
|
||||
var Server *srtp.Server
|
41
internal/streams/handlers.go
Normal file
41
internal/streams/handlers.go
Normal file
@@ -0,0 +1,41 @@
|
||||
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)
|
||||
}
|
139
internal/streams/init.go
Normal file
139
internal/streams/init.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/app/store"
|
||||
"github.com/rs/zerolog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var cfg struct {
|
||||
Mod map[string]any `yaml:"streams"`
|
||||
}
|
||||
|
||||
app.LoadConfig(&cfg)
|
||||
|
||||
log = app.GetLogger("streams")
|
||||
|
||||
for name, item := range cfg.Mod {
|
||||
streams[name] = NewStream(item)
|
||||
}
|
||||
|
||||
for name, item := range store.GetDict("streams") {
|
||||
streams[name] = NewStream(item)
|
||||
}
|
||||
|
||||
api.HandleFunc("api/streams", streamsHandler)
|
||||
}
|
||||
|
||||
func Get(name string) *Stream {
|
||||
return streams[name]
|
||||
}
|
||||
|
||||
func New(name string, source any) *Stream {
|
||||
stream := NewStream(source)
|
||||
streams[name] = stream
|
||||
return stream
|
||||
}
|
||||
|
||||
func NewTemplate(name string, source any) *Stream {
|
||||
// check if source links to some stream name from go2rtc
|
||||
if rawURL, ok := source.(string); ok {
|
||||
if u, err := url.Parse(rawURL); err == nil && u.Scheme == "rtsp" {
|
||||
if stream, ok := streams[u.Path[1:]]; ok {
|
||||
streams[name] = stream
|
||||
return stream
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return New(name, "{input}")
|
||||
}
|
||||
|
||||
func GetOrNew(src string) *Stream {
|
||||
if stream, ok := streams[src]; ok {
|
||||
return stream
|
||||
}
|
||||
|
||||
if !HasProducer(src) {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Info().Str("url", src).Msg("[streams] create new stream")
|
||||
|
||||
return New(src, src)
|
||||
}
|
||||
|
||||
func GetAll() (names []string) {
|
||||
for name := range streams {
|
||||
names = append(names, name)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func streamsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
src := query.Get("src")
|
||||
|
||||
// without source - return all streams list
|
||||
if src == "" && r.Method != "POST" {
|
||||
_ = json.NewEncoder(w).Encode(streams)
|
||||
return
|
||||
}
|
||||
|
||||
// Not sure about all this API. Should be rewrited...
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
e := json.NewEncoder(w)
|
||||
e.SetIndent("", " ")
|
||||
_ = e.Encode(streams[src])
|
||||
|
||||
case "PUT":
|
||||
name := query.Get("name")
|
||||
if name == "" {
|
||||
name = src
|
||||
}
|
||||
|
||||
New(name, src)
|
||||
|
||||
case "PATCH":
|
||||
name := query.Get("name")
|
||||
if name == "" {
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// support {input} templates: https://github.com/AlexxIT/go2rtc#module-hass
|
||||
stream := Get(name)
|
||||
if stream == nil {
|
||||
stream = NewTemplate(name, src)
|
||||
}
|
||||
stream.SetSource(src)
|
||||
|
||||
case "POST":
|
||||
// with dst - redirect source to dst
|
||||
if dst := query.Get("dst"); dst != "" {
|
||||
if stream := Get(dst); stream != nil {
|
||||
if err := stream.Play(src); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
} else {
|
||||
_ = json.NewEncoder(w).Encode(stream)
|
||||
}
|
||||
} else {
|
||||
http.Error(w, "", http.StatusNotFound)
|
||||
}
|
||||
} else {
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
case "DELETE":
|
||||
delete(streams, src)
|
||||
}
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
var streams = map[string]*Stream{}
|
151
internal/streams/play.go
Normal file
151
internal/streams/play.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
)
|
||||
|
||||
func (s *Stream) Play(source string) error {
|
||||
s.mu.Lock()
|
||||
for _, producer := range s.producers {
|
||||
if producer.state == stateInternal && producer.conn != nil {
|
||||
_ = producer.conn.Stop()
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if source == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var src core.Producer
|
||||
|
||||
for _, producer := range s.producers {
|
||||
if producer.conn == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
cons, ok := producer.conn.(core.Consumer)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if src == nil {
|
||||
var err error
|
||||
if src, err = GetProducer(source); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !matchMedia(src, cons) {
|
||||
continue
|
||||
}
|
||||
|
||||
s.AddInternalProducer(src)
|
||||
|
||||
go func() {
|
||||
_ = src.Start()
|
||||
s.RemoveProducer(src)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, producer := range s.producers {
|
||||
// start new client
|
||||
dst, err := GetProducer(producer.url)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// check if client support consumer interface
|
||||
cons, ok := dst.(core.Consumer)
|
||||
if !ok {
|
||||
_ = dst.Stop()
|
||||
continue
|
||||
}
|
||||
|
||||
// start new producer
|
||||
if src == nil {
|
||||
if src, err = GetProducer(source); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !matchMedia(src, cons) {
|
||||
_ = dst.Stop()
|
||||
continue
|
||||
}
|
||||
|
||||
s.AddInternalProducer(src)
|
||||
s.AddInternalConsumer(cons)
|
||||
|
||||
go func() {
|
||||
_ = src.Start()
|
||||
_ = dst.Stop()
|
||||
s.RemoveProducer(src)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
_ = dst.Start()
|
||||
_ = src.Stop()
|
||||
s.RemoveInternalConsumer(cons)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New("can't find consumer")
|
||||
}
|
||||
|
||||
func (s *Stream) AddInternalProducer(conn core.Producer) {
|
||||
producer := &Producer{conn: conn, state: stateInternal}
|
||||
s.mu.Lock()
|
||||
s.producers = append(s.producers, producer)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Stream) AddInternalConsumer(conn core.Consumer) {
|
||||
s.mu.Lock()
|
||||
s.consumers = append(s.consumers, conn)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Stream) RemoveInternalConsumer(conn core.Consumer) {
|
||||
s.mu.Lock()
|
||||
for i, consumer := range s.consumers {
|
||||
if consumer == conn {
|
||||
s.consumers = append(s.consumers[:i], s.consumers[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func matchMedia(prod core.Producer, cons core.Consumer) bool {
|
||||
for _, consMedia := range cons.GetMedias() {
|
||||
for _, prodMedia := range prod.GetMedias() {
|
||||
if prodMedia.Direction != core.DirectionRecvonly {
|
||||
continue
|
||||
}
|
||||
|
||||
prodCodec, consCodec := prodMedia.MatchMedia(consMedia)
|
||||
if prodCodec == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
track, err := prod.GetTrack(prodMedia, prodCodec)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if err = cons.AddTrack(consMedia, consCodec, track); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
255
internal/streams/producer.go
Normal file
255
internal/streams/producer.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type state byte
|
||||
|
||||
const (
|
||||
stateNone state = iota
|
||||
stateMedias
|
||||
stateTracks
|
||||
stateStart
|
||||
stateExternal
|
||||
stateInternal
|
||||
)
|
||||
|
||||
type Producer struct {
|
||||
core.Listener
|
||||
|
||||
url string
|
||||
template string
|
||||
|
||||
conn core.Producer
|
||||
receivers []*core.Receiver
|
||||
senders []*core.Receiver
|
||||
|
||||
state state
|
||||
mu sync.Mutex
|
||||
workerID int
|
||||
}
|
||||
|
||||
func (p *Producer) Dial() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.state == stateNone {
|
||||
conn, err := GetProducer(p.url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.conn = conn
|
||||
p.state = stateMedias
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Producer) GetMedias() []*core.Media {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.conn == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return p.conn.GetMedias()
|
||||
}
|
||||
|
||||
func (p *Producer) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.state == stateNone {
|
||||
return nil, errors.New("get track from none state")
|
||||
}
|
||||
|
||||
for _, track := range p.receivers {
|
||||
if track.Codec == codec {
|
||||
return track, nil
|
||||
}
|
||||
}
|
||||
|
||||
track, err := p.conn.GetTrack(media, codec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.receivers = append(p.receivers, track)
|
||||
|
||||
if p.state == stateMedias {
|
||||
p.state = stateTracks
|
||||
}
|
||||
|
||||
return track, nil
|
||||
}
|
||||
|
||||
func (p *Producer) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.state == stateNone {
|
||||
return errors.New("add track from none state")
|
||||
}
|
||||
|
||||
if err := p.conn.(core.Consumer).AddTrack(media, codec, track); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.senders = append(p.senders, track)
|
||||
|
||||
if p.state == stateMedias {
|
||||
p.state = stateTracks
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Producer) SetSource(s string) {
|
||||
if p.template == "" {
|
||||
p.template = p.url
|
||||
}
|
||||
p.url = strings.Replace(p.template, "{input}", s, 1)
|
||||
}
|
||||
|
||||
func (p *Producer) MarshalJSON() ([]byte, error) {
|
||||
if p.conn != nil {
|
||||
return json.Marshal(p.conn)
|
||||
}
|
||||
|
||||
info := core.Info{URL: p.url}
|
||||
return json.Marshal(info)
|
||||
}
|
||||
|
||||
// internals
|
||||
|
||||
func (p *Producer) start() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.state != stateTracks {
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().Msgf("[streams] start producer url=%s", p.url)
|
||||
|
||||
p.state = stateStart
|
||||
p.workerID++
|
||||
|
||||
go p.worker(p.conn, p.workerID)
|
||||
}
|
||||
|
||||
func (p *Producer) worker(conn core.Producer, workerID int) {
|
||||
if err := conn.Start(); err != nil {
|
||||
p.mu.Lock()
|
||||
closed := p.workerID != workerID
|
||||
p.mu.Unlock()
|
||||
|
||||
if closed {
|
||||
return
|
||||
}
|
||||
|
||||
log.Warn().Err(err).Str("url", p.url).Caller().Send()
|
||||
}
|
||||
|
||||
p.reconnect(workerID, 0)
|
||||
}
|
||||
|
||||
func (p *Producer) reconnect(workerID, retry int) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.workerID != workerID {
|
||||
log.Trace().Msgf("[streams] stop reconnect url=%s", p.url)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().Msgf("[streams] retry=%d to url=%s", retry, p.url)
|
||||
|
||||
conn, err := GetProducer(p.url)
|
||||
if err != nil {
|
||||
log.Debug().Msgf("[streams] producer=%s", err)
|
||||
|
||||
timeout := time.Minute
|
||||
if retry < 5 {
|
||||
timeout = time.Second
|
||||
} else if retry < 10 {
|
||||
timeout = time.Second * 5
|
||||
} else if retry < 20 {
|
||||
timeout = time.Second * 10
|
||||
}
|
||||
|
||||
time.AfterFunc(timeout, func() {
|
||||
p.reconnect(workerID, retry+1)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
for _, media := range conn.GetMedias() {
|
||||
switch media.Direction {
|
||||
case core.DirectionRecvonly:
|
||||
for _, receiver := range p.receivers {
|
||||
codec := media.MatchCodec(receiver.Codec)
|
||||
if codec == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
track, err := conn.GetTrack(media, codec)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
receiver.Replace(track)
|
||||
break
|
||||
}
|
||||
|
||||
case core.DirectionSendonly:
|
||||
for _, sender := range p.senders {
|
||||
codec := media.MatchCodec(sender.Codec)
|
||||
if codec == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
_ = conn.(core.Consumer).AddTrack(media, codec, sender)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p.conn = conn
|
||||
|
||||
go p.worker(conn, workerID)
|
||||
}
|
||||
|
||||
func (p *Producer) stop() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
switch p.state {
|
||||
case stateExternal:
|
||||
log.Debug().Msgf("[streams] can't stop external producer")
|
||||
return
|
||||
case stateNone:
|
||||
log.Debug().Msgf("[streams] can't stop none producer")
|
||||
return
|
||||
case stateStart:
|
||||
p.workerID++
|
||||
}
|
||||
|
||||
log.Debug().Msgf("[streams] stop producer url=%s", p.url)
|
||||
|
||||
if p.conn != nil {
|
||||
_ = p.conn.Stop()
|
||||
p.conn = nil
|
||||
}
|
||||
|
||||
p.state = stateNone
|
||||
p.receivers = nil
|
||||
p.senders = nil
|
||||
}
|
254
internal/streams/stream.go
Normal file
254
internal/streams/stream.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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 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
|
||||
consN := atomic.AddInt32(&s.requests, 1) - 1
|
||||
|
||||
var statErrors []error
|
||||
var statMedias []*core.Media
|
||||
var statProds []*Producer // matched producers for consumer
|
||||
|
||||
// Step 1. Get consumer medias
|
||||
for _, consMedia := range cons.GetMedias() {
|
||||
log.Trace().Msgf("[streams] check cons=%d media=%s", consN, consMedia)
|
||||
|
||||
producers:
|
||||
for prodN, prod := range s.producers {
|
||||
if err = prod.Dial(); err != nil {
|
||||
log.Trace().Err(err).Msgf("[streams] skip prod=%s", prod.url)
|
||||
statErrors = append(statErrors, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Step 2. Get producer medias (not tracks yet)
|
||||
for _, prodMedia := range prod.GetMedias() {
|
||||
log.Trace().Msgf("[streams] check prod=%d media=%s", prodN, prodMedia)
|
||||
statMedias = append(statMedias, prodMedia)
|
||||
|
||||
// Step 3. Match consumer/producer codecs list
|
||||
prodCodec, consCodec := prodMedia.MatchMedia(consMedia)
|
||||
if prodCodec == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var track *core.Receiver
|
||||
|
||||
switch prodMedia.Direction {
|
||||
case core.DirectionRecvonly:
|
||||
log.Trace().Msgf("[streams] match prod=%d => cons=%d", prodN, consN)
|
||||
|
||||
// Step 4. Get recvonly track from producer
|
||||
if track, err = prod.GetTrack(prodMedia, prodCodec); err != nil {
|
||||
log.Info().Err(err).Msg("[streams] can't get track")
|
||||
continue
|
||||
}
|
||||
// Step 5. Add track to consumer
|
||||
if err = cons.AddTrack(consMedia, consCodec, track); err != nil {
|
||||
log.Info().Err(err).Msg("[streams] can't add track")
|
||||
continue
|
||||
}
|
||||
|
||||
case core.DirectionSendonly:
|
||||
log.Trace().Msgf("[streams] match cons=%d => prod=%d", consN, prodN)
|
||||
|
||||
// Step 4. Get recvonly track from consumer (backchannel)
|
||||
if track, err = cons.(core.Producer).GetTrack(consMedia, consCodec); err != nil {
|
||||
log.Info().Err(err).Msg("[streams] can't get track")
|
||||
continue
|
||||
}
|
||||
// Step 5. Add track to producer
|
||||
if err = prod.AddTrack(prodMedia, prodCodec, track); err != nil {
|
||||
log.Info().Err(err).Msg("[streams] can't add track")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
statProds = append(statProds, prod)
|
||||
|
||||
if !consMedia.MatchAll() {
|
||||
break producers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stop producers if they don't have readers
|
||||
if atomic.AddInt32(&s.requests, -1) == 0 {
|
||||
s.stopProducers()
|
||||
}
|
||||
|
||||
if len(statProds) == 0 {
|
||||
return formatError(statMedias, statErrors)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.consumers = append(s.consumers, cons)
|
||||
s.mu.Unlock()
|
||||
|
||||
// there may be duplicates, but that's not a problem
|
||||
for _, prod := range statProds {
|
||||
prod.start()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Stream) RemoveConsumer(cons core.Consumer) {
|
||||
_ = cons.Stop()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
for _, track := range producer.senders {
|
||||
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 formatError(statMedias []*core.Media, statErrors []error) error {
|
||||
var text string
|
||||
|
||||
for _, media := range statMedias {
|
||||
if media.Direction == core.DirectionRecvonly {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, codec := range media.Codecs {
|
||||
name := codec.Name
|
||||
if name == core.CodecAAC {
|
||||
name = "AAC"
|
||||
}
|
||||
if strings.Contains(text, name) {
|
||||
continue
|
||||
}
|
||||
if len(text) > 0 {
|
||||
text += ","
|
||||
}
|
||||
text += name
|
||||
}
|
||||
}
|
||||
|
||||
if text != "" {
|
||||
return errors.New(text)
|
||||
}
|
||||
|
||||
for _, err := range statErrors {
|
||||
s := err.Error()
|
||||
if strings.Contains(text, s) {
|
||||
continue
|
||||
}
|
||||
if len(text) > 0 {
|
||||
text += ","
|
||||
}
|
||||
text += s
|
||||
}
|
||||
|
||||
if text != "" {
|
||||
return errors.New(text)
|
||||
}
|
||||
|
||||
return errors.New("unknown error")
|
||||
}
|
19
internal/streams/stream_test.go
Normal file
19
internal/streams/stream_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTemplate(t *testing.T) {
|
||||
source1 := "does not matter"
|
||||
|
||||
stream1 := New("from_yaml", source1)
|
||||
require.Len(t, streams, 1)
|
||||
|
||||
stream2 := NewTemplate("camera.from_hass", "rtsp://localhost:8554/from_yaml?video")
|
||||
|
||||
require.Equal(t, stream1, stream2)
|
||||
require.Equal(t, stream2.producers[0].url, source1)
|
||||
require.Len(t, streams, 2)
|
||||
}
|
19
internal/tapo/tapo.go
Normal file
19
internal/tapo/tapo.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package tapo
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tapo"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
streams.HandleFunc("tapo", handle)
|
||||
}
|
||||
|
||||
func handle(url string) (core.Producer, error) {
|
||||
conn := tapo.NewClient(url)
|
||||
if err := conn.Dial(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
8
internal/webrtc/README.md
Normal file
8
internal/webrtc/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
## Userful links
|
||||
|
||||
- https://www.ietf.org/archive/id/draft-ietf-wish-whip-01.html
|
||||
- https://www.ietf.org/id/draft-murillo-whep-01.html
|
||||
- https://github.com/Glimesh/broadcast-box/
|
||||
- https://github.com/obsproject/obs-studio/pull/7926
|
||||
- https://misi.github.io/webrtc-c0d3l4b/
|
||||
- https://github.com/webtorrent/webtorrent/blob/master/docs/faq.md
|
122
internal/webrtc/candidates.go
Normal file
122
internal/webrtc/candidates.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
"github.com/pion/sdp/v3"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Address struct {
|
||||
Host string
|
||||
Port int
|
||||
}
|
||||
|
||||
var addresses []Address
|
||||
|
||||
func AddCandidate(address string) {
|
||||
var port int
|
||||
|
||||
// try to get port from address string
|
||||
if i := strings.LastIndexByte(address, ':'); i > 0 {
|
||||
if v, _ := strconv.Atoi(address[i+1:]); v != 0 {
|
||||
address = address[:i]
|
||||
port = v
|
||||
}
|
||||
}
|
||||
|
||||
// use default WebRTC port
|
||||
if port == 0 {
|
||||
port, _ = strconv.Atoi(Port)
|
||||
}
|
||||
|
||||
addresses = append(addresses, Address{Host: address, Port: port})
|
||||
}
|
||||
|
||||
func GetCandidates() (candidates []string) {
|
||||
for _, address := range addresses {
|
||||
// using stun server for receive public IP-address
|
||||
if address.Host == "stun" {
|
||||
ip, err := webrtc.GetCachedPublicIP()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// this is a copy, original host unchanged
|
||||
address.Host = ip.String()
|
||||
}
|
||||
|
||||
candidates = append(
|
||||
candidates,
|
||||
webrtc.CandidateManualHostUDP(address.Host, address.Port),
|
||||
webrtc.CandidateManualHostTCPPassive(address.Host, address.Port),
|
||||
)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func asyncCandidates(tr *api.Transport, cons *webrtc.Conn) {
|
||||
tr.WithContext(func(ctx map[any]any) {
|
||||
if candidates, ok := ctx["candidate"].([]string); ok {
|
||||
// process candidates that receive before this moment
|
||||
for _, candidate := range candidates {
|
||||
_ = cons.AddCandidate(candidate)
|
||||
}
|
||||
|
||||
// remove already processed candidates
|
||||
delete(ctx, "candidate")
|
||||
}
|
||||
|
||||
// set variable for process candidates after this moment
|
||||
ctx["webrtc"] = cons
|
||||
})
|
||||
|
||||
for _, candidate := range GetCandidates() {
|
||||
log.Trace().Str("candidate", candidate).Msg("[webrtc] config")
|
||||
tr.Write(&api.Message{Type: "webrtc/candidate", Value: candidate})
|
||||
}
|
||||
}
|
||||
|
||||
func syncCanditates(answer string) (string, error) {
|
||||
if len(addresses) == 0 {
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
sd := &sdp.SessionDescription{}
|
||||
if err := sd.Unmarshal([]byte(answer)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
md := sd.MediaDescriptions[0]
|
||||
|
||||
for _, candidate := range GetCandidates() {
|
||||
md.WithPropertyAttribute(candidate)
|
||||
}
|
||||
|
||||
data, err := sd.Marshal()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func candidateHandler(tr *api.Transport, msg *api.Message) error {
|
||||
// process incoming candidate in sync function
|
||||
tr.WithContext(func(ctx map[any]any) {
|
||||
candidate := msg.String()
|
||||
log.Trace().Str("candidate", candidate).Msg("[webrtc] remote")
|
||||
|
||||
if cons, ok := ctx["webrtc"].(*webrtc.Conn); ok {
|
||||
// if webrtc.Server already initialized - process candidate
|
||||
_ = cons.AddCandidate(candidate)
|
||||
} else {
|
||||
// or collect candidate and process it later
|
||||
list, _ := ctx["candidate"].([]string)
|
||||
ctx["candidate"] = append(list, candidate)
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
178
internal/webrtc/client.go
Normal file
178
internal/webrtc/client.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/AlexxIT/go2rtc/internal/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
|
||||
}
|
275
internal/webrtc/init.go
Normal file
275
internal/webrtc/init.go
Normal file
@@ -0,0 +1,275 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"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/webrtc"
|
||||
pion "github.com/pion/webrtc/v3"
|
||||
"github.com/rs/zerolog"
|
||||
"net"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var cfg struct {
|
||||
Mod struct {
|
||||
Listen string `yaml:"listen"`
|
||||
Candidates []string `yaml:"candidates"`
|
||||
IceServers []pion.ICEServer `yaml:"ice_servers"`
|
||||
} `yaml:"webrtc"`
|
||||
}
|
||||
|
||||
cfg.Mod.Listen = ":8555/tcp"
|
||||
cfg.Mod.IceServers = []pion.ICEServer{
|
||||
{URLs: []string{"stun:stun.l.google.com:19302"}},
|
||||
}
|
||||
|
||||
app.LoadConfig(&cfg)
|
||||
|
||||
log = app.GetLogger("webrtc")
|
||||
|
||||
address := cfg.Mod.Listen
|
||||
|
||||
// create pionAPI with custom codecs list and custom network settings
|
||||
serverAPI, err := webrtc.NewAPI(address)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
// use same API for WebRTC server and client if no address
|
||||
clientAPI := serverAPI
|
||||
|
||||
if address != "" {
|
||||
log.Info().Str("addr", address).Msg("[webrtc] listen")
|
||||
_, Port, _ = net.SplitHostPort(address)
|
||||
|
||||
clientAPI, _ = webrtc.NewAPI("")
|
||||
}
|
||||
|
||||
pionConf := pion.Configuration{
|
||||
ICEServers: cfg.Mod.IceServers,
|
||||
SDPSemantics: pion.SDPSemanticsUnifiedPlanWithFallback,
|
||||
}
|
||||
|
||||
PeerConnection = func(active bool) (*pion.PeerConnection, error) {
|
||||
// active - client, passive - server
|
||||
if active {
|
||||
return clientAPI.NewPeerConnection(pionConf)
|
||||
} else {
|
||||
return serverAPI.NewPeerConnection(pionConf)
|
||||
}
|
||||
}
|
||||
|
||||
for _, candidate := range cfg.Mod.Candidates {
|
||||
AddCandidate(candidate)
|
||||
}
|
||||
|
||||
// async WebRTC server (two API versions)
|
||||
api.HandleWS("webrtc", asyncHandler)
|
||||
api.HandleWS("webrtc/offer", asyncHandler)
|
||||
api.HandleWS("webrtc/candidate", candidateHandler)
|
||||
|
||||
// sync WebRTC server (two API versions)
|
||||
api.HandleFunc("api/webrtc", syncHandler)
|
||||
|
||||
// WebRTC client
|
||||
streams.HandleFunc("webrtc", streamsHandler)
|
||||
}
|
||||
|
||||
var Port string
|
||||
var log zerolog.Logger
|
||||
|
||||
var PeerConnection func(active bool) (*pion.PeerConnection, error)
|
||||
|
||||
func asyncHandler(tr *api.Transport, msg *api.Message) error {
|
||||
var stream *streams.Stream
|
||||
var mode core.Mode
|
||||
|
||||
query := tr.Request.URL.Query()
|
||||
if name := query.Get("src"); name != "" {
|
||||
stream = streams.GetOrNew(name)
|
||||
mode = core.ModePassiveConsumer
|
||||
log.Debug().Str("src", name).Msg("[webrtc] new consumer")
|
||||
} else if name = query.Get("dst"); name != "" {
|
||||
stream = streams.Get(name)
|
||||
mode = core.ModePassiveProducer
|
||||
log.Debug().Str("src", name).Msg("[webrtc] new producer")
|
||||
}
|
||||
|
||||
if stream == nil {
|
||||
return errors.New(api.StreamNotFound)
|
||||
}
|
||||
|
||||
// create new PeerConnection instance
|
||||
pc, err := PeerConnection(false)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return err
|
||||
}
|
||||
|
||||
var sendAnswer core.Waiter
|
||||
|
||||
conn := webrtc.NewConn(pc)
|
||||
conn.Desc = "WebRTC/WebSocket async"
|
||||
conn.Mode = mode
|
||||
conn.UserAgent = tr.Request.UserAgent()
|
||||
conn.Listen(func(msg any) {
|
||||
switch msg := msg.(type) {
|
||||
case pion.PeerConnectionState:
|
||||
if msg != pion.PeerConnectionStateClosed {
|
||||
return
|
||||
}
|
||||
switch mode {
|
||||
case core.ModePassiveConsumer:
|
||||
stream.RemoveConsumer(conn)
|
||||
case core.ModePassiveProducer:
|
||||
stream.RemoveProducer(conn)
|
||||
}
|
||||
|
||||
case *pion.ICECandidate:
|
||||
sendAnswer.Wait()
|
||||
|
||||
s := msg.ToJSON().Candidate
|
||||
log.Trace().Str("candidate", s).Msg("[webrtc] local")
|
||||
tr.Write(&api.Message{Type: "webrtc/candidate", Value: s})
|
||||
}
|
||||
})
|
||||
|
||||
// V2 - json/object exchange, V1 - raw SDP exchange
|
||||
apiV2 := msg.Type == "webrtc"
|
||||
|
||||
// 1. SetOffer, so we can get remote client codecs
|
||||
var offer string
|
||||
if apiV2 {
|
||||
offer = msg.GetString("sdp")
|
||||
} else {
|
||||
offer = msg.String()
|
||||
}
|
||||
|
||||
log.Trace().Msgf("[webrtc] offer:\n%s", offer)
|
||||
|
||||
if err = conn.SetOffer(offer); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
return err
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case core.ModePassiveConsumer:
|
||||
// 2. AddConsumer, so we get new tracks
|
||||
if err = stream.AddConsumer(conn); err != nil {
|
||||
log.Debug().Err(err).Msg("[webrtc] add consumer")
|
||||
_ = conn.Close()
|
||||
return err
|
||||
}
|
||||
case core.ModePassiveProducer:
|
||||
stream.AddProducer(conn)
|
||||
}
|
||||
|
||||
// 3. Exchange SDP without waiting all candidates
|
||||
answer, err := conn.GetAnswer()
|
||||
log.Trace().Msgf("[webrtc] answer\n%s", answer)
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return err
|
||||
}
|
||||
|
||||
if apiV2 {
|
||||
desc := pion.SessionDescription{Type: pion.SDPTypeAnswer, SDP: answer}
|
||||
tr.Write(&api.Message{Type: "webrtc", Value: desc})
|
||||
} else {
|
||||
tr.Write(&api.Message{Type: "webrtc/answer", Value: answer})
|
||||
}
|
||||
|
||||
sendAnswer.Done()
|
||||
|
||||
asyncCandidates(tr, conn)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ExchangeSDP(stream *streams.Stream, offer, desc, userAgent string) (answer string, err error) {
|
||||
pc, err := PeerConnection(false)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
// create new webrtc instance
|
||||
conn := webrtc.NewConn(pc)
|
||||
conn.Desc = desc
|
||||
conn.UserAgent = userAgent
|
||||
conn.Listen(func(msg any) {
|
||||
switch msg := msg.(type) {
|
||||
case pion.PeerConnectionState:
|
||||
if msg != pion.PeerConnectionStateClosed {
|
||||
return
|
||||
}
|
||||
if conn.Mode == core.ModePassiveConsumer {
|
||||
stream.RemoveConsumer(conn)
|
||||
} else {
|
||||
stream.RemoveProducer(conn)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 1. SetOffer, so we can get remote client codecs
|
||||
log.Trace().Msgf("[webrtc] offer:\n%s", offer)
|
||||
|
||||
if err = conn.SetOffer(offer); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
if IsConsumer(conn) {
|
||||
conn.Mode = core.ModePassiveConsumer
|
||||
|
||||
// 2. AddConsumer, so we get new tracks
|
||||
if err = stream.AddConsumer(conn); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
conn.Mode = core.ModePassiveProducer
|
||||
|
||||
stream.AddProducer(conn)
|
||||
}
|
||||
|
||||
answer, err = conn.GetCompleteAnswer()
|
||||
if err == nil {
|
||||
answer, err = syncCanditates(answer)
|
||||
}
|
||||
log.Trace().Msgf("[webrtc] answer\n%s", answer)
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func IsConsumer(conn *webrtc.Conn) bool {
|
||||
// if wants get video - consumer
|
||||
for _, media := range conn.GetMedias() {
|
||||
if media.Kind == core.KindVideo && media.Direction == core.DirectionSendonly {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// if wants send video - producer
|
||||
for _, media := range conn.GetMedias() {
|
||||
if media.Kind == core.KindVideo && media.Direction == core.DirectionRecvonly {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// if wants something - consumer
|
||||
for _, media := range conn.GetMedias() {
|
||||
if media.Direction == core.DirectionSendonly {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
210
internal/webrtc/server.go
Normal file
210
internal/webrtc/server.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webrtc"
|
||||
pion "github.com/pion/webrtc/v3"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const MimeSDP = "application/sdp"
|
||||
|
||||
var sessions = map[string]*webrtc.Conn{}
|
||||
|
||||
func syncHandler(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "POST":
|
||||
query := r.URL.Query()
|
||||
if query.Get("src") != "" {
|
||||
// WHEP or JSON SDP or raw SDP exchange
|
||||
outputWebRTC(w, r)
|
||||
} else if query.Get("dst") != "" {
|
||||
// WHIP SDP exchange
|
||||
inputWebRTC(w, r)
|
||||
} else {
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
case "PATCH":
|
||||
// TODO: WHEP/WHIP
|
||||
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||
|
||||
case "DELETE":
|
||||
if id := r.URL.Query().Get("id"); id != "" {
|
||||
if conn, ok := sessions[id]; ok {
|
||||
delete(sessions, id)
|
||||
_ = conn.Close()
|
||||
} else {
|
||||
http.Error(w, "", http.StatusNotFound)
|
||||
}
|
||||
} else {
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
default:
|
||||
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// outputWebRTC support API depending on Content-Type:
|
||||
// 1. application/json - receive {"type":"offer","sdp":"v=0\r\n..."} and response {"type":"answer","sdp":"v=0\r\n..."}
|
||||
// 2. application/sdp - receive/response SDP via WebRTC-HTTP Egress Protocol (WHEP)
|
||||
// 3. other - receive/response raw SDP
|
||||
func outputWebRTC(w http.ResponseWriter, r *http.Request) {
|
||||
url := r.URL.Query().Get("src")
|
||||
stream := streams.Get(url)
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
|
||||
mediaType := r.Header.Get("Content-Type")
|
||||
if mediaType != "" {
|
||||
mediaType, _, _ = strings.Cut(mediaType, ";")
|
||||
mediaType = strings.ToLower(strings.TrimSpace(mediaType))
|
||||
}
|
||||
|
||||
var offer string
|
||||
|
||||
switch mediaType {
|
||||
case "application/json":
|
||||
var desc pion.SessionDescription
|
||||
if err := json.NewDecoder(r.Body).Decode(&desc); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
offer = desc.SDP
|
||||
|
||||
default:
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
offer = string(body)
|
||||
}
|
||||
|
||||
var desc string
|
||||
|
||||
switch mediaType {
|
||||
case "application/json":
|
||||
desc = "WebRTC/JSON sync"
|
||||
case MimeSDP:
|
||||
desc = "WebRTC/WHEP sync"
|
||||
default:
|
||||
desc = "WebRTC/HTTP sync"
|
||||
}
|
||||
|
||||
answer, err := ExchangeSDP(stream, offer, desc, r.UserAgent())
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
switch mediaType {
|
||||
case "application/json":
|
||||
w.Header().Set("Content-Type", mediaType)
|
||||
|
||||
v := pion.SessionDescription{
|
||||
Type: pion.SDPTypeAnswer, SDP: answer,
|
||||
}
|
||||
err = json.NewEncoder(w).Encode(v)
|
||||
|
||||
case MimeSDP:
|
||||
w.Header().Set("Content-Type", mediaType)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
|
||||
_, err = w.Write([]byte(answer))
|
||||
|
||||
default:
|
||||
_, err = w.Write([]byte(answer))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func inputWebRTC(w http.ResponseWriter, r *http.Request) {
|
||||
dst := r.URL.Query().Get("dst")
|
||||
stream := streams.Get(dst)
|
||||
if stream == nil {
|
||||
stream = streams.New(dst, nil)
|
||||
}
|
||||
|
||||
// 1. Get offer
|
||||
offer, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Trace().Msgf("[webrtc] WHIP offer\n%s", offer)
|
||||
|
||||
pc, err := PeerConnection(false)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// create new webrtc instance
|
||||
prod := webrtc.NewConn(pc)
|
||||
prod.Desc = "WebRTC/WHIP sync"
|
||||
prod.Mode = core.ModePassiveProducer
|
||||
prod.UserAgent = r.UserAgent()
|
||||
|
||||
if err = prod.SetOffer(string(offer)); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
answer, err := prod.GetCompleteAnswer()
|
||||
if err == nil {
|
||||
answer, err = syncCanditates(answer)
|
||||
}
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Trace().Msgf("[webrtc] WHIP answer\n%s", answer)
|
||||
|
||||
id := strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
sessions[id] = prod
|
||||
|
||||
prod.Listen(func(msg any) {
|
||||
switch msg := msg.(type) {
|
||||
case pion.PeerConnectionState:
|
||||
if msg == pion.PeerConnectionStateClosed {
|
||||
stream.RemoveProducer(prod)
|
||||
if _, ok := sessions[id]; ok {
|
||||
delete(sessions, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
stream.AddProducer(prod)
|
||||
|
||||
w.Header().Set("Content-Type", MimeSDP)
|
||||
w.Header().Set("Location", "webrtc?id="+id)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
|
||||
if _, err = w.Write([]byte(answer)); err != nil {
|
||||
log.Warn().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
}
|
175
internal/webtorrent/init.go
Normal file
175
internal/webtorrent/init.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package webtorrent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/internal/webrtc"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webtorrent"
|
||||
"github.com/rs/zerolog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
var cfg struct {
|
||||
Mod struct {
|
||||
Trackers []string `yaml:"trackers"`
|
||||
Shares map[string]struct {
|
||||
Pwd string `yaml:"pwd"`
|
||||
Src string `yaml:"src"`
|
||||
} `yaml:"shares"`
|
||||
} `yaml:"webtorrent"`
|
||||
}
|
||||
|
||||
cfg.Mod.Trackers = []string{"wss://tracker.openwebtorrent.com"}
|
||||
|
||||
app.LoadConfig(&cfg)
|
||||
|
||||
if len(cfg.Mod.Trackers) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
log = app.GetLogger("webtorrent")
|
||||
|
||||
streams.HandleFunc("webtorrent", streamHandle)
|
||||
|
||||
api.HandleFunc("api/webtorrent", apiHandle)
|
||||
|
||||
srv = &webtorrent.Server{
|
||||
URL: cfg.Mod.Trackers[0],
|
||||
Exchange: func(src, offer string) (answer string, err error) {
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
return "", errors.New(api.StreamNotFound)
|
||||
}
|
||||
return webrtc.ExchangeSDP(stream, offer, "WebRTC/WebTorrent sync", "")
|
||||
},
|
||||
}
|
||||
|
||||
if log.Debug().Enabled() {
|
||||
srv.Listen(func(msg any) {
|
||||
switch msg.(type) {
|
||||
case string, error:
|
||||
log.Debug().Msgf("[webtorrent] %s", msg)
|
||||
case *webtorrent.Message:
|
||||
log.Trace().Any("msg", msg).Msgf("[webtorrent]")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for name, share := range cfg.Mod.Shares {
|
||||
if len(name) < 8 {
|
||||
log.Warn().Str("name", name).Msgf("min share name len - 8 symbols")
|
||||
continue
|
||||
}
|
||||
if len(share.Pwd) < 4 {
|
||||
log.Warn().Str("name", name).Str("pwd", share.Pwd).Msgf("min share pwd len - 4 symbols")
|
||||
continue
|
||||
}
|
||||
if streams.Get(share.Src) == nil {
|
||||
log.Warn().Str("stream", share.Src).Msgf("stream not exists")
|
||||
continue
|
||||
}
|
||||
|
||||
srv.AddShare(name, share.Pwd, share.Src)
|
||||
|
||||
// adds to GET /api/webtorrent
|
||||
shares[name] = name
|
||||
}
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
|
||||
var shares = map[string]string{}
|
||||
var srv *webtorrent.Server
|
||||
|
||||
func apiHandle(w http.ResponseWriter, r *http.Request) {
|
||||
src := r.URL.Query().Get("src")
|
||||
share, ok := shares[src]
|
||||
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
// support act as WebTorrent tracker (for testing purposes)
|
||||
if r.Header.Get("Connection") == "Upgrade" {
|
||||
tracker(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if src != "" {
|
||||
// response one share
|
||||
if ok {
|
||||
pwd := srv.GetSharePwd(share)
|
||||
data := fmt.Sprintf(`{"share":%q,"pwd":%q}`, share, pwd)
|
||||
_, _ = w.Write([]byte(data))
|
||||
} else {
|
||||
http.Error(w, "", http.StatusNotFound)
|
||||
}
|
||||
} else {
|
||||
// response all shares
|
||||
var items []api.Stream
|
||||
for src, share := range shares {
|
||||
pwd := srv.GetSharePwd(share)
|
||||
source := fmt.Sprintf("webtorrent:?share=%s&pwd=%s", share, pwd)
|
||||
items = append(items, api.Stream{Name: src, URL: source})
|
||||
}
|
||||
api.ResponseStreams(w, items)
|
||||
}
|
||||
|
||||
case "POST":
|
||||
// check if share already exist
|
||||
if ok {
|
||||
http.Error(w, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// check if stream exists
|
||||
if stream := streams.Get(src); stream == nil {
|
||||
http.Error(w, "", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// create new random share
|
||||
share = core.RandString(10, 62)
|
||||
pwd := core.RandString(10, 62)
|
||||
srv.AddShare(share, pwd, src)
|
||||
|
||||
shares[src] = share
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
data := fmt.Sprintf(`{"share":%q,"pwd":%q}`, share, pwd)
|
||||
_, _ = w.Write([]byte(data))
|
||||
|
||||
case "DELETE":
|
||||
if ok {
|
||||
srv.RemoveShare(share)
|
||||
delete(shares, src)
|
||||
} else {
|
||||
http.Error(w, "", http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func streamHandle(rawURL string) (core.Producer, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
share := query.Get("share")
|
||||
pwd := query.Get("pwd")
|
||||
if len(share) < 8 || len(pwd) < 4 {
|
||||
return nil, errors.New("wrong URL: " + rawURL)
|
||||
}
|
||||
|
||||
pc, err := webrtc.PeerConnection(true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return webtorrent.NewClient(srv.URL, share, pwd, pc)
|
||||
}
|
107
internal/webtorrent/tracker.go
Normal file
107
internal/webtorrent/tracker.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package webtorrent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webtorrent"
|
||||
"github.com/gorilla/websocket"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var upgrader *websocket.Upgrader
|
||||
var hashes map[string]map[string]*websocket.Conn
|
||||
|
||||
func tracker(w http.ResponseWriter, r *http.Request) {
|
||||
if upgrader == nil {
|
||||
upgrader = &websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 2028,
|
||||
}
|
||||
upgrader.CheckOrigin = func(r *http.Request) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
ws, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Send()
|
||||
return
|
||||
}
|
||||
|
||||
defer ws.Close()
|
||||
|
||||
for {
|
||||
var msg webtorrent.Message
|
||||
if err = ws.ReadJSON(&msg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
//log.Trace().Msgf("[webtorrent] message=%v", msg)
|
||||
|
||||
if msg.InfoHash == "" || msg.PeerId == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if hashes == nil {
|
||||
hashes = map[string]map[string]*websocket.Conn{}
|
||||
}
|
||||
|
||||
// new or old client with offers
|
||||
clients := hashes[msg.InfoHash]
|
||||
if clients == nil {
|
||||
clients = map[string]*websocket.Conn{
|
||||
msg.PeerId: ws,
|
||||
}
|
||||
hashes[msg.InfoHash] = clients
|
||||
} else {
|
||||
clients[msg.PeerId] = ws
|
||||
}
|
||||
|
||||
switch {
|
||||
case msg.Offers != nil:
|
||||
// ask for ping
|
||||
raw := fmt.Sprintf(
|
||||
`{"action":"announce","interval":120,"info_hash":"%s","complete":0,"incomplete":1}`,
|
||||
msg.InfoHash,
|
||||
)
|
||||
if err = ws.WriteMessage(websocket.TextMessage, []byte(raw)); err != nil {
|
||||
log.Warn().Err(err).Send()
|
||||
return
|
||||
}
|
||||
|
||||
// skip if no offers (server)
|
||||
if len(msg.Offers) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// get and check only first offer
|
||||
offer := msg.Offers[0]
|
||||
if offer.OfferId == "" || offer.Offer.Type != "offer" || offer.Offer.SDP == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// send offer to all clients (one of them - server)
|
||||
raw = fmt.Sprintf(
|
||||
`{"action":"announce","info_hash":"%s","peer_id":"%s","offer_id":"%s","offer":{"type":"offer","sdp":"%s"}}`,
|
||||
msg.InfoHash, msg.PeerId, offer.OfferId, offer.Offer.SDP,
|
||||
)
|
||||
|
||||
for _, server := range clients {
|
||||
if server != ws {
|
||||
_ = server.WriteMessage(websocket.TextMessage, []byte(raw))
|
||||
}
|
||||
}
|
||||
|
||||
case msg.OfferId != "" && msg.ToPeerId != "" && msg.Answer != nil:
|
||||
ws1, ok := clients[msg.ToPeerId]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
raw := fmt.Sprintf(
|
||||
`{"action":"announce","info_hash":"%s","peer_id":"%s","offer_id":"%s","answer":{"type":"answer","sdp":"%s"}}`,
|
||||
msg.InfoHash, msg.PeerId, msg.OfferId, msg.Answer.SDP,
|
||||
)
|
||||
_ = ws1.WriteMessage(websocket.TextMessage, []byte(raw))
|
||||
}
|
||||
}
|
||||
}
|
61
main.go
61
main.go
@@ -1,20 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/cmd/api"
|
||||
"github.com/AlexxIT/go2rtc/cmd/app"
|
||||
"github.com/AlexxIT/go2rtc/cmd/debug"
|
||||
"github.com/AlexxIT/go2rtc/cmd/exec"
|
||||
"github.com/AlexxIT/go2rtc/cmd/ffmpeg"
|
||||
"github.com/AlexxIT/go2rtc/cmd/hass"
|
||||
"github.com/AlexxIT/go2rtc/cmd/homekit"
|
||||
"github.com/AlexxIT/go2rtc/cmd/mse"
|
||||
"github.com/AlexxIT/go2rtc/cmd/ngrok"
|
||||
"github.com/AlexxIT/go2rtc/cmd/rtmp"
|
||||
"github.com/AlexxIT/go2rtc/cmd/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/cmd/srtp"
|
||||
"github.com/AlexxIT/go2rtc/cmd/streams"
|
||||
"github.com/AlexxIT/go2rtc/cmd/webrtc"
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/debug"
|
||||
"github.com/AlexxIT/go2rtc/internal/dvrip"
|
||||
"github.com/AlexxIT/go2rtc/internal/echo"
|
||||
"github.com/AlexxIT/go2rtc/internal/exec"
|
||||
"github.com/AlexxIT/go2rtc/internal/ffmpeg"
|
||||
"github.com/AlexxIT/go2rtc/internal/hass"
|
||||
"github.com/AlexxIT/go2rtc/internal/hls"
|
||||
"github.com/AlexxIT/go2rtc/internal/homekit"
|
||||
"github.com/AlexxIT/go2rtc/internal/http"
|
||||
"github.com/AlexxIT/go2rtc/internal/isapi"
|
||||
"github.com/AlexxIT/go2rtc/internal/ivideon"
|
||||
"github.com/AlexxIT/go2rtc/internal/mjpeg"
|
||||
"github.com/AlexxIT/go2rtc/internal/mp4"
|
||||
"github.com/AlexxIT/go2rtc/internal/mpegts"
|
||||
"github.com/AlexxIT/go2rtc/internal/ngrok"
|
||||
"github.com/AlexxIT/go2rtc/internal/onvif"
|
||||
"github.com/AlexxIT/go2rtc/internal/roborock"
|
||||
"github.com/AlexxIT/go2rtc/internal/rtmp"
|
||||
"github.com/AlexxIT/go2rtc/internal/rtsp"
|
||||
"github.com/AlexxIT/go2rtc/internal/srtp"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/internal/tapo"
|
||||
"github.com/AlexxIT/go2rtc/internal/webrtc"
|
||||
"github.com/AlexxIT/go2rtc/internal/webtorrent"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
@@ -22,22 +34,33 @@ import (
|
||||
|
||||
func main() {
|
||||
app.Init() // init config and logs
|
||||
api.Init() // init HTTP API server
|
||||
streams.Init() // load streams list
|
||||
onvif.Init()
|
||||
|
||||
rtsp.Init() // add support RTSP client and RTSP server
|
||||
rtmp.Init() // add support RTMP client
|
||||
exec.Init() // add support exec scheme (depends on RTSP server)
|
||||
ffmpeg.Init() // add support ffmpeg scheme (depends on exec scheme)
|
||||
hass.Init() // add support hass scheme
|
||||
|
||||
api.Init() // init HTTP API server
|
||||
|
||||
webrtc.Init()
|
||||
mse.Init()
|
||||
echo.Init()
|
||||
ivideon.Init()
|
||||
http.Init()
|
||||
dvrip.Init()
|
||||
tapo.Init()
|
||||
isapi.Init()
|
||||
mpegts.Init()
|
||||
roborock.Init()
|
||||
|
||||
srtp.Init()
|
||||
homekit.Init()
|
||||
|
||||
webrtc.Init()
|
||||
mp4.Init()
|
||||
hls.Init()
|
||||
mjpeg.Init()
|
||||
|
||||
webtorrent.Init()
|
||||
ngrok.Init()
|
||||
debug.Init()
|
||||
|
||||
|
8
pkg/README.md
Normal file
8
pkg/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
## Useful links
|
||||
|
||||
- https://www.wowza.com/blog/streaming-protocols
|
||||
- https://vimeo.com/blog/post/rtmp-stream/
|
||||
- https://sanjeev-pandey.medium.com/understanding-the-mpeg-4-moov-atom-pseudo-streaming-in-mp4-93935e1b9e9a
|
||||
- [Android Supported media formats](https://developer.android.com/guide/topics/media/media-formats)
|
||||
- [THEOplayer](https://www.theoplayer.com/test-your-stream-hls-dash-hesp)
|
||||
- [How Generate DTS/PTS](https://www.ramugedia.com/how-generate-dts-pts-from-elementary-stream)
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user