Compare commits
535 Commits
develop
...
gek/initia
Author | SHA1 | Date |
---|---|---|
|
503d30cb44 | 2 years ago |
|
2e5f14a6da | 2 years ago |
|
7ab2ddfd7a | 2 years ago |
|
7a0e5a2736 | 2 years ago |
|
7ce6bbed53 | 2 years ago |
|
c2c7286673 | 2 years ago |
|
21552de0f9 | 2 years ago |
|
d99234b7d4 | 2 years ago |
|
d0d3b84afa | 2 years ago |
|
8779cd502f | 2 years ago |
|
9f276fcaa6 | 2 years ago |
|
468b7376af | 2 years ago |
|
b2d266d2ad | 2 years ago |
|
908c515a5a | 2 years ago |
|
1f9c409f60 | 2 years ago |
|
d0b307f260 | 2 years ago |
|
7a90320137 | 2 years ago |
|
481e601731 | 2 years ago |
|
8322b5057e | 2 years ago |
|
8c7358e14c | 2 years ago |
|
be4629b127 | 2 years ago |
|
5fcbe9daef | 2 years ago |
|
613bae6201 | 2 years ago |
|
97d62513ac | 2 years ago |
|
b267288b05 | 2 years ago |
|
442d57f522 | 2 years ago |
|
b62de83082 | 2 years ago |
|
41b0116ca6 | 2 years ago |
|
7f0250146d | 2 years ago |
|
35b4a9b500 | 2 years ago |
|
19f99a6e34 | 2 years ago |
|
b765509dd9 | 2 years ago |
|
8a85defedb | 2 years ago |
|
c289078629 | 2 years ago |
|
983d7424ea | 2 years ago |
|
d4a830a83f | 2 years ago |
|
8cde247244 | 2 years ago |
|
9934ed2bd3 | 2 years ago |
|
b88ac8a2c6 | 2 years ago |
|
638bf7b466 | 2 years ago |
|
e8688dd26a | 2 years ago |
|
9a647f07c0 | 2 years ago |
|
890ca4b31f | 2 years ago |
|
0dd5bfc94f | 2 years ago |
|
703eff5a24 | 2 years ago |
|
9046dec692 | 2 years ago |
|
13713e2e4e | 2 years ago |
|
1542618843 | 2 years ago |
|
965fd47faa | 2 years ago |
|
2bd0be67c8 | 2 years ago |
|
a153cf1314 | 2 years ago |
|
584a42df7d | 2 years ago |
|
61f2f159ac | 2 years ago |
|
772a87db70 | 2 years ago |
|
1da0828aa3 | 2 years ago |
|
0f33546804 | 2 years ago |
|
be53ad935d | 2 years ago |
|
6707c481f3 | 2 years ago |
|
a21fbda241 | 2 years ago |
|
3ef5ed9d5c | 2 years ago |
|
acf2d5ed89 | 2 years ago |
|
ee9e0fd669 | 2 years ago |
|
9fc1697f7e | 2 years ago |
|
edd9434aa6 | 2 years ago |
|
de2f8974ab | 2 years ago |
|
1082d5eaae | 2 years ago |
|
7b9ca72f77 | 2 years ago |
|
dd4bb8385f | 2 years ago |
|
e87983196e | 2 years ago |
|
4318d95311 | 2 years ago |
|
990e7992e6 | 2 years ago |
|
642f9cbaf6 | 2 years ago |
|
fd48b677d5 | 2 years ago |
|
c9396f7aa1 | 2 years ago |
|
b7eb272533 | 2 years ago |
|
4954086177 | 2 years ago |
|
2a60e228c4 | 2 years ago |
|
a7f03d7bc4 | 2 years ago |
|
77bf57c10f | 2 years ago |
|
6c9366b481 | 2 years ago |
|
3ee37c2a3b | 2 years ago |
|
7997f760b8 | 2 years ago |
|
724d7a5068 | 2 years ago |
|
cd0292e809 | 2 years ago |
|
0e7a45940a | 2 years ago |
|
5b4264199d | 2 years ago |
|
8add5afa74 | 2 years ago |
|
0860ffa46a | 2 years ago |
|
880c2f207f | 2 years ago |
|
46e0db59bb | 2 years ago |
|
2e671cb75a | 2 years ago |
|
31e7a0ee50 | 2 years ago |
|
b3128bcf63 | 2 years ago |
|
224761ab2f | 2 years ago |
|
97d22a3157 | 2 years ago |
|
6f009f293f | 2 years ago |
|
9bc7d1c58c | 2 years ago |
|
7a2151188d | 2 years ago |
|
a5f1982e85 | 2 years ago |
|
446d425ccd | 2 years ago |
|
1d82a380a3 | 2 years ago |
|
3add568613 | 2 years ago |
|
6cdeb679f2 | 2 years ago |
|
d307a4b47d | 2 years ago |
|
f1a9aadece | 2 years ago |
|
2a2579e618 | 2 years ago |
|
830bbe26f2 | 2 years ago |
|
92bf2487c1 | 2 years ago |
|
b7fa7ffc31 | 2 years ago |
|
819cbd2742 | 2 years ago |
|
2c1b06c387 | 2 years ago |
|
5bb5536398 | 2 years ago |
|
a4b14ab59d | 2 years ago |
|
e5a0116bb5 | 2 years ago |
|
f5fe1b0353 | 2 years ago |
|
0a14abfa85 | 2 years ago |
|
70ee29cad3 | 2 years ago |
|
9a549650f1 | 2 years ago |
|
6f1091eb9e | 2 years ago |
|
cbf282d372 | 2 years ago |
|
68a00fbbbb | 2 years ago |
|
062d1830d5 | 2 years ago |
|
aa870e020a | 2 years ago |
|
f97c35dbfd | 2 years ago |
|
a2931888b3 | 2 years ago |
|
25fdab6b6f | 2 years ago |
|
7c3c3dde45 | 2 years ago |
|
19100ff68b | 2 years ago |
|
fda82ee252 | 2 years ago |
|
e25fd78517 | 2 years ago |
|
662d1710e0 | 2 years ago |
|
a147f4a28c | 2 years ago |
|
892b498475 | 2 years ago |
|
7e9a0cf388 | 2 years ago |
|
691b53cb95 | 2 years ago |
|
9cdfa53801 | 2 years ago |
|
0cf6e28f02 | 2 years ago |
|
88e3c2460f | 2 years ago |
|
088c17e91d | 2 years ago |
|
bb87d0abd0 | 2 years ago |
|
c66c8f79cd | 2 years ago |
|
ba409e016f | 2 years ago |
|
70505f10b1 | 2 years ago |
|
c55a16f173 | 2 years ago |
|
c43b9f6e89 | 2 years ago |
|
7bfa59e23b | 2 years ago |
|
e1f15de101 | 2 years ago |
|
96c6f52763 | 2 years ago |
|
04394db1db | 2 years ago |
|
f01ba6725a | 2 years ago |
|
b1950b3d23 | 2 years ago |
|
ce485ccdc7 | 2 years ago |
|
9db9a24d58 | 2 years ago |
|
c754778a6c | 2 years ago |
|
3291567179 | 2 years ago |
|
e6c6947e3b | 2 years ago |
|
e310d5abed | 2 years ago |
|
2101919590 | 2 years ago |
|
646e33cfb9 | 2 years ago |
|
137d519199 | 2 years ago |
|
91f1f511ec | 2 years ago |
|
70988f4457 | 2 years ago |
|
833aca7805 | 2 years ago |
|
e23cdee794 | 2 years ago |
|
1fe3add5aa | 2 years ago |
|
4eeeca3936 | 2 years ago |
|
23f211b070 | 2 years ago |
|
4bbd23afc5 | 2 years ago |
|
41ec63844f | 2 years ago |
|
c2926fe28d | 2 years ago |
|
6483910b72 | 2 years ago |
|
75bc588ab4 | 2 years ago |
|
755290eb93 | 2 years ago |
|
2a4da3271c | 2 years ago |
|
13d0ffadc3 | 2 years ago |
|
a79901c8a0 | 2 years ago |
|
fee5e4e2ab | 2 years ago |
|
acc01e5bfe | 2 years ago |
|
1ddd51a76a | 2 years ago |
|
62b5fae837 | 2 years ago |
|
d42a705d33 | 2 years ago |
|
cfe3cfb5b4 | 2 years ago |
|
0cf77a1faa | 2 years ago |
|
9363166614 | 2 years ago |
|
105766049f | 2 years ago |
|
333d79221d | 2 years ago |
|
b5e4001f78 | 2 years ago |
|
b006c74b73 | 2 years ago |
|
a4aec200dc | 2 years ago |
|
cb702944a3 | 2 years ago |
|
1c671fda35 | 2 years ago |
|
208bca1d56 | 2 years ago |
|
466c20e9d1 | 2 years ago |
|
f1ee3c9dd6 | 2 years ago |
|
c16b9cbe27 | 2 years ago |
|
9736a23fb4 | 2 years ago |
|
482d3286a3 | 2 years ago |
|
fbb18d2097 | 2 years ago |
|
7da6721604 | 2 years ago |
|
4e7d137d7d | 2 years ago |
|
66e4159ec3 | 2 years ago |
|
f8871624a4 | 2 years ago |
|
62ce0f2c3b | 2 years ago |
|
af07fdc4e1 | 2 years ago |
|
a1fa06a410 | 2 years ago |
|
9415211c97 | 2 years ago |
|
9058042c08 | 2 years ago |
|
dcf22215f1 | 2 years ago |
|
ec8a27e316 | 2 years ago |
|
e15a3a4657 | 2 years ago |
|
f788e07989 | 2 years ago |
|
6f59070b5b | 2 years ago |
|
8e36363cdf | 2 years ago |
|
0cace98825 | 2 years ago |
|
c1b29452bd | 2 years ago |
|
ba041c48ed | 2 years ago |
|
9a5ed23cbc | 2 years ago |
|
75c1b17d12 | 2 years ago |
|
42bf334c83 | 2 years ago |
|
1450f70e05 | 2 years ago |
|
7d5a8455aa | 2 years ago |
|
3e72f3793d | 2 years ago |
|
e76d542f43 | 2 years ago |
|
3ba1d551e5 | 2 years ago |
|
eadb9df489 | 2 years ago |
|
2b378d3a9c | 2 years ago |
|
892f4cc6ec | 2 years ago |
|
e0779f62df | 2 years ago |
|
016334f18c | 2 years ago |
|
3eb7dbd6dd | 2 years ago |
|
a7b6fe285b | 2 years ago |
|
e9d23bc5c3 | 2 years ago |
|
0e3ae5a6a0 | 2 years ago |
|
0ad77fa8e8 | 2 years ago |
|
c2995b03cc | 2 years ago |
|
e89e433c71 | 2 years ago |
|
90df637210 | 2 years ago |
|
b183819d03 | 2 years ago |
|
11d2c56eb7 | 2 years ago |
|
5920a7cdd9 | 2 years ago |
|
08b3a07213 | 2 years ago |
|
a2ee4e2e00 | 2 years ago |
|
28881c9157 | 2 years ago |
|
67e4d4b184 | 2 years ago |
|
12bff5a1c0 | 2 years ago |
|
0d5ae476a0 | 2 years ago |
|
193f2767eb | 2 years ago |
|
7a26f39b08 | 2 years ago |
|
9a6f97f8c9 | 2 years ago |
|
31a8e2ce53 | 2 years ago |
|
fd47475c11 | 2 years ago |
|
86e880a46d | 2 years ago |
|
e3384eb6db | 2 years ago |
|
5ada2bf240 | 2 years ago |
|
8e96b0c496 | 2 years ago |
|
ebdd9b030b | 2 years ago |
|
216ddf6dbb | 2 years ago |
|
c7a9e660d5 | 2 years ago |
|
c499862463 | 2 years ago |
|
2404223173 | 2 years ago |
|
b68226f964 | 2 years ago |
|
e820f9acce | 2 years ago |
|
9c8e85b784 | 2 years ago |
|
e947229797 | 2 years ago |
|
8cbb1a83b7 | 2 years ago |
|
49ae88ba4d | 2 years ago |
|
fc7f63af66 | 2 years ago |
|
933cf1fab1 | 2 years ago |
|
3076161d59 | 2 years ago |
|
ef04c1fd86 | 2 years ago |
|
e4a7a337a3 | 2 years ago |
|
b252f0cd82 | 2 years ago |
|
a941a38377 | 2 years ago |
|
d280352895 | 2 years ago |
|
18ca933f62 | 2 years ago |
|
42f0e4315c | 2 years ago |
|
2b5171349e | 2 years ago |
|
0f65afaba6 | 2 years ago |
|
d47dfa5823 | 2 years ago |
|
92eeaca968 | 2 years ago |
|
a86a54717f | 2 years ago |
|
a29549afb5 | 2 years ago |
|
1b021f6f3d | 2 years ago |
|
6acbe6ab17 | 2 years ago |
|
59ef3a532b | 2 years ago |
|
c56906a754 | 2 years ago |
|
80e919d16f | 2 years ago |
|
dd1417b735 | 2 years ago |
|
e3106a5e92 | 2 years ago |
|
1216af9a21 | 2 years ago |
|
602681ac39 | 2 years ago |
|
531e9fe2de | 2 years ago |
|
2d26d43c59 | 2 years ago |
|
0ddc812120 | 2 years ago |
|
5a8ae659ae | 2 years ago |
|
0cb71c4099 | 2 years ago |
|
ea50b4a13f | 2 years ago |
|
27b6df3592 | 2 years ago |
|
18afea5770 | 2 years ago |
|
9d32214b8b | 2 years ago |
|
fabcccdcd8 | 2 years ago |
|
f19b3ea050 | 2 years ago |
|
0ef1d54cc8 | 2 years ago |
|
d4b01b1555 | 2 years ago |
|
b60f74b947 | 2 years ago |
|
8208f9f237 | 2 years ago |
|
bd53752192 | 2 years ago |
|
42a70b587d | 2 years ago |
|
2f5cb55c89 | 2 years ago |
|
18de983d07 | 2 years ago |
|
1d3d40b85e | 2 years ago |
|
f6026e27b4 | 2 years ago |
|
ba4f8fabe9 | 2 years ago |
|
0562bdf3ee | 2 years ago |
|
aeddf0b073 | 2 years ago |
|
e74c22fb63 | 2 years ago |
|
6ad0c78d5c | 2 years ago |
|
d356c8dc3e | 2 years ago |
|
f01a5d962a | 2 years ago |
|
cbbc219091 | 2 years ago |
|
c815b8ebf4 | 2 years ago |
|
eb28ceca54 | 2 years ago |
|
b5a894cff8 | 2 years ago |
|
326e693d88 | 2 years ago |
|
3a98c3c10e | 2 years ago |
|
cd451a8d11 | 2 years ago |
|
4b67e90a9b | 2 years ago |
|
81ee8327dc | 2 years ago |
|
844e94710e | 2 years ago |
|
33b5580467 | 2 years ago |
|
092d2ae5c0 | 2 years ago |
|
3f09db1445 | 2 years ago |
|
8d71ae4782 | 2 years ago |
|
60ead3fe95 | 2 years ago |
|
5fc18107f2 | 2 years ago |
|
b8fd7b1bb0 | 2 years ago |
|
f832beaf30 | 2 years ago |
|
c32b9018e2 | 2 years ago |
|
19cdfbd29f | 2 years ago |
|
b7ba3d529a | 2 years ago |
|
2cac4c549d | 2 years ago |
|
525e7ababf | 2 years ago |
|
34cdce3f71 | 2 years ago |
|
541b0981f0 | 2 years ago |
|
ac4bd0bad0 | 2 years ago |
|
947b83de1f | 2 years ago |
|
099cc02fe6 | 2 years ago |
|
9b89bf3fe7 | 2 years ago |
|
fe30150edd | 2 years ago |
|
0f77ac3989 | 2 years ago |
|
7b23f2b73a | 2 years ago |
|
d38039069e | 2 years ago |
|
afe5536bf1 | 2 years ago |
|
175f6f3f10 | 2 years ago |
|
ac1066547d | 2 years ago |
|
1a59707852 | 2 years ago |
|
dfd04ae83b | 2 years ago |
|
dd3814890f | 2 years ago |
|
768c090801 | 2 years ago |
|
b2f2fa93e4 | 2 years ago |
|
9e18fbf783 | 2 years ago |
|
dfe4f43de2 | 2 years ago |
|
6682ce3a44 | 2 years ago |
|
43573fceb3 | 2 years ago |
|
a4ef64e66d | 2 years ago |
|
4704b603b1 | 2 years ago |
|
ecfa957913 | 2 years ago |
|
eb485d5515 | 2 years ago |
|
5c83af36f0 | 2 years ago |
|
f7754f2ae9 | 2 years ago |
|
7ba1065dac | 2 years ago |
|
77c0a659c3 | 2 years ago |
|
02098529ee | 2 years ago |
|
a753c141ee | 2 years ago |
|
14c9a5a0eb | 2 years ago |
|
1bd57620e7 | 2 years ago |
|
e37df8056e | 2 years ago |
|
604957e972 | 2 years ago |
|
3b94388198 | 2 years ago |
|
3ccc989dd0 | 2 years ago |
|
8113862b18 | 2 years ago |
|
58971ab97c | 2 years ago |
|
259a53520c | 2 years ago |
|
fe753df120 | 2 years ago |
|
4d1332e9c8 | 2 years ago |
|
b7fbb7fd1f | 2 years ago |
|
70f3bade2f | 2 years ago |
|
bccd18822d | 2 years ago |
|
8f627c5060 | 2 years ago |
|
c6c7e6d32e | 2 years ago |
|
b9c5e2fe47 | 2 years ago |
|
be1e3d7661 | 2 years ago |
|
013f9c83bc | 2 years ago |
|
fdfad42afb | 2 years ago |
|
b218bad10f | 2 years ago |
|
6d67ae489c | 2 years ago |
|
73bc1c69f2 | 2 years ago |
|
5ea8a6f659 | 2 years ago |
|
b833f94e3e | 2 years ago |
|
5a0ef12eb2 | 2 years ago |
|
ce5b131aee | 2 years ago |
|
20ac8ccbd1 | 2 years ago |
|
5cf84a4368 | 2 years ago |
|
50de017dfc | 2 years ago |
|
3f478a8316 | 2 years ago |
|
476c8a354d | 2 years ago |
|
0146700701 | 2 years ago |
|
48459c1e4b | 2 years ago |
|
1713852ae1 | 2 years ago |
|
6c0864a74a | 2 years ago |
|
73806d7cd2 | 2 years ago |
|
0508fc7662 | 2 years ago |
|
5a7de87190 | 2 years ago |
|
755aa95b2b | 2 years ago |
|
c9f321b45d | 2 years ago |
|
377f206d15 | 2 years ago |
|
3f2eef3977 | 2 years ago |
|
56ea57144d | 2 years ago |
|
21db901ed8 | 2 years ago |
|
134cc6fc78 | 2 years ago |
|
12ff3458f6 | 2 years ago |
|
174d32adbd | 2 years ago |
|
d0bb5d2975 | 2 years ago |
|
220702f124 | 2 years ago |
|
81fa707def | 2 years ago |
|
bc237de7e1 | 2 years ago |
|
d49fa83716 | 2 years ago |
|
f399cb0fd8 | 2 years ago |
|
74d40646f6 | 2 years ago |
|
b2a4869590 | 2 years ago |
|
96aecc2cbe | 2 years ago |
|
2e45ec1a00 | 2 years ago |
|
2a2544b42e | 2 years ago |
|
2dc57bcf42 | 2 years ago |
|
c04618feb0 | 2 years ago |
|
80e05e2f60 | 2 years ago |
|
dbf65a2d7a | 2 years ago |
|
b7c6f4a81b | 2 years ago |
|
d05a92da3d | 2 years ago |
|
99ee7ea946 | 2 years ago |
|
c05ced0f55 | 2 years ago |
|
690fb26aaf | 2 years ago |
|
dcddd0c3a7 | 2 years ago |
|
c630419ab0 | 2 years ago |
|
68d631da0e | 2 years ago |
|
620aaea5ff | 2 years ago |
|
fc9563681e | 2 years ago |
|
dcf2240b8c | 2 years ago |
|
3e3d952982 | 2 years ago |
|
88eafc9501 | 2 years ago |
|
d2379c029f | 2 years ago |
|
af3e72ca8a | 2 years ago |
|
1d80f490b4 | 2 years ago |
|
c159e4a63c | 2 years ago |
|
f03c6f4b2f | 2 years ago |
|
befc1ec689 | 2 years ago |
|
4fc9247ade | 2 years ago |
|
4f4d1f153d | 2 years ago |
|
6e340e6d98 | 2 years ago |
|
9014b18ff1 | 2 years ago |
|
256caa481b | 2 years ago |
|
adfbd54457 | 2 years ago |
|
db97e4dfd6 | 2 years ago |
|
72d05bc964 | 2 years ago |
|
f68d90fadf | 2 years ago |
|
06576746e9 | 2 years ago |
|
7cc9636e75 | 2 years ago |
|
2db62608a7 | 2 years ago |
|
ebdaa7a657 | 2 years ago |
|
58310a3f37 | 2 years ago |
|
41277d48e3 | 2 years ago |
|
1851148b01 | 2 years ago |
|
827b3f6576 | 2 years ago |
|
d4aeb2d461 | 2 years ago |
|
b98f02216e | 2 years ago |
|
4cf0026915 | 2 years ago |
|
749fbf217d | 2 years ago |
|
9e30041d3c | 2 years ago |
|
bbb096a036 | 2 years ago |
|
259bd8b988 | 2 years ago |
|
7ebc9d9eb9 | 2 years ago |
|
7e6610b1a3 | 2 years ago |
|
4af4eec456 | 2 years ago |
|
71a21d5bd2 | 2 years ago |
|
fd3fd7167d | 2 years ago |
|
b049923a8a | 2 years ago |
|
a84d8444a4 | 2 years ago |
|
6587b53430 | 2 years ago |
|
05c95114ea | 2 years ago |
|
fc4bdb8c64 | 2 years ago |
|
bc162443db | 2 years ago |
|
e9f6da2669 | 2 years ago |
|
eb89499b83 | 2 years ago |
|
2ccc6d0cb3 | 2 years ago |
|
1e75f41f2a | 2 years ago |
|
7cc992bb2b | 2 years ago |
|
f982e83397 | 2 years ago |
|
981e99c2d6 | 2 years ago |
|
57eafd3361 | 2 years ago |
|
cd8567888f | 2 years ago |
|
c434377123 | 2 years ago |
|
294a0e592e | 2 years ago |
|
402ba7d4dc | 2 years ago |
|
84c2246621 | 2 years ago |
|
1a8c0ab67a | 2 years ago |
|
622b76a44c | 2 years ago |
|
f9b048f71b | 2 years ago |
|
6659eb1fc6 | 2 years ago |
|
5d1c7d0a41 | 2 years ago |
|
10bffb49fe | 2 years ago |
|
eda6839331 | 2 years ago |
|
92e328f9ca | 2 years ago |
|
812ee5e8fa | 2 years ago |
|
18ac45a12e | 2 years ago |
|
8711d9cc05 | 2 years ago |
|
b3a76feee5 | 2 years ago |
|
655ea319b9 | 2 years ago |
|
d00b435269 | 2 years ago |
|
ec2b5103e5 | 2 years ago |
|
d947c4b4a4 | 2 years ago |
|
8021c66869 | 2 years ago |
|
adb67e79b3 | 2 years ago |
|
ea376477b6 | 2 years ago |
|
065e779e99 | 2 years ago |
|
1da7dc92dd | 2 years ago |
|
66d4bb2321 | 2 years ago |
|
6aacd7bfce | 2 years ago |
|
5555dc1751 | 2 years ago |
|
96fb5ebb82 | 2 years ago |
|
e1b9c160c9 | 2 years ago |
|
4ba36c17a3 | 2 years ago |
|
08f8149b63 | 2 years ago |
|
9020c874f4 | 2 years ago |
|
f02f4fce88 | 2 years ago |
|
25710d5053 | 2 years ago |
57 changed files with 13757 additions and 182 deletions
@ -0,0 +1,167 @@
@@ -0,0 +1,167 @@
|
||||
package controllers |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"errors" |
||||
"fmt" |
||||
"net/http" |
||||
"strings" |
||||
|
||||
"github.com/owncast/owncast/config" |
||||
"github.com/owncast/owncast/replays" |
||||
log "github.com/sirupsen/logrus" |
||||
) |
||||
|
||||
// GetAllClips will return all clips that have been previously created.
|
||||
func GetAllClips(w http.ResponseWriter, r *http.Request) { |
||||
if !config.EnableReplayFeatures { |
||||
w.WriteHeader(http.StatusNotFound) |
||||
return |
||||
} |
||||
|
||||
clips, err := replays.GetAllClips() |
||||
if err != nil { |
||||
log.Errorln(err) |
||||
w.WriteHeader(http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
WriteResponse(w, clips) |
||||
} |
||||
|
||||
// AddClip will create a new clip for a given stream and time window.
|
||||
func AddClip(w http.ResponseWriter, r *http.Request) { |
||||
if !config.EnableReplayFeatures { |
||||
w.WriteHeader(http.StatusNotFound) |
||||
return |
||||
} |
||||
|
||||
type addClipRequest struct { |
||||
StreamId string `json:"streamId"` |
||||
ClipTitle string `json:"clipTitle"` |
||||
RelativeStartTimeSeconds float32 `json:"relativeStartTimeSeconds"` |
||||
RelativeEndTimeSeconds float32 `json:"relativeEndTimeSeconds"` |
||||
} |
||||
|
||||
if r.Method != http.MethodPost { |
||||
BadRequestHandler(w, nil) |
||||
return |
||||
} |
||||
|
||||
decoder := json.NewDecoder(r.Body) |
||||
var request addClipRequest |
||||
|
||||
if request.RelativeEndTimeSeconds < request.RelativeStartTimeSeconds { |
||||
BadRequestHandler(w, errors.New("end time must be after start time")) |
||||
return |
||||
} |
||||
|
||||
if err := decoder.Decode(&request); err != nil { |
||||
log.Errorln(err) |
||||
WriteSimpleResponse(w, false, "unable to create clip") |
||||
return |
||||
} |
||||
|
||||
streamId := request.StreamId |
||||
clipTitle := request.ClipTitle |
||||
startTime := request.RelativeStartTimeSeconds |
||||
endTime := request.RelativeEndTimeSeconds |
||||
|
||||
// Some validation
|
||||
playlistGenerator := replays.NewPlaylistGenerator() |
||||
|
||||
stream, err := playlistGenerator.GetStream(streamId) |
||||
if err != nil { |
||||
BadRequestHandler(w, errors.New("stream not found")) |
||||
return |
||||
} |
||||
|
||||
if stream.StartTime.IsZero() { |
||||
BadRequestHandler(w, errors.New("stream start time not found")) |
||||
return |
||||
} |
||||
|
||||
// Make sure the proposed clip start time and end time are within
|
||||
// the start and end time of the stream.
|
||||
finalSegment, err := replays.GetFinalSegmentForStream(streamId) |
||||
if err != nil { |
||||
InternalErrorHandler(w, err) |
||||
return |
||||
} |
||||
|
||||
if finalSegment.RelativeTimestamp < startTime { |
||||
BadRequestHandler(w, errors.New("start time is after the known end of the stream")) |
||||
return |
||||
} |
||||
|
||||
clipId, duration, err := replays.AddClipForStream(streamId, clipTitle, "", startTime, endTime) |
||||
if err != nil { |
||||
log.Errorln(err) |
||||
w.WriteHeader(http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
WriteSimpleResponse(w, true, "clip "+clipId+" created with duration of "+fmt.Sprint(duration)+" seconds") |
||||
} |
||||
|
||||
// GetClip will return playable content for a given clip Id.
|
||||
func GetClip(w http.ResponseWriter, r *http.Request) { |
||||
if !config.EnableReplayFeatures { |
||||
w.WriteHeader(http.StatusNotFound) |
||||
return |
||||
} |
||||
|
||||
pathComponents := strings.Split(r.URL.Path, "/") |
||||
if len(pathComponents) == 3 { |
||||
// Return the master playlist for the requested stream
|
||||
clipId := pathComponents[2] |
||||
getClipMasterPlaylist(clipId, w) |
||||
return |
||||
} else if len(pathComponents) == 4 { |
||||
// Return the media playlist for the requested stream and output config
|
||||
clipId := pathComponents[2] |
||||
outputConfigId := pathComponents[3] |
||||
getClipMediaPlaylist(clipId, outputConfigId, w) |
||||
return |
||||
} |
||||
|
||||
BadRequestHandler(w, nil) |
||||
} |
||||
|
||||
// getReplayMasterPlaylist will return a complete replay of a stream
|
||||
// as a HLS playlist.
|
||||
func getClipMasterPlaylist(clipId string, w http.ResponseWriter) { |
||||
playlistGenerator := replays.NewPlaylistGenerator() |
||||
playlist, err := playlistGenerator.GenerateMasterPlaylistForClip(clipId) |
||||
if err != nil { |
||||
log.Println(err) |
||||
} |
||||
|
||||
if playlist == nil { |
||||
w.WriteHeader(http.StatusNotFound) |
||||
return |
||||
} |
||||
|
||||
w.Header().Add("Content-Type", "application/x-mpegURL") |
||||
if _, err := w.Write(playlist.Encode().Bytes()); err != nil { |
||||
log.Errorln(err) |
||||
return |
||||
} |
||||
} |
||||
|
||||
// getClipMediaPlaylist will return media playlist for a given clip
|
||||
// and stream output configuration.
|
||||
func getClipMediaPlaylist(clipId, outputConfigId string, w http.ResponseWriter) { |
||||
playlistGenerator := replays.NewPlaylistGenerator() |
||||
playlist, err := playlistGenerator.GenerateMediaPlaylistForClipAndConfiguration(clipId, outputConfigId) |
||||
if err != nil { |
||||
w.WriteHeader(http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
w.Header().Add("Content-Type", "application/x-mpegURL") |
||||
if _, err := w.Write(playlist.Encode().Bytes()); err != nil { |
||||
log.Errorln(err) |
||||
return |
||||
} |
||||
} |
@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
|
||||
package controllers |
||||
|
||||
import ( |
||||
"net/http" |
||||
"strings" |
||||
|
||||
"github.com/owncast/owncast/config" |
||||
"github.com/owncast/owncast/replays" |
||||
log "github.com/sirupsen/logrus" |
||||
) |
||||
|
||||
// GetReplays will return a list of all available replays.
|
||||
func GetReplays(w http.ResponseWriter, r *http.Request) { |
||||
if !config.EnableReplayFeatures { |
||||
w.WriteHeader(http.StatusNotFound) |
||||
return |
||||
} |
||||
|
||||
streams, err := replays.GetStreams() |
||||
if err != nil { |
||||
log.Errorln(err) |
||||
w.WriteHeader(http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
WriteResponse(w, streams) |
||||
} |
||||
|
||||
// GetReplay will return a playable content for a given stream Id.
|
||||
func GetReplay(w http.ResponseWriter, r *http.Request) { |
||||
pathComponents := strings.Split(r.URL.Path, "/") |
||||
if len(pathComponents) == 3 { |
||||
// Return the master playlist for the requested stream
|
||||
streamId := pathComponents[2] |
||||
getReplayMasterPlaylist(streamId, w) |
||||
return |
||||
} else if len(pathComponents) == 4 { |
||||
// Return the media playlist for the requested stream and output config
|
||||
streamId := pathComponents[2] |
||||
outputConfigId := pathComponents[3] |
||||
getReplayMediaPlaylist(streamId, outputConfigId, w) |
||||
return |
||||
} |
||||
|
||||
BadRequestHandler(w, nil) |
||||
} |
||||
|
||||
// getReplayMasterPlaylist will return a complete replay of a stream as a HLS playlist.
|
||||
// /api/replay/{streamId}.
|
||||
func getReplayMasterPlaylist(streamId string, w http.ResponseWriter) { |
||||
playlistGenerator := replays.NewPlaylistGenerator() |
||||
playlist, err := playlistGenerator.GenerateMasterPlaylistForStream(streamId) |
||||
if err != nil { |
||||
log.Println(err) |
||||
} |
||||
|
||||
if playlist == nil { |
||||
w.WriteHeader(http.StatusNotFound) |
||||
return |
||||
} |
||||
|
||||
w.Header().Add("Content-Type", "application/x-mpegURL") |
||||
if _, err := w.Write(playlist.Encode().Bytes()); err != nil { |
||||
log.Errorln(err) |
||||
return |
||||
} |
||||
} |
||||
|
||||
// getReplayMediaPlaylist will return a media playlist for a given stream.
|
||||
// /api/replay/{streamId}/{outputConfigId}.
|
||||
func getReplayMediaPlaylist(streamId, outputConfigId string, w http.ResponseWriter) { |
||||
playlistGenerator := replays.NewPlaylistGenerator() |
||||
playlist, err := playlistGenerator.GenerateMediaPlaylistForStreamAndConfiguration(streamId, outputConfigId) |
||||
if err != nil { |
||||
w.WriteHeader(http.StatusInternalServerError) |
||||
return |
||||
} |
||||
|
||||
w.Header().Add("Content-Type", "application/x-mpegURL") |
||||
if _, err := w.Write(playlist.Encode().Bytes()); err != nil { |
||||
log.Errorln(err) |
||||
return |
||||
} |
||||
} |
@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
|
||||
package data |
||||
|
||||
import ( |
||||
"database/sql" |
||||
) |
||||
|
||||
func createRecordingTables(db *sql.DB) { |
||||
createSegmentsTableSQL := `CREATE TABLE IF NOT EXISTS video_segments ( |
||||
"id" string NOT NULL, |
||||
"stream_id" string NOT NULL, |
||||
"output_configuration_id" string NOT NULL, |
||||
"path" TEXT NOT NULL, |
||||
"relative_timestamp" REAL NOT NULL, |
||||
"timestamp" DATETIME, |
||||
PRIMARY KEY (id) |
||||
);CREATE INDEX video_segments_stream_id ON video_segments (stream_id);CREATE INDEX video_segments_stream_id_timestamp ON video_segments (stream_id,timestamp);` |
||||
|
||||
createVideoOutputConfigsTableSQL := `CREATE TABLE IF NOT EXISTS video_segment_output_configuration ( |
||||
"id" string NOT NULL, |
||||
"variant_id" string NOT NULL, |
||||
"name" string NOT NULL, |
||||
"stream_id" string NOT NULL, |
||||
"segment_duration" INTEGER NOT NULL, |
||||
"bitrate" INTEGER NOT NULL, |
||||
"framerate" INTEGER NOT NULL, |
||||
"resolution_width" INTEGER, |
||||
"resolution_height" INTEGER, |
||||
"timestamp" DATETIME, |
||||
PRIMARY KEY (id) |
||||
);CREATE INDEX video_segment_output_configuration_stream_id ON video_segment_output_configuration (stream_id);` |
||||
|
||||
createVideoStreamsTableSQL := `CREATE TABLE IF NOT EXISTS streams ( |
||||
"id" string NOT NULL, |
||||
"stream_title" TEXT, |
||||
"start_time" DATETIME, |
||||
"end_time" DATETIME, |
||||
PRIMARY KEY (id) |
||||
); |
||||
CREATE INDEX streams_id ON streams (id); |
||||
CREATE INDEX streams_start_time ON streams (start_time); |
||||
CREATE INDEX streams_start_end_time ON streams (start_time,end_time); |
||||
` |
||||
|
||||
createClipsTableSQL := `CREATE TABLE IF NOT EXISTS replay_clips ( |
||||
"id" string NOT NULL, |
||||
"stream_id" string NOT NULL, |
||||
"clipped_by" string, |
||||
"clip_title" TEXT, |
||||
"relative_start_time" REAL, |
||||
"relative_end_time" REAL, |
||||
"timestamp" DATETIME, |
||||
PRIMARY KEY (id), |
||||
FOREIGN KEY(stream_id) REFERENCES streams(id) |
||||
); |
||||
CREATE INDEX clip_id ON replay_clips (id); |
||||
CREATE INDEX clip_stream_id ON replay_clips (stream_id); |
||||
CREATE INDEX clip_start_end_time ON replay_clips (start_time,end_time); |
||||
` |
||||
|
||||
MustExec(createSegmentsTableSQL, db) |
||||
MustExec(createVideoOutputConfigsTableSQL, db) |
||||
MustExec(createVideoStreamsTableSQL, db) |
||||
MustExec(createClipsTableSQL, db) |
||||
} |
@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
|
||||
package core |
||||
|
||||
import ( |
||||
"io" |
||||
|
||||
"github.com/owncast/owncast/core/transcoder" |
||||
) |
||||
|
||||
func setupVideoComponentsForId(streamId string) { |
||||
} |
||||
|
||||
func setupLiveTranscoderForId(streamId string, rtmpOut *io.PipeReader) { |
||||
_storage.SetStreamId(streamId) |
||||
handler.SetStreamId(streamId) |
||||
|
||||
go func() { |
||||
_transcoder = transcoder.NewTranscoder(streamId) |
||||
_transcoder.TranscoderCompleted = func(error) { |
||||
SetStreamAsDisconnected() |
||||
_transcoder = nil |
||||
_currentBroadcast = nil |
||||
} |
||||
_transcoder.SetStdin(rtmpOut) |
||||
_transcoder.Start(true) |
||||
}() |
||||
} |
@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
|
||||
package models |
||||
|
||||
import ( |
||||
"database/sql" |
||||
"errors" |
||||
"time" |
||||
) |
||||
|
||||
type FlexibleDate struct { |
||||
time.Time |
||||
} |
||||
|
||||
func (self *FlexibleDate) UnmarshalJSON(b []byte) (err error) { |
||||
s := string(b) |
||||
|
||||
// Get rid of the quotes "" around the value.
|
||||
s = s[1 : len(s)-1] |
||||
|
||||
result, err := FlexibleDateParse(s) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
self.Time = result |
||||
|
||||
return |
||||
} |
||||
|
||||
// FlexibleDateParse is a convinience function to parse a date that could be
|
||||
// a string, a time.Time, or a sql.NullTime.
|
||||
func FlexibleDateParse(date interface{}) (time.Time, error) { |
||||
// If it's within a sql.NullTime wrapper, return the time from that.
|
||||
nulltime, ok := date.(sql.NullTime) |
||||
if ok { |
||||
return nulltime.Time, nil |
||||
} |
||||
|
||||
// Parse as string
|
||||
datestring, ok := date.(string) |
||||
if ok { |
||||
t, err := time.Parse(time.RFC3339Nano, datestring) |
||||
if err == nil { |
||||
return t, nil |
||||
} |
||||
|
||||
t, err = time.Parse("2006-01-02T15:04:05.999999999Z0700", datestring) |
||||
if err == nil { |
||||
return t, nil |
||||
} |
||||
|
||||
t, err = time.Parse("2006-01-02 15:04:05.999999999-07:00", datestring) |
||||
if err == nil { |
||||
return t, nil |
||||
} |
||||
} |
||||
|
||||
dateobject, ok := date.(time.Time) |
||||
if ok { |
||||
return dateobject, nil |
||||
} |
||||
|
||||
return time.Time{}, errors.New("unable to parse date") |
||||
} |
@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
|
||||
package models |
||||
|
||||
import ( |
||||
"database/sql" |
||||
"encoding/json" |
||||
"testing" |
||||
"time" |
||||
) |
||||
|
||||
func TestFlexibleDateParsing(t *testing.T) { |
||||
type testJson struct { |
||||
Testdate FlexibleDate `json:"testdate"` |
||||
} |
||||
|
||||
nullTime := sql.NullTime{Time: time.Unix(1591614434, 0), Valid: true} |
||||
testNullTime, err := FlexibleDateParse(nullTime) |
||||
if err != nil { |
||||
t.Error(err) |
||||
} |
||||
|
||||
if testNullTime.Unix() != nullTime.Time.Unix() { |
||||
t.Errorf("Expected %d but got %d", nullTime.Time.Unix(), testNullTime.Unix()) |
||||
} |
||||
|
||||
testStrings := map[string]time.Time{ |
||||
"2023-08-10 17:40:15.376736475-07:00": time.Unix(1691714415, 0), |
||||
} |
||||
|
||||
for testString, expectedTime := range testStrings { |
||||
testJsonString := `{"testdate":"` + testString + `"}` |
||||
response := testJson{} |
||||
|
||||
err := json.Unmarshal([]byte(testJsonString), &response) |
||||
if err != nil { |
||||
t.Error(err) |
||||
} |
||||
|
||||
if response.Testdate.Time.Unix() != expectedTime.Unix() { |
||||
t.Errorf("Expected %d but got %d", expectedTime.Unix(), response.Testdate.Time.Unix()) |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,141 @@
@@ -0,0 +1,141 @@
|
||||
package replays |
||||
|
||||
import ( |
||||
"context" |
||||
"database/sql" |
||||
"fmt" |
||||
"time" |
||||
|
||||
"github.com/owncast/owncast/core/data" |
||||
"github.com/owncast/owncast/db" |
||||
"github.com/owncast/owncast/utils" |
||||
"github.com/pkg/errors" |
||||
"github.com/teris-io/shortid" |
||||
) |
||||
|
||||
// Clip represents a clip that has been created from a stream.
|
||||
// A clip is a subset of a stream that has has start and end seconds
|
||||
// relative to the start of the stream.
|
||||
type Clip struct { |
||||
ID string `json:"id"` |
||||
StreamId string `json:"stream_id"` |
||||
ClippedBy string `json:"clipped_by,omitempty"` |
||||
ClipTitle string `json:"title,omitempty"` |
||||
StreamTitle string `json:"stream_title,omitempty"` |
||||
RelativeStartTime float32 `json:"relativeStartTime"` |
||||
RelativeEndTime float32 `json:"relativeEndTime"` |
||||
DurationSeconds int `json:"durationSeconds"` |
||||
Manifest string `json:"manifest,omitempty"` |
||||
Timestamp time.Time `json:"timestamp"` |
||||
} |
||||
|
||||
// GetClips will return all clips that have been recorded.
|
||||
func GetAllClips() ([]*Clip, error) { |
||||
clips, err := data.GetDatastore().GetQueries().GetAllClips(context.Background()) |
||||
if err != nil { |
||||
return nil, errors.WithMessage(err, "failure to get clips") |
||||
} |
||||
|
||||
response := []*Clip{} |
||||
for _, clip := range clips { |
||||
s := Clip{ |
||||
ID: clip.ID, |
||||
ClipTitle: clip.ClipTitle.String, |
||||
StreamId: clip.StreamID, |
||||
StreamTitle: clip.StreamTitle.String, |
||||
RelativeStartTime: float32(clip.RelativeStartTime.Float64), |
||||
RelativeEndTime: float32(clip.RelativeEndTime.Float64), |
||||
DurationSeconds: int(clip.DurationSeconds), |
||||
Timestamp: clip.Timestamp.Time, |
||||
Manifest: fmt.Sprintf("/clip/%s", clip.ID), |
||||
} |
||||
response = append(response, &s) |
||||
} |
||||
return response, nil |
||||
} |
||||
|
||||
// GetAllClipsForStream will return all clips that have been recorded for a stream.
|
||||
func GetAllClipsForStream(streamId string) ([]*Clip, error) { |
||||
clips, err := data.GetDatastore().GetQueries().GetAllClipsForStream(context.Background(), streamId) |
||||
if err != nil { |
||||
return nil, errors.WithMessage(err, "failure to get clips") |
||||
} |
||||
|
||||
response := []*Clip{} |
||||
for _, clip := range clips { |
||||
s := Clip{ |
||||
ID: clip.ClipID, |
||||
ClipTitle: clip.ClipTitle.String, |
||||
StreamTitle: clip.StreamTitle.String, |
||||
RelativeStartTime: float32(clip.RelativeStartTime.Float64), |
||||
RelativeEndTime: float32(clip.RelativeEndTime.Float64), |
||||
Timestamp: clip.Timestamp.Time, |
||||
Manifest: fmt.Sprintf("/clips/%s", clip.ClipID), |
||||
} |
||||
response = append(response, &s) |
||||
} |
||||
return response, nil |
||||
} |
||||
|
||||
// AddClipForStream will save a new clip for a stream.
|
||||
func AddClipForStream(streamId, clipTitle, clippedBy string, relativeStartTimeSeconds, relativeEndTimeSeconds float32) (string, int, error) { |
||||
playlistGenerator := NewPlaylistGenerator() |
||||
|
||||
// Verify this stream exists
|
||||
if _, err := playlistGenerator.GetStream(streamId); err != nil { |
||||
return "", 0, errors.WithMessage(err, "stream not found") |
||||
} |
||||
|
||||
// Verify this stream has at least one output configuration.
|
||||
configs, err := playlistGenerator.GetConfigurationsForStream(streamId) |
||||
if err != nil { |
||||
return "", 0, errors.WithMessage(err, "unable to get configurations for stream") |
||||
} |
||||
|
||||
if len(configs) == 0 { |
||||
return "", 0, errors.New("no configurations found for stream") |
||||
} |
||||
|
||||
// We want the start and end seconds to be aligned to the segment so
|
||||
// round up and down the values to get a fully inclusive segment range.
|
||||
config := configs[0] |
||||
segmentDuration := int(config.SegmentDuration) |
||||
|
||||
updatedRelativeStartTimeSeconds := utils.RoundDownToNearest(relativeStartTimeSeconds, segmentDuration) |
||||
updatedRelativeEndTimeSeconds := utils.RoundUpToNearest(relativeEndTimeSeconds, segmentDuration) |
||||
clipId := shortid.MustGenerate() |
||||
duration := updatedRelativeEndTimeSeconds - updatedRelativeStartTimeSeconds |
||||
|
||||
err = data.GetDatastore().GetQueries().InsertClip(context.Background(), db.InsertClipParams{ |
||||
ID: clipId, |
||||
StreamID: streamId, |
||||
ClipTitle: sql.NullString{String: clipTitle, Valid: clipTitle != ""}, |
||||
RelativeStartTime: sql.NullFloat64{Float64: float64(updatedRelativeStartTimeSeconds), Valid: true}, |
||||
RelativeEndTime: sql.NullFloat64{Float64: float64(updatedRelativeEndTimeSeconds), Valid: true}, |
||||
Timestamp: sql.NullTime{Time: time.Now(), Valid: true}, |
||||
}) |
||||
if err != nil { |
||||
return "", 0, errors.WithMessage(err, "failure to add clip") |
||||
} |
||||
|
||||
return clipId, duration, nil |
||||
} |
||||
|
||||
// GetFinalSegmentForStream will return the final known segment for a stream.
|
||||
func GetFinalSegmentForStream(streamId string) (*HLSSegment, error) { |
||||
segmentResponse, err := data.GetDatastore().GetQueries().GetFinalSegmentForStream(context.Background(), streamId) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "unable to get final segment for stream") |
||||
} |
||||
|
||||
segment := HLSSegment{ |
||||
ID: segmentResponse.ID, |
||||
StreamID: segmentResponse.StreamID, |
||||
OutputConfigurationID: segmentResponse.OutputConfigurationID, |
||||
Path: segmentResponse.Path, |
||||
RelativeTimestamp: segmentResponse.RelativeTimestamp, |
||||
Timestamp: segmentResponse.Timestamp.Time, |
||||
} |
||||
|
||||
return &segment, nil |
||||
} |
@ -0,0 +1,122 @@
@@ -0,0 +1,122 @@
|
||||
package replays |
||||
|
||||
import ( |
||||
"context" |
||||
"database/sql" |
||||
"strconv" |
||||
"strings" |
||||
"time" |
||||
|
||||
"github.com/owncast/owncast/core/data" |
||||
"github.com/owncast/owncast/db" |
||||
"github.com/owncast/owncast/utils" |
||||
"github.com/teris-io/shortid" |
||||
|
||||
log "github.com/sirupsen/logrus" |
||||
) |
||||
|
||||
type HLSRecorder struct { |
||||
streamID string |
||||
startTime time.Time |
||||
|
||||
// The video variant configurations that were used for this stream.
|
||||
outputConfigurations []HLSOutputConfiguration |
||||
|
||||
datastore *data.Datastore |
||||
} |
||||
|
||||
// NewRecording returns a new instance of the HLS recorder.
|
||||
func NewRecording(streamID string) *HLSRecorder { |
||||
// We don't support replaying offline clips.
|
||||
if streamID == "offline" { |
||||
return nil |
||||
} |
||||
|
||||
log.Infoln("Recording replay of this stream:", streamID) |
||||
|
||||
h := HLSRecorder{ |
||||
streamID: streamID, |
||||
startTime: time.Now(), |
||||
datastore: data.GetDatastore(), |
||||
} |
||||
|
||||
outputs := data.GetStreamOutputVariants() |
||||
latency := data.GetStreamLatencyLevel() |
||||
|
||||
streamTitle := data.GetStreamTitle() |
||||
validTitle := streamTitle != "" |
||||
|
||||
if err := h.datastore.GetQueries().InsertStream(context.Background(), db.InsertStreamParams{ |
||||
ID: streamID, |
||||
StartTime: sql.NullTime{Time: h.startTime, Valid: true}, |
||||
StreamTitle: sql.NullString{String: streamTitle, Valid: validTitle}, |
||||
}); err != nil { |
||||
log.Panicln(err) |
||||
} |
||||
|
||||
// Create a reference of the output configurations that were used for this stream.
|
||||
for variantId, o := range outputs { |
||||
configId := shortid.MustGenerate() |
||||
|
||||
if err := h.datastore.GetQueries().InsertOutputConfiguration(context.Background(), db.InsertOutputConfigurationParams{ |
||||
ID: configId, |
||||
Name: o.Name, |
||||
StreamID: streamID, |
||||
VariantID: strconv.Itoa(variantId), |
||||
SegmentDuration: int32(latency.SecondsPerSegment), |
||||
Bitrate: int32(o.VideoBitrate), |
||||
Framerate: int32(o.Framerate), |
||||
ResolutionWidth: sql.NullInt32{Int32: int32(o.ScaledWidth), Valid: true}, |
||||
ResolutionHeight: sql.NullInt32{Int32: int32(o.ScaledHeight), Valid: true}, |
||||
Timestamp: sql.NullTime{Time: time.Now(), Valid: true}, |
||||
}); err != nil { |
||||
log.Panicln(err) |
||||
} |
||||
|
||||
h.outputConfigurations = append(h.outputConfigurations, HLSOutputConfiguration{ |
||||
ID: configId, |
||||
Name: o.Name, |
||||
VideoBitrate: o.VideoBitrate, |
||||
ScaledWidth: o.ScaledWidth, |
||||
ScaledHeight: o.ScaledHeight, |
||||
Framerate: o.Framerate, |
||||
SegmentDuration: float64(latency.SegmentCount), |
||||
}) |
||||
} |
||||
return &h |
||||
} |
||||
|
||||
// SegmentWritten is called when a segment is written to disk.
|
||||
func (h *HLSRecorder) SegmentWritten(path string) { |
||||
outputConfigurationIndexString := utils.GetIndexFromFilePath(path) |
||||
outputConfigurationIndex, err := strconv.Atoi(outputConfigurationIndexString) |
||||
if err != nil { |
||||
log.Errorln("HLSRecorder segmentWritten error:", err) |
||||
return |
||||
} |
||||
|
||||
p := strings.ReplaceAll(path, "data/", "") |
||||
relativeTimestamp := time.Since(h.startTime) |
||||
|
||||
if err := h.datastore.GetQueries().InsertSegment(context.Background(), db.InsertSegmentParams{ |
||||
ID: shortid.MustGenerate(), |
||||
StreamID: h.streamID, |
||||
OutputConfigurationID: h.outputConfigurations[outputConfigurationIndex].ID, |
||||
Path: p, |
||||
RelativeTimestamp: float32(relativeTimestamp.Seconds()), |
||||
Timestamp: sql.NullTime{Time: time.Now(), Valid: true}, |
||||
}); err != nil { |
||||
log.Errorln(err) |
||||
} |
||||
} |
||||
|
||||
// StreamEnded is called when a stream is ended so the end time can be noted
|
||||
// in the stream's metadata.
|
||||
func (h *HLSRecorder) StreamEnded() { |
||||
if err := h.datastore.GetQueries().SetStreamEnded(context.Background(), db.SetStreamEndedParams{ |
||||
ID: h.streamID, |
||||
EndTime: sql.NullTime{Time: time.Now(), Valid: true}, |
||||
}); err != nil { |
||||
log.Errorln(err) |
||||
} |
||||
} |
@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
|
||||
package replays |
||||
|
||||
import "time" |
||||
|
||||
// HLSSegment represents a single HLS segment.
|
||||
type HLSSegment struct { |
||||
ID string |
||||
StreamID string |
||||
Timestamp time.Time |
||||
RelativeTimestamp float32 |
||||
OutputConfigurationID string |
||||
Path string |
||||
} |
@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
|
||||
package replays |
||||
|
||||
import ( |
||||
"bytes" |
||||
"fmt" |
||||
|
||||
"github.com/grafov/m3u8" |
||||
) |
||||
|
||||
// MediaPlaylistAllowCacheTag is a custom tag to explicitly state that this
|
||||
// playlist is allowed to be cached.
|
||||
type MediaPlaylistAllowCacheTag struct { |
||||
Type string |
||||
} |
||||
|
||||
// TagName should return the full tag identifier including the leading
|
||||
// '#' and trailing ':' if the tag also contains a value or attribute
|
||||
// list.
|
||||
func (tag *MediaPlaylistAllowCacheTag) TagName() string { |
||||
return "#EXT-X-ALLOW-CACHE" |
||||
} |
||||
|
||||
// Decode decodes the input line. The line will be the entire matched
|
||||
// line, including the identifier.
|
||||
func (tag *MediaPlaylistAllowCacheTag) Decode(line string) (m3u8.CustomTag, error) { |
||||
_, err := fmt.Sscanf(line, "#EXT-X-ALLOW-CACHE") |
||||
|
||||
return tag, err |
||||
} |
||||
|
||||
// SegmentTag specifies that this tag is not for segments.
|
||||
func (tag *MediaPlaylistAllowCacheTag) SegmentTag() bool { |
||||
return false |
||||
} |
||||
|
||||
// Encode formats the structure to the text result.
|
||||
func (tag *MediaPlaylistAllowCacheTag) Encode() *bytes.Buffer { |
||||
buf := new(bytes.Buffer) |
||||
|
||||
buf.WriteString(tag.TagName()) |
||||
buf.WriteString(tag.Type) |
||||
|
||||
return buf |
||||
} |
||||
|
||||
// String implements Stringer interface.
|
||||
func (tag *MediaPlaylistAllowCacheTag) String() string { |
||||
return tag.Encode().String() |
||||
} |
@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
|
||||
package replays |
||||
|
||||
import "github.com/pkg/errors" |
||||
|
||||
type HLSOutputConfiguration struct { |
||||
ID string |
||||
StreamId string |
||||
VariantId string |
||||
Name string |
||||
VideoBitrate int |
||||
ScaledWidth int |
||||
ScaledHeight int |
||||
Framerate int |
||||
SegmentDuration float64 |
||||
} |
||||
|
||||
func (config *HLSOutputConfiguration) Validate() error { |
||||
if config.VideoBitrate == 0 { |
||||
return errors.New("video bitrate is unavailable") |
||||
} |
||||
|
||||
if config.Framerate == 0 { |
||||
return errors.New("video framerate is unavailable") |
||||
} |
||||
|
||||
return nil |
||||
} |
@ -0,0 +1,146 @@
@@ -0,0 +1,146 @@
|
||||
package replays |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
"strings" |
||||
"time" |
||||
|
||||
"github.com/grafov/m3u8" |
||||
"github.com/pkg/errors" |
||||
) |
||||
|
||||
// GetConfigurationsForStream returns the output configurations for a given stream.
|
||||
func (p *PlaylistGenerator) GetConfigurationsForStream(streamId string) ([]*HLSOutputConfiguration, error) { |
||||
outputConfigRows, err := p.datastore.GetQueries().GetOutputConfigurationsForStreamId(context.Background(), streamId) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "failed to get output configurations for stream") |
||||
} |
||||
|
||||
outputConfigs := []*HLSOutputConfiguration{} |
||||
for _, row := range outputConfigRows { |
||||
config := &HLSOutputConfiguration{ |
||||
ID: row.ID, |
||||
StreamId: streamId, |
||||
VariantId: row.VariantID, |
||||
Name: row.Name, |
||||
VideoBitrate: int(row.Bitrate), |
||||
Framerate: int(row.Framerate), |
||||
ScaledHeight: int(row.ResolutionWidth.Int32), |
||||
ScaledWidth: int(row.ResolutionHeight.Int32), |
||||
SegmentDuration: float64(row.SegmentDuration), |
||||
} |
||||
outputConfigs = append(outputConfigs, config) |
||||
} |
||||
|
||||
return outputConfigs, nil |
||||
} |
||||
|
||||
func (p *PlaylistGenerator) createMediaPlaylistForConfigurationAndSegments(configuration *HLSOutputConfiguration, startTime time.Time, inProgress bool, segments []HLSSegment) (*m3u8.MediaPlaylist, error) { |
||||
playlistSize := len(segments) |
||||
segmentDuration := configuration.SegmentDuration |
||||
playlist, err := m3u8.NewMediaPlaylist(0, uint(playlistSize)) |
||||
|
||||
playlist.TargetDuration = configuration.SegmentDuration |
||||
|
||||
if !inProgress { |
||||
playlist.MediaType = m3u8.VOD |
||||
} else { |
||||
playlist.MediaType = m3u8.EVENT |
||||
} |
||||
|
||||
// Add the segments to the playlist.
|
||||
for index, segment := range segments { |
||||
// If it's a URL leave it as is, if it's a local path then append a slash.
|
||||
path := segment.Path |
||||
if !strings.HasPrefix(path, "http") { |
||||
path = "/" + path |
||||
} |
||||
|
||||
mediaSegment := m3u8.MediaSegment{ |
||||
URI: path, |
||||
Duration: segmentDuration, |
||||
SeqId: uint64(index), |
||||
ProgramDateTime: segment.Timestamp, |
||||
} |
||||
if err := playlist.AppendSegment(&mediaSegment); err != nil { |
||||
return nil, errors.Wrap(err, "failed to append segment to recording playlist") |
||||
} |
||||
} |
||||
|
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
// Configure the properties of this media playlist.
|
||||
if err := playlist.SetProgramDateTime(startTime); err != nil { |
||||
return nil, errors.Wrap(err, "failed to set media playlist program date time") |
||||
} |
||||
|
||||
// Our live output is specified as v6, so let's match it to be as close as
|
||||
// possible to what we're doing for live streams.
|
||||
playlist.SetVersion(6) |
||||
|
||||
if !inProgress { |
||||
// Specify explicitly that the playlist content is allowed to be cached.
|
||||
// However, if in-progress recordings are supported this should not be enabled
|
||||
// in order for the playlist to be updated with new segments. inProgress is
|
||||
// determined by seeing if the stream has an endTime or not.
|
||||
playlist.SetCustomTag(&MediaPlaylistAllowCacheTag{}) |
||||
|
||||
// Set the ENDLIST tag and close the playlist for writing if the stream is
|
||||
// not still in progress.
|
||||
playlist.Close() |
||||
} |
||||
|
||||
return playlist, nil |
||||
} |
||||
|
||||
func (p *PlaylistGenerator) createNewMasterPlaylist() *m3u8.MasterPlaylist { |
||||
playlist := m3u8.NewMasterPlaylist() |
||||
playlist.SetIndependentSegments(true) |
||||
playlist.SetVersion(6) |
||||
|
||||
return playlist |
||||
} |
||||
|
||||
// GetAllSegmentsForOutputConfiguration returns all the segments for a given output config.
|
||||
func (p *PlaylistGenerator) GetAllSegmentsForOutputConfiguration(outputId string) ([]HLSSegment, error) { |
||||
segmentRows, err := p.datastore.GetQueries().GetSegmentsForOutputId(context.Background(), outputId) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "failed to get segments for output config") |
||||
} |
||||
|
||||
segments := []HLSSegment{} |
||||
for _, row := range segmentRows { |
||||
segment := HLSSegment{ |
||||
ID: row.ID, |
||||
StreamID: row.StreamID, |
||||
OutputConfigurationID: row.OutputConfigurationID, |
||||
Timestamp: row.Timestamp.Time, |
||||
Path: row.Path, |
||||
} |
||||
segments = append(segments, segment) |
||||
} |
||||
|
||||
return segments, nil |
||||
} |
||||
|
||||
func (p *PlaylistGenerator) getMediaPlaylistParamsForConfig(config *HLSOutputConfiguration) m3u8.VariantParams { |
||||
params := m3u8.VariantParams{ |
||||
ProgramId: 1, |
||||
Name: config.Name, |
||||
FrameRate: float64(config.Framerate), |
||||
Bandwidth: uint32(config.VideoBitrate * 1000), |
||||
// Match what is generated in our live playlists.
|
||||
Codecs: "avc1.64001f,mp4a.40.2", |
||||
} |
||||
|
||||
// If both the width and height are set then we can set that as
|
||||
// the resolution in the media playlist.
|
||||
if config.ScaledHeight > 0 && config.ScaledWidth > 0 { |
||||
params.Resolution = fmt.Sprintf("%dx%d", config.ScaledWidth, config.ScaledHeight) |
||||
} |
||||
|
||||
return params |
||||
} |
@ -0,0 +1,136 @@
@@ -0,0 +1,136 @@
|
||||
package replays |
||||
|
||||
import ( |
||||
"testing" |
||||
"time" |
||||
|
||||
"github.com/grafov/m3u8" |
||||
) |
||||
|
||||
var ( |
||||
generator = NewPlaylistGenerator() |
||||
config = []HLSOutputConfiguration{ |
||||
{ |
||||
ID: "1", |
||||
VideoBitrate: 1000, |
||||
Framerate: 30, |
||||
}, |
||||
{ |
||||
ID: "2", |
||||
VideoBitrate: 2000, |
||||
Framerate: 30, |
||||
}, |
||||
} |
||||
) |
||||
|
||||
var segments = []HLSSegment{ |
||||
{ |
||||
ID: "testSegmentId", |
||||
StreamID: "testStreamId", |
||||
Timestamp: time.Now(), |
||||
OutputConfigurationID: "testOutputConfigId", |
||||
Path: "hls/testStreamId/testOutputConfigId/testSegmentId.ts", |
||||
}, |
||||
} |
||||
|
||||
func TestMasterPlaylist(t *testing.T) { |
||||
playlist := generator.createNewMasterPlaylist() |
||||
|
||||
mediaPlaylists, err := generator.createMediaPlaylistForConfigurationAndSegments(&config[0], time.Now(), false, segments) |
||||
playlist.Append("test", mediaPlaylists, m3u8.VariantParams{ |
||||
Bandwidth: uint32(config[0].VideoBitrate), |
||||
FrameRate: float64(config[0].Framerate), |
||||
}) |
||||
mediaPlaylists.Close() |
||||
|
||||
if err != nil { |
||||
t.Error(err) |
||||
} |
||||
|
||||
if playlist.Version() != 6 { |
||||
t.Error("expected version 6, got", playlist.Version()) |
||||
} |
||||
|
||||
if !playlist.IndependentSegments() { |
||||
t.Error("expected independent segments") |
||||
} |
||||
|
||||
if playlist.Variants[0].Bandwidth != uint32(config[0].VideoBitrate) { |
||||
t.Error("expected bandwidth", config[0].VideoBitrate, "got", playlist.Variants[0].Bandwidth) |
||||
} |
||||
|
||||
if playlist.Variants[0].FrameRate != float64(config[0].Framerate) { |
||||
t.Error("expected framerate", config[0].Framerate, "got", playlist.Variants[0].FrameRate) |
||||
} |
||||
} |
||||
|
||||
func TestCompletedMediaPlaylist(t *testing.T) { |
||||
startTime := segments[0].Timestamp |
||||
conf := config[0] |
||||
|
||||
// Create a completed media playlist.
|
||||
playlist, err := generator.createMediaPlaylistForConfigurationAndSegments(&conf, startTime, false, segments) |
||||
if err != nil { |
||||
t.Error(err) |
||||
} |
||||
|
||||
if playlist.TargetDuration != conf.SegmentDuration { |
||||
t.Error("expected target duration", conf.SegmentDuration, "got", playlist.TargetDuration) |
||||
} |
||||
|
||||
// Verify it's marked as cachable.
|
||||
if playlist.Custom["#EXT-X-ALLOW-CACHE"].String() != "#EXT-X-ALLOW-CACHE" { |
||||
t.Error("expected cachable playlist, tag not set") |
||||
} |
||||
|
||||
// Verify it has the correct number of segments in the media playlist.
|
||||
if int(playlist.Count()) != len(segments) { |
||||
t.Error("expected", len(segments), "segments, got", playlist.Count()) |
||||
} |
||||
|
||||
// Test the playlist version.
|
||||
if playlist.Version() != 6 { |
||||
t.Error("expected version 6, got", playlist.Version()) |
||||
} |
||||
|
||||
// Verify the playlist type
|
||||
if playlist.MediaType != m3u8.VOD { |
||||
t.Error("expected VOD playlist type, got type", playlist.MediaType) |
||||
} |
||||
|
||||
// Verify the first segment URI.
|
||||
if playlist.Segments[0].URI != "/"+segments[0].Path { |
||||
t.Error("expected segment URI", segments[0].Path, "got", playlist.Segments[0].URI) |
||||
} |
||||
} |
||||
|
||||
func TestInProgressMediaPlaylist(t *testing.T) { |
||||
startTime := segments[0].Timestamp |
||||
conf := config[0] |
||||
|
||||
// Create a completed media playlist.
|
||||
playlist, err := generator.createMediaPlaylistForConfigurationAndSegments(&conf, startTime, true, segments) |
||||
if err != nil { |
||||
t.Error(err) |
||||
} |
||||
|
||||
// Verify it's marked as cachable.
|
||||
if playlist.Custom != nil && playlist.Custom["#EXT-X-ALLOW-CACHE"].String() == "#EXT-X-ALLOW-CACHE" { |
||||
t.Error("expected non-achable playlist when stream is still in progress") |
||||
} |
||||
|
||||
// Verify it has the correct number of segments in the media playlist.
|
||||
if int(playlist.Count()) != len(segments) { |
||||
t.Error("expected", len(segments), "segments, got", playlist.Count()) |
||||
} |
||||
|
||||
// Test the playlist version.
|
||||
if playlist.Version() != 6 { |
||||
t.Error("expected version 6, got", playlist.Version()) |
||||
} |
||||
|
||||
// Verify the playlist type
|
||||
if playlist.MediaType != m3u8.EVENT { |
||||
t.Error("expected EVENT playlist type, got type", playlist.MediaType) |
||||
} |
||||
} |
@ -0,0 +1,154 @@
@@ -0,0 +1,154 @@
|
||||
package replays |
||||
|
||||
import ( |
||||
"context" |
||||
"database/sql" |
||||
"fmt" |
||||
"path/filepath" |
||||
"testing" |
||||
"time" |
||||
|
||||
"github.com/owncast/owncast/core/data" |
||||
"github.com/owncast/owncast/db" |
||||
"github.com/owncast/owncast/utils" |
||||
log "github.com/sirupsen/logrus" |
||||
"github.com/teris-io/shortid" |
||||
) |
||||
|
||||
var ( |
||||
fakeStreamId = shortid.MustGenerate() |
||||
fakeSegmentCount = 300 |
||||
fakeSegmentDuration = 0 |
||||
fakeStreamStartTime = time.Now() |
||||
fakeConfigId = "" |
||||
fakeClipper = shortid.MustGenerate() |
||||
fakeClipStartTime = 10 |
||||
fakeClipEndTime = 15 |
||||
) |
||||
|
||||
func TestMain(m *testing.M) { |
||||
if err := data.SetupPersistence(":memory:"); err != nil { |
||||
panic(err) |
||||
} |
||||
|
||||
Setup() |
||||
populateFakeStream() |
||||
m.Run() |
||||
} |
||||
|
||||
func populateFakeStream() { |
||||
queries := data.GetDatastore().GetQueries() |
||||
|
||||
recording := NewRecording(fakeStreamId) |
||||
fakeConfigId = recording.outputConfigurations[0].ID |
||||
fakeSegmentDuration = data.GetStreamLatencyLevel().SecondsPerSegment // Seconds
|
||||
|
||||
for i := 0; i < fakeSegmentCount; i++ { |
||||
fakeSegmentName := fmt.Sprintf("%s-%d.ts", fakeStreamId, i) |
||||
if err := queries.InsertSegment(context.Background(), db.InsertSegmentParams{ |
||||
ID: shortid.MustGenerate(), |
||||
StreamID: fakeStreamId, |
||||
OutputConfigurationID: fakeConfigId, |
||||
Path: filepath.Join(fakeStreamId, fakeConfigId, "0", fakeSegmentName), |
||||
RelativeTimestamp: float32(i * fakeSegmentDuration), |
||||
Timestamp: sql.NullTime{Time: fakeStreamStartTime.Add(time.Duration(fakeSegmentDuration * i)), Valid: true}, |
||||
}); err != nil { |
||||
log.Errorln(err) |
||||
} |
||||
} |
||||
|
||||
if err := queries.SetStreamEnded(context.Background(), db.SetStreamEndedParams{ |
||||
ID: fakeStreamId, |
||||
EndTime: sql.NullTime{Time: fakeStreamStartTime.Add(time.Duration(fakeSegmentDuration * fakeSegmentCount)), Valid: true}, |
||||
}); err != nil { |
||||
log.Errorln(err) |
||||
} |
||||
} |
||||
|
||||
func TestStream(t *testing.T) { |
||||
playlist := NewPlaylistGenerator() |
||||
stream, err := playlist.GetStream(fakeStreamId) |
||||
if err != nil { |
||||
t.Error(err) |
||||
} |
||||
|
||||
if stream.ID != fakeStreamId { |
||||
t.Error("expected stream id", fakeStreamId, "got", stream.ID) |
||||
} |
||||
} |
||||
|
||||
func TestPlaylist(t *testing.T) { |
||||
playlist := NewPlaylistGenerator() |
||||
p, err := playlist.GenerateMediaPlaylistForStreamAndConfiguration(fakeStreamId, fakeConfigId) |
||||
if p == nil { |
||||
t.Error("expected playlist") |
||||
} |
||||
|
||||
if err != nil { |
||||
t.Error(err) |
||||
} |
||||
|
||||
if len(p.Segments) != fakeSegmentCount { |
||||
t.Error("expected", fakeSegmentCount, "segments, got", len(p.Segments)) |
||||
} |
||||
} |
||||
|
||||
func TestClip(t *testing.T) { |
||||
segmentDuration := data.GetStreamLatencyLevel().SecondsPerSegment |
||||
playlist := NewPlaylistGenerator() |
||||
clipId, _, err := AddClipForStream(fakeStreamId, "test clip", fakeClipper, float32(fakeClipStartTime), float32(fakeClipEndTime)) |
||||
if err != nil { |
||||
t.Error(err) |
||||
} |
||||
|
||||
clips, err := GetAllClips() |
||||
if err != nil { |
||||
t.Error(err) |
||||
} |
||||
|
||||
if len(clips) != 1 { |
||||
t.Error("expected 1 clip, got", len(clips)) |
||||
} |
||||
|
||||
clip := clips[0] |
||||
|
||||
if clip.ID != clipId { |
||||
t.Error("expected clip id", clipId, "got", clip.ID) |
||||
} |
||||
|
||||
if clip.Manifest != fmt.Sprintf("/clip/%s", clipId) { |
||||
t.Error("expected manifest id", fmt.Sprintf("/clip/%s", clipId), "got", clip.Manifest) |
||||
} |
||||
|
||||
expectedStartTime := float32(utils.RoundDownToNearest(float32(fakeClipStartTime), segmentDuration)) |
||||
if clip.RelativeStartTime != expectedStartTime { |
||||
t.Error("expected clip start time", fakeClipStartTime, "got", clip.RelativeStartTime) |
||||
} |
||||
|
||||
expectedEndTime := float32(utils.RoundUpToNearest(float32(fakeClipEndTime), segmentDuration)) |
||||
if clip.RelativeEndTime != expectedEndTime { |
||||
t.Error("expected clip end time", fakeClipEndTime, "got", clip.RelativeEndTime) |
||||
} |
||||
|
||||
expectedDuration := expectedEndTime - expectedStartTime |
||||
if float32(clip.DurationSeconds) != expectedDuration { |
||||
t.Error("expected clip duration", expectedDuration, "got", clip.DurationSeconds) |
||||
} |
||||
|
||||
p, err := playlist.GenerateMediaPlaylistForClipAndConfiguration(clipId, fakeConfigId) |
||||
if err != nil { |
||||
t.Error(err) |
||||
} |
||||
if p == nil { |
||||
t.Error("expected playlist") |
||||
} |
||||
|
||||
expectedSegmentCount := 3 |
||||
if len(p.Segments) != expectedSegmentCount { |
||||
t.Error("expected", expectedSegmentCount, "segments, got", len(p.Segments)) |
||||
} |
||||
|
||||
if p.TargetDuration != float64(fakeSegmentDuration) { |
||||
t.Error("expected target duration of", fakeSegmentDuration, "got", p.TargetDuration) |
||||
} |
||||
} |
@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
|
||||
package replays |
||||
|
||||
import ( |
||||
"context" |
||||
|
||||
"github.com/owncast/owncast/core/data" |
||||
log "github.com/sirupsen/logrus" |
||||
) |
||||
|
||||
// Setup will setup the replay package.
|
||||
func Setup() { |
||||
fixUnfinishedStreams() |
||||
} |
||||
|
||||
// fixUnfinishedStreams will find streams with no end time and attempt to
|
||||
// give them an end time based on the last segment assigned to that stream.
|
||||
func fixUnfinishedStreams() { |
||||
if err := data.GetDatastore().GetQueries().FixUnfinishedStreams(context.Background()); err != nil { |
||||
log.Warnln(err) |
||||
} |
||||
} |
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
package replays |
||||
|
||||
type StorageProvider interface { |
||||
Setup() error |
||||
Save(localFilePath, destinationPath string, retryCount int) (string, error) |
||||
} |
@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
|
||||
package replays |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
"time" |
||||
|
||||
"github.com/owncast/owncast/core/data" |
||||
"github.com/pkg/errors" |
||||
) |
||||
|
||||
type Stream struct { |
||||
ID string `json:"id"` |
||||
Title string `json:"title,omitempty"` |
||||
StartTime time.Time `json:"startTime"` |
||||
EndTime time.Time `json:"endTime,omitempty"` |
||||
InProgress bool `json:"inProgress,omitempty"` |
||||
Manifest string `json:"manifest,omitempty"` |
||||
} |
||||
|
||||
// GetStreams will return all streams that have been recorded.
|
||||
func GetStreams() ([]*Stream, error) { |
||||
streams, err := data.GetDatastore().GetQueries().GetStreams(context.Background()) |
||||
if err != nil { |
||||
return nil, errors.WithMessage(err, "failure to get streams") |
||||
} |
||||
|
||||
response := []*Stream{} |
||||
for _, stream := range streams { |
||||
s := Stream{ |
||||
ID: stream.ID, |
||||
Title: stream.StreamTitle.String, |
||||
StartTime: stream.StartTime.Time, |
||||
EndTime: stream.EndTime.Time, |
||||
InProgress: !stream.EndTime.Valid, |
||||
Manifest: fmt.Sprintf("/replay/%s", stream.ID), |
||||
} |
||||
response = append(response, &s) |
||||
} |
||||
return response, nil |
||||
} |
@ -0,0 +1,142 @@
@@ -0,0 +1,142 @@
|
||||
package replays |
||||
|
||||
import ( |
||||
"context" |
||||
"strings" |
||||
|
||||
"github.com/grafov/m3u8" |
||||
"github.com/owncast/owncast/db" |
||||
"github.com/owncast/owncast/models" |
||||
"github.com/pkg/errors" |
||||
) |
||||
|
||||
// GenerateMasterPlaylistForClip returns a master playlist for a given clip Id.
|
||||
// It includes references to the media playlists for each output configuration.
|
||||
func (p *PlaylistGenerator) GenerateMasterPlaylistForClip(clipId string) (*m3u8.MasterPlaylist, error) { |
||||
clip, err := p.datastore.GetQueries().GetClip(context.Background(), clipId) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "unable to fetch requested clip") |
||||
} |
||||
|
||||
streamId := clip.StreamID |
||||
configs, err := p.GetConfigurationsForStream(streamId) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "failed to get configurations for stream") |
||||
} |
||||
|
||||
// Create the master playlist that will hold the different media playlists.
|
||||
masterPlaylist := p.createNewMasterPlaylist() |
||||
|
||||
// Create the media playlists for each output configuration.
|
||||
for _, config := range configs { |
||||
// Verify the validity of the configuration.
|
||||
if err := config.Validate(); err != nil { |
||||
return nil, errors.Wrap(err, "invalid output configuration") |
||||
} |
||||
|
||||
mediaPlaylist, err := p.GenerateMediaPlaylistForClipAndConfiguration(clipId, config.ID) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "failed to create clip media playlist") |
||||
} |
||||
|
||||
// Append the media playlist to the master playlist.
|
||||
params := p.getMediaPlaylistParamsForConfig(config) |
||||
|
||||
// Add the media playlist to the master playlist.
|
||||
publicPlaylistPath := strings.Join([]string{"/clip", clipId, config.ID}, "/") |
||||
masterPlaylist.Append(publicPlaylistPath, mediaPlaylist, params) |
||||
} |
||||
|
||||
// Return the final master playlist that contains all the media playlists.
|
||||
return masterPlaylist, nil |
||||
} |
||||
|
||||
// GenerateMediaPlaylistForClipAndConfiguration returns a media playlist for a
|
||||
// given clip Id and output configuration.
|
||||
func (p *PlaylistGenerator) GenerateMediaPlaylistForClipAndConfiguration(clipId, outputConfigurationId string) (*m3u8.MediaPlaylist, error) { |
||||
clip, err := p.GetClip(clipId) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "failed to get stream") |
||||
} |
||||
|
||||
config, err := p.GetOutputConfig(outputConfigurationId) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "failed to get output configuration") |
||||
} |
||||
|
||||
clipStartSeconds := clip.RelativeStartTime |
||||
clipEndSeconds := clip.RelativeEndTime |
||||
|
||||
// Fetch all the segments for this configuration.
|
||||
segments, err := p.GetAllSegmentsForOutputConfigurationAndWindow(outputConfigurationId, clipStartSeconds, clipEndSeconds) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "failed to get all clip segments for output configuration") |
||||
} |
||||
|
||||
// Create the media playlist for this configuration and add the segments.
|
||||
mediaPlaylist, err := p.createMediaPlaylistForConfigurationAndSegments(config, clip.Timestamp, false, segments) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "failed to create clip media playlist") |
||||
} |
||||
|
||||
return mediaPlaylist, nil |
||||
} |
||||
|
||||
// GetClip returns a clip by its ID.
|
||||
func (p *PlaylistGenerator) GetClip(clipId string) (*Clip, error) { |
||||
clip, err := p.datastore.GetQueries().GetClip(context.Background(), clipId) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "failed to get clip") |
||||
} |
||||
|
||||
if clip.ClipID == "" { |
||||
return nil, errors.Wrap(err, "failed to get clip") |
||||
} |
||||
|
||||
if !clip.RelativeEndTime.Valid { |
||||
return nil, errors.Wrap(err, "failed to get clip") |
||||
} |
||||
|
||||
timestamp, err := models.FlexibleDateParse(clip.ClipTimestamp) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "failed to parse clip timestamp") |
||||
} |
||||
|
||||
c := Clip{ |
||||
ID: clip.ClipID, |
||||
StreamId: clip.StreamID, |
||||
ClipTitle: clip.ClipTitle.String, |
||||
RelativeStartTime: float32(clip.RelativeStartTime.Float64), |
||||
RelativeEndTime: float32(clip.RelativeEndTime.Float64), |
||||
Timestamp: timestamp, |
||||
} |
||||
|
||||
return &c, nil |
||||
} |
||||
|
||||
// GetAllSegmentsForOutputConfigurationAndWindow returns all the segments for a
|
||||
// given output config and time window.
|
||||
func (p *PlaylistGenerator) GetAllSegmentsForOutputConfigurationAndWindow(configId string, startSeconds, endSeconds float32) ([]HLSSegment, error) { |
||||
segmentRows, err := p.datastore.GetQueries().GetSegmentsForOutputIdAndWindow(context.Background(), db.GetSegmentsForOutputIdAndWindowParams{ |
||||
OutputConfigurationID: configId, |
||||
StartSeconds: startSeconds, |
||||
EndSeconds: endSeconds, |
||||
}) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "failed to get clip segments for output config") |
||||
} |
||||
|
||||
segments := []HLSSegment{} |
||||
for _, row := range segmentRows { |
||||
segment := HLSSegment{ |
||||
ID: row.ID, |
||||
StreamID: row.StreamID, |
||||
OutputConfigurationID: row.OutputConfigurationID, |
||||
Timestamp: row.Timestamp.Time, |
||||
Path: row.Path, |
||||
} |
||||
segments = append(segments, segment) |
||||
} |
||||
|
||||
return segments, nil |
||||
} |
@ -0,0 +1,129 @@
@@ -0,0 +1,129 @@
|
||||
package replays |
||||
|
||||
import ( |
||||
"context" |
||||
"strings" |
||||
|
||||
"github.com/grafov/m3u8" |
||||
"github.com/owncast/owncast/core/data" |
||||
"github.com/owncast/owncast/db" |
||||
"github.com/pkg/errors" |
||||
) |
||||
|
||||
/* |
||||
The PlaylistGenerator is responsible for creating the master and media |
||||
playlists, in order to replay a stream in whole, or part. It requires detailed |
||||
metadata about how the initial live stream was configured, as well as a |
||||
access to every segment that was created during the live stream. |
||||
*/ |
||||
|
||||
type PlaylistGenerator struct { |
||||
datastore *data.Datastore |
||||
} |
||||
|
||||
func NewPlaylistGenerator() *PlaylistGenerator { |
||||
return &PlaylistGenerator{ |
||||
datastore: data.GetDatastore(), |
||||
} |
||||
} |
||||
|
||||
func (p *PlaylistGenerator) GenerateMasterPlaylistForStream(streamId string) (*m3u8.MasterPlaylist, error) { |
||||
// Determine the different output configurations for this stream.
|
||||
configs, err := p.GetConfigurationsForStream(streamId) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "failed to get configurations for stream") |
||||
} |
||||
|
||||
// Create the master playlist that will hold the different media playlists.
|
||||
masterPlaylist := p.createNewMasterPlaylist() |
||||
|
||||
// Create the media playlists for each output configuration.
|
||||
for _, config := range configs { |
||||
// Verify the validity of the configuration.
|
||||
if err := config.Validate(); err != nil { |
||||
return nil, errors.Wrap(err, "invalid output configuration") |
||||
} |
||||
|
||||
mediaPlaylist, err := p.GenerateMediaPlaylistForStreamAndConfiguration(streamId, config.ID) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "failed to create media playlist") |
||||
} |
||||
|
||||
// Append the media playlist to the master playlist.
|
||||
params := p.getMediaPlaylistParamsForConfig(config) |
||||
|
||||
// Add the media playlist to the master playlist.
|
||||
publicPlaylistPath := strings.Join([]string{"/replay", streamId, config.ID}, "/") |
||||
masterPlaylist.Append(publicPlaylistPath, mediaPlaylist, params) |
||||
} |
||||
|
||||
// Return the final master playlist that contains all the media playlists.
|
||||
return masterPlaylist, nil |
||||
} |
||||
|
||||
func (p *PlaylistGenerator) GenerateMediaPlaylistForStreamAndConfiguration(streamId, outputConfigurationId string) (*m3u8.MediaPlaylist, error) { |
||||
stream, err := p.GetStream(streamId) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "failed to get stream") |
||||
} |
||||
|
||||
config, err := p.GetOutputConfig(outputConfigurationId) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "failed to get output configuration") |
||||
} |
||||
|
||||
// Fetch all the segments for this configuration.
|
||||
segments, err := p.GetAllSegmentsForOutputConfiguration(outputConfigurationId) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "failed to get all segments for output configuration") |
||||
} |
||||
|
||||
// Create the media playlist for this configuration and add the segments.
|
||||
mediaPlaylist, err := p.createMediaPlaylistForConfigurationAndSegments(config, stream.StartTime, stream.InProgress, segments) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "failed to create media playlist") |
||||
} |
||||
|
||||
return mediaPlaylist, nil |
||||
} |
||||
|
||||
func (p *PlaylistGenerator) GetStream(streamId string) (*Stream, error) { |
||||
stream, err := p.datastore.GetQueries().GetStreamById(context.Background(), streamId) |
||||
if stream.ID == "" { |
||||
return nil, errors.Wrap(err, "failed to get stream") |
||||
} |
||||
|
||||
s := Stream{ |
||||
ID: stream.ID, |
||||
Title: stream.StreamTitle.String, |
||||
StartTime: stream.StartTime.Time, |
||||
EndTime: stream.EndTime.Time, |
||||
InProgress: !stream.EndTime.Valid, |
||||
} |
||||
|
||||
return &s, nil |
||||
} |
||||
|
||||
func (p *PlaylistGenerator) GetOutputConfig(outputConfigId string) (*HLSOutputConfiguration, error) { |
||||
config, err := p.datastore.GetQueries().GetOutputConfigurationForId(context.Background(), outputConfigId) |
||||
if err != nil { |
||||
return nil, errors.Wrap(err, "failed to get output configuration") |
||||
} |
||||
|
||||
return createConfigFromConfigRow(config), nil |
||||
} |
||||
|
||||
func createConfigFromConfigRow(row db.GetOutputConfigurationForIdRow) *HLSOutputConfiguration { |
||||
config := HLSOutputConfiguration{ |
||||
ID: row.ID, |
||||
StreamId: row.StreamID, |
||||
VariantId: row.VariantID, |
||||
Name: row.Name, |
||||
VideoBitrate: int(row.Bitrate), |
||||
Framerate: int(row.Framerate), |
||||
ScaledHeight: int(row.ResolutionWidth.Int32), |
||||
ScaledWidth: int(row.ResolutionHeight.Int32), |
||||
SegmentDuration: float64(row.SegmentDuration), |
||||
} |
||||
return &config |
||||
} |
File diff suppressed because one or more lines are too long
@ -0,0 +1 @@
@@ -0,0 +1 @@
|
||||
self.__SSG_MANIFEST=new Set,self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB(); |
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
|
||||
{ |
||||
"name": "owncast-test-automation", |
||||
"version": "1.0.0", |
||||
"description": "", |
||||
"main": "index.js", |
||||
"scripts": { |
||||
"test": "jest --bail" |
||||
}, |
||||
"author": "", |
||||
"license": "ISC", |
||||
"dependencies": { |
||||
"m3u8-parser": "^4.7.0", |
||||
"node-fetch": "^2.6.7" |
||||
}, |
||||
"devDependencies": { |
||||
"jest": "^26.6.3" |
||||
} |
||||
} |
@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
|
||||
const m3u8Parser = require('m3u8-parser'); |
||||
const fetch = require('node-fetch'); |
||||
const url = require('url'); |
||||
const { test } = require('@jest/globals'); |
||||
|
||||
const REPLAYS_API = '/api/replays'; |
||||
const TEST_OWNCAST_INSTANCE = 'http://localhost:8080'; |
||||
const HLS_FETCH_ITERATIONS = 5; |
||||
|
||||
jest.setTimeout(40000); |
||||
|
||||
async function getPlaylist(urlString) { |
||||
const response = await fetch(urlString); |
||||
expect(response.status).toBe(200); |
||||
const body = await response.text(); |
||||
|
||||
var parser = new m3u8Parser.Parser(); |
||||
|
||||
parser.push(body); |
||||
parser.end(); |
||||
|
||||
return parser.manifest; |
||||
} |
||||
|
||||
async function getReplaysAPI(urlString) { |
||||
const response = await fetch(urlString); |
||||
expect(response.status).toBe(200); |
||||
const body = await response.text(); |
||||
return body; |
||||
} |
||||
|
||||
function normalizeUrl(urlString, baseUrl) { |
||||
let parsedString = url.parse(urlString); |
||||
if (!parsedString.host) { |
||||
const testInstanceRoot = url.parse(baseUrl); |
||||
parsedString.protocol = testInstanceRoot.protocol; |
||||
parsedString.host = testInstanceRoot.host; |
||||
|
||||
const filename = baseUrl.substring(baseUrl.lastIndexOf('/') + 1); |
||||
parsedString.pathname = |
||||
testInstanceRoot.pathname.replace(filename, '') + urlString; |
||||
} |
||||
return url.format(parsedString).toString(); |
||||
} |
||||
|
||||
// Iterate over an array of video segments and make sure they return back
|
||||
// valid status.
|
||||
async function validateSegments(segments) { |
||||
for (let segment of segments) { |
||||
const res = await fetch(segment); |
||||
expect(res.status).toBe(200); |
||||
} |
||||
} |
||||
|
||||
describe('fetch list of clips', () => { |
||||
const replaysAPIEndpoint = `${TEST_OWNCAST_INSTANCE}${REPLAYS_API}`; |
||||
// var masterPlaylist;
|
||||
// var mediaPlaylistUrl;
|
||||
|
||||
test('fetch replay list', async (done) => { |
||||
console.log(replaysAPIEndpoint); |
||||
try { |
||||
const response = await getReplaysAPI(replaysAPIEndpoint); |
||||
console.log(response); |
||||
} catch (e) { |
||||
console.error('error fetching and parsing master playlist', e); |
||||
} |
||||
|
||||
done(); |
||||
}); |
||||
|
||||
// test('verify there is a media playlist', () => {
|
||||
// // Master playlist should have at least one media playlist.
|
||||
// expect(masterPlaylist.playlists.length).toBe(1);
|
||||
|
||||
// try {
|
||||
// mediaPlaylistUrl = normalizeUrl(
|
||||
// masterPlaylist.playlists[0].uri,
|
||||
// masterPlaylistUrl
|
||||
// );
|
||||
// } catch (e) {
|
||||
// console.error('error fetching and parsing media playlist', e);
|
||||
// }
|
||||
// });
|
||||
|
||||
// test('verify there are segments', async (done) => {
|
||||
// let playlist;
|
||||
// try {
|
||||
// playlist = await getPlaylist(mediaPlaylistUrl);
|
||||
// } catch (e) {
|
||||
// console.error('error verifying segments in media playlist', e);
|
||||
// }
|
||||
|
||||
// const segments = playlist.segments;
|
||||
// expect(segments.length).toBeGreaterThan(0);
|
||||
|
||||
// done();
|
||||
// });
|
||||
|
||||
// // Iterate over segments and make sure they change.
|
||||
// // Use the reported duration of the segment to wait to
|
||||
// // fetch another just like a real HLS player would do.
|
||||
// var lastSegmentUrl;
|
||||
// for (let i = 0; i < HLS_FETCH_ITERATIONS; i++) {
|
||||
// test('fetch and monitor media playlist segments ' + i, async (done) => {
|
||||
// await new Promise((r) => setTimeout(r, 5000));
|
||||
|
||||
// try {
|
||||
// var playlist = await getPlaylist(mediaPlaylistUrl);
|
||||
// } catch (e) {
|
||||
// console.error('error updating media playlist', mediaPlaylistUrl, e);
|
||||
// }
|
||||
|
||||
// const segments = playlist.segments;
|
||||
// const segment = segments[segments.length - 1];
|
||||
// expect(segment.uri).not.toBe(lastSegmentUrl);
|
||||
|
||||
// try {
|
||||
// var segmentUrl = normalizeUrl(segment.uri, mediaPlaylistUrl);
|
||||
// await validateSegments([segmentUrl]);
|
||||
// } catch (e) {
|
||||
// console.error('unable to validate HLS segment', segmentUrl, e);
|
||||
// }
|
||||
|
||||
// lastSegmentUrl = segment.uri;
|
||||
|
||||
// done();
|
||||
// });
|
||||
// }
|
||||
}); |
@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash |
||||
|
||||
set -e |
||||
|
||||
source ../tools.sh |
||||
|
||||
# Install the node test framework |
||||
npm install --silent >/dev/null |
||||
|
||||
install_ffmpeg |
||||
|
||||
start_owncast "--enableReplayFeatures" |
||||
|
||||
start_stream |
||||
|
||||
sleep 10 |
||||
|
||||
# Run tests against a fresh install with no settings. |
||||
npm test |
Loading…
Reference in new issue